factorio-test-cli 2.0.1 → 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/README.md +57 -0
- package/cli-error.js +6 -0
- package/cli.js +18 -7
- 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
|
+
}
|
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
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { spawn } from "child_process";
|
|
2
|
+
import * as path from "path";
|
|
3
|
+
import { fileURLToPath } from "url";
|
|
4
|
+
import BufferLineSplitter from "./buffer-line-splitter.js";
|
|
5
|
+
import { FactorioOutputHandler } from "./factorio-output-parser.js";
|
|
6
|
+
import { OutputPrinter } from "./output-formatter.js";
|
|
7
|
+
import { ProgressRenderer } from "./progress-renderer.js";
|
|
8
|
+
import { TestRunCollector } from "./test-run-collector.js";
|
|
9
|
+
import { CliError } from "./cli-error.js";
|
|
10
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
11
|
+
export function getHeadlessSavePath(overridePath) {
|
|
12
|
+
if (overridePath) {
|
|
13
|
+
return path.resolve(overridePath);
|
|
14
|
+
}
|
|
15
|
+
return path.join(__dirname, "headless-save.zip");
|
|
16
|
+
}
|
|
17
|
+
export function parseResultMessage(message) {
|
|
18
|
+
if (message.endsWith(":focused")) {
|
|
19
|
+
return {
|
|
20
|
+
status: message.slice(0, -":focused".length),
|
|
21
|
+
hasFocusedTests: true,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
return {
|
|
25
|
+
status: message,
|
|
26
|
+
hasFocusedTests: false,
|
|
27
|
+
};
|
|
28
|
+
}
|
|
29
|
+
function createOutputComponents(options) {
|
|
30
|
+
const handler = new FactorioOutputHandler();
|
|
31
|
+
const collector = new TestRunCollector();
|
|
32
|
+
const printer = new OutputPrinter({
|
|
33
|
+
verbose: options.verbose,
|
|
34
|
+
quiet: options.quiet,
|
|
35
|
+
});
|
|
36
|
+
const progress = new ProgressRenderer();
|
|
37
|
+
handler.on("event", (event) => {
|
|
38
|
+
collector.handleEvent(event);
|
|
39
|
+
progress.handleEvent(event);
|
|
40
|
+
if (options.verbose) {
|
|
41
|
+
progress.withPermanentOutput(() => console.log(JSON.stringify(event)));
|
|
42
|
+
}
|
|
43
|
+
});
|
|
44
|
+
handler.on("log", (line) => {
|
|
45
|
+
collector.captureLog(line);
|
|
46
|
+
progress.withPermanentOutput(() => printer.printVerbose(line));
|
|
47
|
+
});
|
|
48
|
+
handler.on("message", (line) => {
|
|
49
|
+
progress.withPermanentOutput(() => printer.printMessage(line));
|
|
50
|
+
});
|
|
51
|
+
collector.on("testFinished", (test) => {
|
|
52
|
+
progress.handleTestFinished(test);
|
|
53
|
+
progress.withPermanentOutput(() => printer.printTestResult(test));
|
|
54
|
+
});
|
|
55
|
+
return { handler, collector, printer, progress };
|
|
56
|
+
}
|
|
57
|
+
export async function runFactorioTestsHeadless(factorioPath, dataDir, savePath, additionalArgs, options) {
|
|
58
|
+
const args = [
|
|
59
|
+
"--benchmark",
|
|
60
|
+
savePath,
|
|
61
|
+
"--benchmark-ticks",
|
|
62
|
+
"1000000000",
|
|
63
|
+
"--mod-directory",
|
|
64
|
+
path.join(dataDir, "mods"),
|
|
65
|
+
"-c",
|
|
66
|
+
path.join(dataDir, "config.ini"),
|
|
67
|
+
...additionalArgs,
|
|
68
|
+
];
|
|
69
|
+
console.log("Running tests (headless)...");
|
|
70
|
+
const factorioProcess = spawn(factorioPath, args, {
|
|
71
|
+
stdio: ["inherit", "pipe", "pipe"],
|
|
72
|
+
});
|
|
73
|
+
const { handler, collector, printer, progress } = createOutputComponents(options);
|
|
74
|
+
let resultMessage;
|
|
75
|
+
let testRunStarted = false;
|
|
76
|
+
let startupTimedOut = false;
|
|
77
|
+
let wasCancelled = false;
|
|
78
|
+
handler.on("result", (msg) => {
|
|
79
|
+
resultMessage = msg;
|
|
80
|
+
progress.finish();
|
|
81
|
+
printer.resetMessage();
|
|
82
|
+
});
|
|
83
|
+
handler.on("event", (event) => {
|
|
84
|
+
if (event.type === "testRunStarted") {
|
|
85
|
+
testRunStarted = true;
|
|
86
|
+
clearTimeout(startupTimeout);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
const startupTimeout = setTimeout(() => {
|
|
90
|
+
if (!testRunStarted) {
|
|
91
|
+
startupTimedOut = true;
|
|
92
|
+
factorioProcess.kill();
|
|
93
|
+
}
|
|
94
|
+
}, 10_000);
|
|
95
|
+
const abortHandler = () => {
|
|
96
|
+
wasCancelled = true;
|
|
97
|
+
factorioProcess.kill();
|
|
98
|
+
};
|
|
99
|
+
options.signal?.addEventListener("abort", abortHandler);
|
|
100
|
+
new BufferLineSplitter(factorioProcess.stdout).on("line", (line) => handler.handleLine(line));
|
|
101
|
+
new BufferLineSplitter(factorioProcess.stderr).on("line", (line) => handler.handleLine(line));
|
|
102
|
+
await new Promise((resolve, reject) => {
|
|
103
|
+
factorioProcess.on("exit", (code, signal) => {
|
|
104
|
+
clearTimeout(startupTimeout);
|
|
105
|
+
options.signal?.removeEventListener("abort", abortHandler);
|
|
106
|
+
if (wasCancelled) {
|
|
107
|
+
resolve();
|
|
108
|
+
}
|
|
109
|
+
else if (startupTimedOut) {
|
|
110
|
+
reject(new CliError("Factorio unresponsive: no test run started within 10 seconds"));
|
|
111
|
+
}
|
|
112
|
+
else if (resultMessage !== undefined) {
|
|
113
|
+
resolve();
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
reject(new CliError(`Factorio exited with code ${code}, signal ${signal}, no result received`));
|
|
117
|
+
}
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
if (wasCancelled) {
|
|
121
|
+
return { status: "cancelled", hasFocusedTests: false };
|
|
122
|
+
}
|
|
123
|
+
const parsed = parseResultMessage(resultMessage);
|
|
124
|
+
return { ...parsed, message: resultMessage, data: collector.getData() };
|
|
125
|
+
}
|
|
126
|
+
export async function runFactorioTestsGraphics(factorioPath, dataDir, savePath, additionalArgs, options) {
|
|
127
|
+
const args = [
|
|
128
|
+
"--load-game",
|
|
129
|
+
savePath,
|
|
130
|
+
"--mod-directory",
|
|
131
|
+
path.join(dataDir, "mods"),
|
|
132
|
+
"-c",
|
|
133
|
+
path.join(dataDir, "config.ini"),
|
|
134
|
+
...additionalArgs,
|
|
135
|
+
];
|
|
136
|
+
console.log("Running tests (graphics)...");
|
|
137
|
+
const factorioProcess = spawn(factorioPath, args, {
|
|
138
|
+
stdio: ["inherit", "pipe", "inherit"],
|
|
139
|
+
});
|
|
140
|
+
const { handler, collector, printer, progress } = createOutputComponents(options);
|
|
141
|
+
let resultMessage;
|
|
142
|
+
let resolvePromise;
|
|
143
|
+
handler.on("result", (msg) => {
|
|
144
|
+
resultMessage = msg;
|
|
145
|
+
progress.finish();
|
|
146
|
+
printer.resetMessage();
|
|
147
|
+
if (options.resolveOnResult && resolvePromise) {
|
|
148
|
+
resolvePromise();
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
new BufferLineSplitter(factorioProcess.stdout).on("line", (line) => handler.handleLine(line));
|
|
152
|
+
await new Promise((resolve, reject) => {
|
|
153
|
+
resolvePromise = resolve;
|
|
154
|
+
factorioProcess.on("exit", (code, signal) => {
|
|
155
|
+
if (resultMessage !== undefined) {
|
|
156
|
+
resolve();
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
reject(new CliError(`Factorio exited with code ${code}, signal ${signal}`));
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
});
|
|
163
|
+
const parsed = parseResultMessage(resultMessage);
|
|
164
|
+
return { ...parsed, message: resultMessage, data: collector.getData() };
|
|
165
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { parseResultMessage } from "./factorio-process.js";
|
|
3
|
+
describe("parseResultMessage", () => {
|
|
4
|
+
it.each([
|
|
5
|
+
["passed", { status: "passed", hasFocusedTests: false }],
|
|
6
|
+
["failed", { status: "failed", hasFocusedTests: false }],
|
|
7
|
+
["todo", { status: "todo", hasFocusedTests: false }],
|
|
8
|
+
["loadError", { status: "loadError", hasFocusedTests: false }],
|
|
9
|
+
["passed:focused", { status: "passed", hasFocusedTests: true }],
|
|
10
|
+
["failed:focused", { status: "failed", hasFocusedTests: true }],
|
|
11
|
+
["todo:focused", { status: "todo", hasFocusedTests: true }],
|
|
12
|
+
])("parses %s", (input, expected) => {
|
|
13
|
+
expect(parseResultMessage(input)).toEqual(expected);
|
|
14
|
+
});
|
|
15
|
+
});
|
package/file-watcher.js
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import * as fs from "fs";
|
|
2
|
+
import { minimatch } from "minimatch";
|
|
3
|
+
export function matchesPattern(filename, patterns) {
|
|
4
|
+
const normalizedFilename = filename.replace(/\\/g, "/");
|
|
5
|
+
return patterns.some((pattern) => minimatch(normalizedFilename, pattern));
|
|
6
|
+
}
|
|
7
|
+
export function watchDirectory(dir, onChange, options) {
|
|
8
|
+
const debounceMs = options.debounceMs ?? 300;
|
|
9
|
+
let timeout;
|
|
10
|
+
const debouncedOnChange = () => {
|
|
11
|
+
if (timeout)
|
|
12
|
+
clearTimeout(timeout);
|
|
13
|
+
timeout = setTimeout(onChange, debounceMs);
|
|
14
|
+
};
|
|
15
|
+
const watcher = fs.watch(dir, { recursive: true }, (_, filename) => {
|
|
16
|
+
if (!filename)
|
|
17
|
+
return;
|
|
18
|
+
if (matchesPattern(filename, options.patterns)) {
|
|
19
|
+
debouncedOnChange();
|
|
20
|
+
}
|
|
21
|
+
});
|
|
22
|
+
return watcher;
|
|
23
|
+
}
|
|
24
|
+
export function watchFile(filePath, onChange, options) {
|
|
25
|
+
const debounceMs = options?.debounceMs ?? 300;
|
|
26
|
+
let timeout;
|
|
27
|
+
const debouncedOnChange = () => {
|
|
28
|
+
if (timeout)
|
|
29
|
+
clearTimeout(timeout);
|
|
30
|
+
timeout = setTimeout(onChange, debounceMs);
|
|
31
|
+
};
|
|
32
|
+
const watcher = fs.watch(filePath, () => {
|
|
33
|
+
debouncedOnChange();
|
|
34
|
+
});
|
|
35
|
+
return watcher;
|
|
36
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { matchesPattern } from "./file-watcher.js";
|
|
3
|
+
describe("matchesPattern", () => {
|
|
4
|
+
const defaultPatterns = ["info.json", "**/*.lua"];
|
|
5
|
+
it.each([
|
|
6
|
+
["info.json", defaultPatterns, true],
|
|
7
|
+
["control.lua", defaultPatterns, true],
|
|
8
|
+
["nested/file.lua", defaultPatterns, true],
|
|
9
|
+
["deeply/nested/module.lua", defaultPatterns, true],
|
|
10
|
+
["data.ts", defaultPatterns, false],
|
|
11
|
+
["settings.json", defaultPatterns, false],
|
|
12
|
+
["info.json.bak", defaultPatterns, false],
|
|
13
|
+
["some/info.json", defaultPatterns, false],
|
|
14
|
+
])("matchesPattern(%j, %j) => %j", (filename, patterns, expected) => {
|
|
15
|
+
expect(matchesPattern(filename, patterns)).toBe(expected);
|
|
16
|
+
});
|
|
17
|
+
it("handles custom patterns", () => {
|
|
18
|
+
expect(matchesPattern("src/main.ts", ["**/*.ts"])).toBe(true);
|
|
19
|
+
expect(matchesPattern("main.ts", ["**/*.ts"])).toBe(true);
|
|
20
|
+
expect(matchesPattern("main.js", ["**/*.ts"])).toBe(false);
|
|
21
|
+
});
|
|
22
|
+
it("handles single wildcard", () => {
|
|
23
|
+
expect(matchesPattern("file.lua", ["*.lua"])).toBe(true);
|
|
24
|
+
expect(matchesPattern("nested/file.lua", ["*.lua"])).toBe(false);
|
|
25
|
+
});
|
|
26
|
+
it("handles backslash paths (Windows)", () => {
|
|
27
|
+
expect(matchesPattern("nested\\file.lua", defaultPatterns)).toBe(true);
|
|
28
|
+
});
|
|
29
|
+
});
|
|
Binary file
|