factorio-test-cli 3.1.2 → 3.2.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/config/cli-config.js +14 -6
- package/config/index.js +3 -3
- package/config/loader.js +40 -32
- package/config/test-config.js +4 -1
- package/config.test.js +95 -41
- package/factorio-process.js +41 -9
- package/factorio-process.test.js +2 -0
- package/package.json +1 -1
- package/run.js +53 -72
- package/schema.test.js +18 -12
- package/test-output.js +28 -3
- package/test-output.test.js +104 -0
package/config/cli-config.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
2
|
import { testRunnerConfigSchema, registerTestConfigOptions } from "./test-config.js";
|
|
3
3
|
export const DEFAULT_DATA_DIRECTORY = "./factorio-test-data-dir";
|
|
4
|
-
const
|
|
4
|
+
export const fileConfigFields = {
|
|
5
5
|
modPath: {
|
|
6
6
|
schema: z.string().optional(),
|
|
7
7
|
cli: {
|
|
@@ -27,8 +27,7 @@ const cliConfigFields = {
|
|
|
27
27
|
schema: z.string().optional(),
|
|
28
28
|
cli: {
|
|
29
29
|
flags: "-d --data-directory <path>",
|
|
30
|
-
description:
|
|
31
|
-
default: DEFAULT_DATA_DIRECTORY,
|
|
30
|
+
description: `Factorio data directory, where mods, saves, config etc. will be (default: "${DEFAULT_DATA_DIRECTORY}").`,
|
|
32
31
|
},
|
|
33
32
|
},
|
|
34
33
|
save: {
|
|
@@ -96,9 +95,17 @@ const cliConfigFields = {
|
|
|
96
95
|
parseArg: (v) => parseInt(v, 10),
|
|
97
96
|
},
|
|
98
97
|
},
|
|
98
|
+
outputTimeout: {
|
|
99
|
+
schema: z.number().min(0).optional(),
|
|
100
|
+
cli: {
|
|
101
|
+
flags: "--output-timeout <seconds>",
|
|
102
|
+
description: "Kill Factorio if no stdout/stderr output received within this many seconds. 0 to disable (default: 15).",
|
|
103
|
+
parseArg: (v) => parseInt(v, 10),
|
|
104
|
+
},
|
|
105
|
+
},
|
|
99
106
|
};
|
|
100
|
-
export const
|
|
101
|
-
...Object.fromEntries(Object.entries(
|
|
107
|
+
export const fileConfigSchema = z.object({
|
|
108
|
+
...Object.fromEntries(Object.entries(fileConfigFields).map(([k, v]) => [k, v.schema])),
|
|
102
109
|
test: testRunnerConfigSchema.optional(),
|
|
103
110
|
});
|
|
104
111
|
function addOption(command, cli) {
|
|
@@ -120,7 +127,7 @@ function addOption(command, cli) {
|
|
|
120
127
|
}
|
|
121
128
|
}
|
|
122
129
|
export function registerAllCliOptions(command) {
|
|
123
|
-
const f =
|
|
130
|
+
const f = fileConfigFields;
|
|
124
131
|
command.option("-c --config <path>", "Path to config file (default: factorio-test.json, or 'factorio-test' key in package.json)");
|
|
125
132
|
command.option("-g --graphics", "Launch Factorio with graphics (interactive mode) instead of headless");
|
|
126
133
|
command.option("-w --watch", "Watch for file changes and rerun tests");
|
|
@@ -137,6 +144,7 @@ export function registerAllCliOptions(command) {
|
|
|
137
144
|
addOption(command, f.outputFile.cli);
|
|
138
145
|
command.option("--no-output-file", "Disable writing test results file");
|
|
139
146
|
addOption(command, f.forbidOnly.cli);
|
|
147
|
+
addOption(command, f.outputTimeout.cli);
|
|
140
148
|
addOption(command, f.watchPatterns.cli);
|
|
141
149
|
addOption(command, f.udpPort.cli);
|
|
142
150
|
}
|
package/config/index.js
CHANGED
|
@@ -1,3 +1,3 @@
|
|
|
1
|
-
export {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
1
|
+
export { parseCliTestOptions, testRunnerConfigSchema } from "./test-config.js";
|
|
2
|
+
export { fileConfigSchema, registerAllCliOptions } from "./cli-config.js";
|
|
3
|
+
export { loadFileConfig, resolveConfig } from "./loader.js";
|
package/config/loader.js
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
import * as fs from "fs";
|
|
2
2
|
import * as path from "path";
|
|
3
3
|
import { CliError } from "../cli-error.js";
|
|
4
|
-
import {
|
|
4
|
+
import { getDefaultOutputPath } from "../test-results.js";
|
|
5
|
+
import { DEFAULT_DATA_DIRECTORY, fileConfigFields, fileConfigSchema } from "./cli-config.js";
|
|
5
6
|
import { parseCliTestOptions } from "./test-config.js";
|
|
7
|
+
const DEFAULT_WATCH_PATTERNS = ["info.json", "**/*.lua"];
|
|
6
8
|
function formatZodError(error, filePath) {
|
|
7
9
|
const issues = error.issues.map((issue) => {
|
|
8
10
|
const pathStr = issue.path.join(".");
|
|
@@ -10,7 +12,7 @@ function formatZodError(error, filePath) {
|
|
|
10
12
|
});
|
|
11
13
|
return `Invalid config in ${filePath}:\n${issues.join("\n")}`;
|
|
12
14
|
}
|
|
13
|
-
export function
|
|
15
|
+
export function loadFileConfig(configPath) {
|
|
14
16
|
const paths = configPath
|
|
15
17
|
? [path.resolve(configPath)]
|
|
16
18
|
: [path.resolve("factorio-test.json"), path.resolve("package.json")];
|
|
@@ -21,7 +23,7 @@ export function loadConfig(configPath) {
|
|
|
21
23
|
const rawConfig = filePath.endsWith("package.json") ? content["factorio-test"] : content;
|
|
22
24
|
if (!rawConfig)
|
|
23
25
|
continue;
|
|
24
|
-
const result =
|
|
26
|
+
const result = fileConfigSchema.strict().safeParse(rawConfig);
|
|
25
27
|
if (!result.success) {
|
|
26
28
|
throw new CliError(formatZodError(result.error, filePath));
|
|
27
29
|
}
|
|
@@ -38,35 +40,41 @@ function resolveConfigPaths(config, configDir) {
|
|
|
38
40
|
save: config.save ? path.resolve(configDir, config.save) : undefined,
|
|
39
41
|
};
|
|
40
42
|
}
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
-
};
|
|
43
|
+
function mergeTestConfig(fileConfig, cliOptions) {
|
|
44
|
+
const defined = Object.fromEntries(Object.entries(cliOptions).filter(([, v]) => v !== undefined));
|
|
45
|
+
return { ...fileConfig, ...defined };
|
|
54
46
|
}
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
options.factorioArgs ??= fileConfig.factorioArgs;
|
|
62
|
-
options.verbose ??= fileConfig.verbose;
|
|
63
|
-
options.quiet ??= fileConfig.quiet;
|
|
64
|
-
return options;
|
|
47
|
+
function getBaseConfig(fileConfig, cliOptions) {
|
|
48
|
+
const raw = {};
|
|
49
|
+
for (const key of Object.keys(fileConfigFields)) {
|
|
50
|
+
raw[key] = cliOptions[key] ?? fileConfig[key];
|
|
51
|
+
}
|
|
52
|
+
return raw;
|
|
65
53
|
}
|
|
66
|
-
export function
|
|
67
|
-
const
|
|
68
|
-
const
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
54
|
+
export function resolveConfig({ cliOptions, patterns }) {
|
|
55
|
+
const fileConfig = loadFileConfig(cliOptions.config);
|
|
56
|
+
const baseConfig = getBaseConfig(fileConfig, cliOptions);
|
|
57
|
+
const testConfig = mergeTestConfig(fileConfig.test, parseCliTestOptions(cliOptions, patterns));
|
|
58
|
+
return {
|
|
59
|
+
graphics: cliOptions.graphics,
|
|
60
|
+
watch: cliOptions.watch,
|
|
61
|
+
modPath: baseConfig.modPath,
|
|
62
|
+
modName: baseConfig.modName,
|
|
63
|
+
factorioPath: baseConfig.factorioPath,
|
|
64
|
+
dataDirectory: path.resolve(baseConfig.dataDirectory ?? DEFAULT_DATA_DIRECTORY),
|
|
65
|
+
save: baseConfig.save,
|
|
66
|
+
mods: baseConfig.mods,
|
|
67
|
+
factorioArgs: baseConfig.factorioArgs,
|
|
68
|
+
verbose: baseConfig.verbose,
|
|
69
|
+
quiet: baseConfig.quiet,
|
|
70
|
+
outputFile: cliOptions.outputFile === false
|
|
71
|
+
? undefined
|
|
72
|
+
: (baseConfig.outputFile ??
|
|
73
|
+
getDefaultOutputPath(path.resolve(baseConfig.dataDirectory ?? DEFAULT_DATA_DIRECTORY))),
|
|
74
|
+
forbidOnly: baseConfig.forbidOnly ?? true,
|
|
75
|
+
watchPatterns: baseConfig.watchPatterns ?? DEFAULT_WATCH_PATTERNS,
|
|
76
|
+
udpPort: baseConfig.udpPort ?? 14434,
|
|
77
|
+
outputTimeout: baseConfig.outputTimeout ?? 15,
|
|
78
|
+
testConfig,
|
|
79
|
+
};
|
|
72
80
|
}
|
package/config/test-config.js
CHANGED
|
@@ -88,7 +88,7 @@ export function registerTestConfigOptions(command) {
|
|
|
88
88
|
function snakeToCamel(str) {
|
|
89
89
|
return str.replace(/_([a-z])/g, (_, c) => c.toUpperCase());
|
|
90
90
|
}
|
|
91
|
-
export function parseCliTestOptions(opts) {
|
|
91
|
+
export function parseCliTestOptions(opts, patterns) {
|
|
92
92
|
const result = {};
|
|
93
93
|
for (const snake of Object.keys(testConfigFields)) {
|
|
94
94
|
const camel = snakeToCamel(snake);
|
|
@@ -100,5 +100,8 @@ export function parseCliTestOptions(opts) {
|
|
|
100
100
|
result[snake] = value;
|
|
101
101
|
}
|
|
102
102
|
}
|
|
103
|
+
if (patterns.length > 0) {
|
|
104
|
+
result.test_pattern = patterns.map((p) => `(${p})`).join("|");
|
|
105
|
+
}
|
|
103
106
|
return result;
|
|
104
107
|
}
|
package/config.test.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { describe, it, expect, beforeEach, afterEach, assertType } from "vitest";
|
|
2
2
|
import * as fs from "fs";
|
|
3
3
|
import * as path from "path";
|
|
4
|
-
import {
|
|
4
|
+
import { loadFileConfig, resolveConfig } from "./config/index.js";
|
|
5
5
|
const testDir = path.join(import.meta.dirname, "__test_fixtures__");
|
|
6
6
|
describe("loadConfig", () => {
|
|
7
7
|
beforeEach(() => {
|
|
@@ -11,7 +11,7 @@ describe("loadConfig", () => {
|
|
|
11
11
|
fs.rmSync(testDir, { recursive: true, force: true });
|
|
12
12
|
});
|
|
13
13
|
it("returns empty object when no config exists", () => {
|
|
14
|
-
expect(
|
|
14
|
+
expect(loadFileConfig(path.join(testDir, "nonexistent.json"))).toEqual({});
|
|
15
15
|
});
|
|
16
16
|
it("loads factorio-test.json with snake_case test config", () => {
|
|
17
17
|
const configPath = path.join(testDir, "factorio-test.json");
|
|
@@ -19,7 +19,7 @@ describe("loadConfig", () => {
|
|
|
19
19
|
modPath: "./test",
|
|
20
20
|
test: { game_speed: 100 },
|
|
21
21
|
}));
|
|
22
|
-
expect(
|
|
22
|
+
expect(loadFileConfig(configPath)).toMatchObject({
|
|
23
23
|
modPath: path.join(testDir, "test"),
|
|
24
24
|
test: { game_speed: 100 },
|
|
25
25
|
});
|
|
@@ -27,27 +27,27 @@ describe("loadConfig", () => {
|
|
|
27
27
|
it("throws on invalid keys", () => {
|
|
28
28
|
const configPath = path.join(testDir, "bad.json");
|
|
29
29
|
fs.writeFileSync(configPath, JSON.stringify({ test: { invalid_key: true } }));
|
|
30
|
-
expect(() =>
|
|
30
|
+
expect(() => loadFileConfig(configPath)).toThrow();
|
|
31
31
|
});
|
|
32
32
|
it("error message includes file path for invalid top-level key", () => {
|
|
33
33
|
const configPath = path.join(testDir, "bad-toplevel.json");
|
|
34
34
|
fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
|
|
35
|
-
expect(() =>
|
|
35
|
+
expect(() => loadFileConfig(configPath)).toThrow(configPath);
|
|
36
36
|
});
|
|
37
37
|
it("error message includes field name for invalid top-level key", () => {
|
|
38
38
|
const configPath = path.join(testDir, "bad-toplevel.json");
|
|
39
39
|
fs.writeFileSync(configPath, JSON.stringify({ unknownKey: true }));
|
|
40
|
-
expect(() =>
|
|
40
|
+
expect(() => loadFileConfig(configPath)).toThrow(/unknownKey/);
|
|
41
41
|
});
|
|
42
42
|
it("error message includes field path for invalid nested key", () => {
|
|
43
43
|
const configPath = path.join(testDir, "bad-nested.json");
|
|
44
44
|
fs.writeFileSync(configPath, JSON.stringify({ test: { badNestedKey: true } }));
|
|
45
|
-
expect(() =>
|
|
45
|
+
expect(() => loadFileConfig(configPath)).toThrow(/test/);
|
|
46
46
|
});
|
|
47
47
|
it("error message includes field name for type mismatch", () => {
|
|
48
48
|
const configPath = path.join(testDir, "bad-type.json");
|
|
49
49
|
fs.writeFileSync(configPath, JSON.stringify({ test: { game_speed: "fast" } }));
|
|
50
|
-
expect(() =>
|
|
50
|
+
expect(() => loadFileConfig(configPath)).toThrow(/game_speed/);
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
53
|
describe("TestRunnerConfig type compatibility", () => {
|
|
@@ -55,40 +55,94 @@ describe("TestRunnerConfig type compatibility", () => {
|
|
|
55
55
|
assertType({});
|
|
56
56
|
});
|
|
57
57
|
});
|
|
58
|
-
describe("
|
|
59
|
-
|
|
60
|
-
|
|
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");
|
|
58
|
+
describe("resolveConfig", () => {
|
|
59
|
+
beforeEach(() => {
|
|
60
|
+
fs.mkdirSync(testDir, { recursive: true });
|
|
66
61
|
});
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
expect(result.game_speed).toBe(100);
|
|
70
|
-
expect(result.log_passed_tests).toBe(true);
|
|
62
|
+
afterEach(() => {
|
|
63
|
+
fs.rmSync(testDir, { recursive: true, force: true });
|
|
71
64
|
});
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
expect(result.
|
|
85
|
-
});
|
|
86
|
-
it("
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
expect(result.
|
|
65
|
+
function writeConfig(config) {
|
|
66
|
+
const configPath = path.join(testDir, "factorio-test.json");
|
|
67
|
+
fs.writeFileSync(configPath, JSON.stringify(config));
|
|
68
|
+
return configPath;
|
|
69
|
+
}
|
|
70
|
+
it("CLI options override file config", () => {
|
|
71
|
+
const configPath = writeConfig({ verbose: true, forbidOnly: false });
|
|
72
|
+
const result = resolveConfig({
|
|
73
|
+
cliOptions: { config: configPath, verbose: false, forbidOnly: true },
|
|
74
|
+
patterns: [],
|
|
75
|
+
});
|
|
76
|
+
expect(result.verbose).toBe(false);
|
|
77
|
+
expect(result.forbidOnly).toBe(true);
|
|
78
|
+
});
|
|
79
|
+
it("applies defaults when neither CLI nor file provides value", () => {
|
|
80
|
+
const configPath = writeConfig({});
|
|
81
|
+
const result = resolveConfig({ cliOptions: { config: configPath }, patterns: [] });
|
|
82
|
+
expect(result.forbidOnly).toBe(true);
|
|
83
|
+
expect(result.udpPort).toBe(14434);
|
|
84
|
+
expect(result.outputTimeout).toBe(15);
|
|
85
|
+
expect(result.watchPatterns).toEqual(["info.json", "**/*.lua"]);
|
|
86
|
+
});
|
|
87
|
+
it("outputFile: false disables output", () => {
|
|
88
|
+
const configPath = writeConfig({ outputFile: "results.json" });
|
|
89
|
+
const result = resolveConfig({
|
|
90
|
+
cliOptions: { config: configPath, outputFile: false },
|
|
91
|
+
patterns: [],
|
|
92
|
+
});
|
|
93
|
+
expect(result.outputFile).toBeUndefined();
|
|
94
|
+
});
|
|
95
|
+
it("computes default outputFile from dataDirectory", () => {
|
|
96
|
+
const configPath = writeConfig({});
|
|
97
|
+
const result = resolveConfig({ cliOptions: { config: configPath }, patterns: [] });
|
|
98
|
+
expect(result.outputFile).toMatch(/test-results\.json$/);
|
|
99
|
+
});
|
|
100
|
+
it("file config fills in missing CLI values", () => {
|
|
101
|
+
const configPath = writeConfig({ udpPort: 9999, outputTimeout: 30 });
|
|
102
|
+
const result = resolveConfig({ cliOptions: { config: configPath }, patterns: [] });
|
|
103
|
+
expect(result.udpPort).toBe(9999);
|
|
104
|
+
expect(result.outputTimeout).toBe(30);
|
|
105
|
+
});
|
|
106
|
+
describe("test config merge", () => {
|
|
107
|
+
it("positional patterns override CLI option and config file", () => {
|
|
108
|
+
const configPath = writeConfig({ test: { test_pattern: "config" } });
|
|
109
|
+
const result = resolveConfig({
|
|
110
|
+
cliOptions: { config: configPath, testPattern: "cli" },
|
|
111
|
+
patterns: ["pos1", "pos2"],
|
|
112
|
+
});
|
|
113
|
+
expect(result.testConfig.test_pattern).toBe("(pos1)|(pos2)");
|
|
114
|
+
});
|
|
115
|
+
it("CLI option overrides config file when no positional patterns", () => {
|
|
116
|
+
const configPath = writeConfig({ test: { test_pattern: "config" } });
|
|
117
|
+
const result = resolveConfig({
|
|
118
|
+
cliOptions: { config: configPath, testPattern: "cli" },
|
|
119
|
+
patterns: [],
|
|
120
|
+
});
|
|
121
|
+
expect(result.testConfig.test_pattern).toBe("cli");
|
|
122
|
+
});
|
|
123
|
+
it("uses config file when no CLI option or positional patterns", () => {
|
|
124
|
+
const configPath = writeConfig({ test: { test_pattern: "config" } });
|
|
125
|
+
const result = resolveConfig({ cliOptions: { config: configPath }, patterns: [] });
|
|
126
|
+
expect(result.testConfig.test_pattern).toBe("config");
|
|
127
|
+
});
|
|
128
|
+
it("undefined when no patterns specified anywhere", () => {
|
|
129
|
+
const configPath = writeConfig({});
|
|
130
|
+
const result = resolveConfig({ cliOptions: { config: configPath }, patterns: [] });
|
|
131
|
+
expect(result.testConfig.test_pattern).toBeUndefined();
|
|
132
|
+
});
|
|
133
|
+
it("CLI test options override file test config", () => {
|
|
134
|
+
const configPath = writeConfig({ test: { game_speed: 100 } });
|
|
135
|
+
const result = resolveConfig({
|
|
136
|
+
cliOptions: { config: configPath, gameSpeed: 200 },
|
|
137
|
+
patterns: [],
|
|
138
|
+
});
|
|
139
|
+
expect(result.testConfig.game_speed).toBe(200);
|
|
140
|
+
});
|
|
141
|
+
it("preserves file test config when CLI undefined", () => {
|
|
142
|
+
const configPath = writeConfig({ test: { game_speed: 100, log_passed_tests: true } });
|
|
143
|
+
const result = resolveConfig({ cliOptions: { config: configPath }, patterns: [] });
|
|
144
|
+
expect(result.testConfig.game_speed).toBe(100);
|
|
145
|
+
expect(result.testConfig.log_passed_tests).toBe(true);
|
|
146
|
+
});
|
|
93
147
|
});
|
|
94
148
|
});
|
package/factorio-process.js
CHANGED
|
@@ -86,15 +86,20 @@ export function getHeadlessSavePath(overridePath) {
|
|
|
86
86
|
return path.join(__dirname, "headless-save.zip");
|
|
87
87
|
}
|
|
88
88
|
export function parseResultMessage(message) {
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
89
|
+
let remaining = message;
|
|
90
|
+
let status;
|
|
91
|
+
const hasFocused = remaining.endsWith(":focused");
|
|
92
|
+
if (hasFocused)
|
|
93
|
+
remaining = remaining.slice(0, -":focused".length);
|
|
94
|
+
if (remaining.startsWith("bailed:")) {
|
|
95
|
+
status = "bailed";
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
status = remaining;
|
|
94
99
|
}
|
|
95
100
|
return {
|
|
96
|
-
status:
|
|
97
|
-
hasFocusedTests:
|
|
101
|
+
status: status,
|
|
102
|
+
hasFocusedTests: hasFocused,
|
|
98
103
|
};
|
|
99
104
|
}
|
|
100
105
|
function createOutputComponents(options) {
|
|
@@ -150,6 +155,7 @@ export async function runFactorioTestsHeadless(factorioPath, dataDir, savePath,
|
|
|
150
155
|
let testRunStarted = false;
|
|
151
156
|
let startupTimedOut = false;
|
|
152
157
|
let wasCancelled = false;
|
|
158
|
+
let outputTimedOut = false;
|
|
153
159
|
handler.on("event", (event) => {
|
|
154
160
|
if (event.type === "testRunStarted") {
|
|
155
161
|
testRunStarted = true;
|
|
@@ -162,20 +168,46 @@ export async function runFactorioTestsHeadless(factorioPath, dataDir, savePath,
|
|
|
162
168
|
factorioProcess.kill();
|
|
163
169
|
}
|
|
164
170
|
}, 10_000);
|
|
171
|
+
const outputTimeout = options.outputTimeout;
|
|
172
|
+
let outputWatchdog;
|
|
173
|
+
function resetOutputWatchdog() {
|
|
174
|
+
if (!outputTimeout)
|
|
175
|
+
return;
|
|
176
|
+
clearTimeout(outputWatchdog);
|
|
177
|
+
outputWatchdog = setTimeout(() => {
|
|
178
|
+
outputTimedOut = true;
|
|
179
|
+
factorioProcess.kill();
|
|
180
|
+
}, outputTimeout * 1000);
|
|
181
|
+
}
|
|
182
|
+
if (outputTimeout) {
|
|
183
|
+
resetOutputWatchdog();
|
|
184
|
+
}
|
|
165
185
|
const abortHandler = () => {
|
|
166
186
|
wasCancelled = true;
|
|
167
187
|
factorioProcess.kill();
|
|
168
188
|
};
|
|
169
189
|
options.signal?.addEventListener("abort", abortHandler);
|
|
170
|
-
new BufferLineSplitter(factorioProcess.stdout)
|
|
171
|
-
new BufferLineSplitter(factorioProcess.stderr)
|
|
190
|
+
const stdoutSplitter = new BufferLineSplitter(factorioProcess.stdout);
|
|
191
|
+
const stderrSplitter = new BufferLineSplitter(factorioProcess.stderr);
|
|
192
|
+
stdoutSplitter.on("line", (line) => {
|
|
193
|
+
resetOutputWatchdog();
|
|
194
|
+
handler.handleLine(line);
|
|
195
|
+
});
|
|
196
|
+
stderrSplitter.on("line", (line) => {
|
|
197
|
+
resetOutputWatchdog();
|
|
198
|
+
handler.handleLine(line);
|
|
199
|
+
});
|
|
172
200
|
await new Promise((resolve, reject) => {
|
|
173
201
|
factorioProcess.on("exit", (code, signal) => {
|
|
174
202
|
clearTimeout(startupTimeout);
|
|
203
|
+
clearTimeout(outputWatchdog);
|
|
175
204
|
options.signal?.removeEventListener("abort", abortHandler);
|
|
176
205
|
if (wasCancelled) {
|
|
177
206
|
resolve();
|
|
178
207
|
}
|
|
208
|
+
else if (outputTimedOut) {
|
|
209
|
+
reject(new CliError(`Factorio process stuck: no output received for ${outputTimeout} seconds`));
|
|
210
|
+
}
|
|
179
211
|
else if (startupTimedOut) {
|
|
180
212
|
reject(new CliError("Factorio unresponsive: no test run started within 10 seconds"));
|
|
181
213
|
}
|
package/factorio-process.test.js
CHANGED
|
@@ -16,6 +16,8 @@ describe("parseResultMessage", () => {
|
|
|
16
16
|
["passed:focused", { status: "passed", hasFocusedTests: true }],
|
|
17
17
|
["failed:focused", { status: "failed", hasFocusedTests: true }],
|
|
18
18
|
["todo:focused", { status: "todo", hasFocusedTests: true }],
|
|
19
|
+
["bailed:failed", { status: "bailed", hasFocusedTests: false }],
|
|
20
|
+
["bailed:failed:focused", { status: "bailed", hasFocusedTests: true }],
|
|
19
21
|
])("parses %s", (input, expected) => {
|
|
20
22
|
expect(parseResultMessage(input)).toEqual(expected);
|
|
21
23
|
});
|
package/package.json
CHANGED
package/run.js
CHANGED
|
@@ -4,13 +4,14 @@ import * as dgram from "dgram";
|
|
|
4
4
|
import * as fsp from "fs/promises";
|
|
5
5
|
import * as path from "path";
|
|
6
6
|
import { CliError } from "./cli-error.js";
|
|
7
|
-
import {
|
|
7
|
+
import { registerAllCliOptions, resolveConfig } from "./config/index.js";
|
|
8
8
|
import { autoDetectFactorioPath } from "./factorio-process.js";
|
|
9
9
|
import { getHeadlessSavePath, runFactorioTestsGraphics, runFactorioTestsHeadless, } from "./factorio-process.js";
|
|
10
10
|
import { watchDirectory, watchFile } from "./file-watcher.js";
|
|
11
11
|
import { configureModToTest, ensureConfigIni, installFactorioTest, installModDependencies, installMods, parseModRequirement, resetAutorunSettings, resolveModWatchTarget, setSettingsForAutorun, } from "./mod-setup.js";
|
|
12
12
|
import { runScript, setVerbose } from "./process-utils.js";
|
|
13
|
-
import {
|
|
13
|
+
import { OutputFormatter } from "./test-output.js";
|
|
14
|
+
import { readPreviousFailedTests, writeResultsFile } from "./test-results.js";
|
|
14
15
|
const thisCommand = program
|
|
15
16
|
.command("run")
|
|
16
17
|
.summary("Runs tests with Factorio test.")
|
|
@@ -37,24 +38,23 @@ Examples:
|
|
|
37
38
|
.argument("[filter...]", "Lua patterns to filter tests (OR logic)");
|
|
38
39
|
registerAllCliOptions(thisCommand);
|
|
39
40
|
thisCommand.action((patterns, options) => runTests(patterns, options));
|
|
40
|
-
async function setupTestRun(patterns,
|
|
41
|
-
const
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
if (options.modPath !== undefined && options.modName !== undefined) {
|
|
41
|
+
async function setupTestRun(patterns, cliOptions) {
|
|
42
|
+
const config = resolveConfig({ cliOptions, patterns });
|
|
43
|
+
setVerbose(!!config.verbose);
|
|
44
|
+
if (config.modPath !== undefined && config.modName !== undefined) {
|
|
45
45
|
throw new CliError("Only one of --mod-path or --mod-name can be specified.");
|
|
46
46
|
}
|
|
47
|
-
if (
|
|
47
|
+
if (config.modPath === undefined && config.modName === undefined) {
|
|
48
48
|
throw new CliError("One of --mod-path or --mod-name must be specified.");
|
|
49
49
|
}
|
|
50
|
-
const factorioPath =
|
|
51
|
-
const dataDir =
|
|
50
|
+
const factorioPath = config.factorioPath ?? autoDetectFactorioPath();
|
|
51
|
+
const dataDir = config.dataDirectory;
|
|
52
52
|
const modsDir = path.join(dataDir, "mods");
|
|
53
53
|
await fsp.mkdir(modsDir, { recursive: true });
|
|
54
|
-
const modToTest = await configureModToTest(modsDir,
|
|
55
|
-
const modDependencies =
|
|
54
|
+
const modToTest = await configureModToTest(modsDir, config.modPath, config.modName, config.verbose);
|
|
55
|
+
const modDependencies = config.modPath ? await installModDependencies(modsDir, path.resolve(config.modPath)) : [];
|
|
56
56
|
await installFactorioTest(modsDir);
|
|
57
|
-
const configModRequirements =
|
|
57
|
+
const configModRequirements = config.mods
|
|
58
58
|
?.filter((m) => !m.match(/^\S+=(?:true|false)$/))
|
|
59
59
|
.map(parseModRequirement)
|
|
60
60
|
.filter((r) => r != null) ?? [];
|
|
@@ -65,106 +65,88 @@ async function setupTestRun(patterns, options) {
|
|
|
65
65
|
"factorio-test=true",
|
|
66
66
|
`${modToTest}=true`,
|
|
67
67
|
...modDependencies.map((m) => `${m}=true`),
|
|
68
|
-
...(
|
|
68
|
+
...(config.mods?.map((m) => (m.match(/^\S+=(?:true|false)$/) ? m : `${m.split(/\s/)[0]}=true`)) ?? []),
|
|
69
69
|
];
|
|
70
|
-
if (
|
|
70
|
+
if (config.verbose)
|
|
71
71
|
console.log("Adjusting mods");
|
|
72
72
|
await runScript("fmtk", "mods", "adjust", "--modsPath", modsDir, "--disableExtra", ...enableModsOptions);
|
|
73
73
|
await ensureConfigIni(dataDir);
|
|
74
|
-
const mode =
|
|
75
|
-
const savePath = getHeadlessSavePath(
|
|
76
|
-
const
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
const testConfig = buildTestConfig(fileConfig, options, patterns);
|
|
80
|
-
const udpPort = options.watch && options.graphics ? (options.udpPort ?? fileConfig.udpPort ?? 14434) : undefined;
|
|
81
|
-
const factorioArgs = [...(options.factorioArgs ?? [])];
|
|
82
|
-
if (udpPort !== undefined) {
|
|
83
|
-
factorioArgs.push(`--enable-lua-udp=${udpPort}`);
|
|
74
|
+
const mode = config.graphics ? "graphics" : "headless";
|
|
75
|
+
const savePath = getHeadlessSavePath(config.save);
|
|
76
|
+
const factorioArgs = [...(config.factorioArgs ?? [])];
|
|
77
|
+
if (config.watch && config.graphics) {
|
|
78
|
+
factorioArgs.push(`--enable-lua-udp=${config.udpPort}`);
|
|
84
79
|
}
|
|
85
|
-
return {
|
|
86
|
-
factorioPath,
|
|
87
|
-
dataDir,
|
|
88
|
-
modsDir,
|
|
89
|
-
modToTest,
|
|
90
|
-
mode,
|
|
91
|
-
savePath,
|
|
92
|
-
outputPath,
|
|
93
|
-
factorioArgs,
|
|
94
|
-
testConfig,
|
|
95
|
-
options,
|
|
96
|
-
fileConfig,
|
|
97
|
-
udpPort,
|
|
98
|
-
};
|
|
80
|
+
return { config, factorioPath, dataDir, modsDir, modToTest, mode, savePath, factorioArgs };
|
|
99
81
|
}
|
|
100
82
|
async function executeTestRun(ctx, execOptions) {
|
|
101
|
-
const { factorioPath, dataDir, modsDir, modToTest, mode, savePath,
|
|
83
|
+
const { config, factorioPath, dataDir, modsDir, modToTest, mode, savePath, factorioArgs } = ctx;
|
|
102
84
|
const { signal, skipResetAutorun, resolveOnResult } = execOptions ?? {};
|
|
103
|
-
const reorderEnabled =
|
|
104
|
-
const lastFailedTests = reorderEnabled &&
|
|
85
|
+
const reorderEnabled = config.testConfig.reorder_failed_first ?? true;
|
|
86
|
+
const lastFailedTests = reorderEnabled && config.outputFile ? await readPreviousFailedTests(config.outputFile) : [];
|
|
105
87
|
await setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest, mode, {
|
|
106
|
-
verbose:
|
|
88
|
+
verbose: config.verbose,
|
|
107
89
|
lastFailedTests,
|
|
108
90
|
});
|
|
109
|
-
if (Object.keys(testConfig).length > 0) {
|
|
110
|
-
await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", JSON.stringify(testConfig), "--modsPath", modsDir);
|
|
91
|
+
if (Object.keys(config.testConfig).length > 0) {
|
|
92
|
+
await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", JSON.stringify(config.testConfig), "--modsPath", modsDir);
|
|
111
93
|
}
|
|
112
94
|
let result;
|
|
113
95
|
try {
|
|
114
96
|
result =
|
|
115
97
|
mode === "headless"
|
|
116
98
|
? await runFactorioTestsHeadless(factorioPath, dataDir, savePath, factorioArgs, {
|
|
117
|
-
verbose:
|
|
118
|
-
quiet:
|
|
99
|
+
verbose: config.verbose,
|
|
100
|
+
quiet: config.quiet,
|
|
119
101
|
signal,
|
|
102
|
+
outputTimeout: config.outputTimeout,
|
|
120
103
|
})
|
|
121
104
|
: await runFactorioTestsGraphics(factorioPath, dataDir, savePath, factorioArgs, {
|
|
122
|
-
verbose:
|
|
123
|
-
quiet:
|
|
105
|
+
verbose: config.verbose,
|
|
106
|
+
quiet: config.quiet,
|
|
124
107
|
resolveOnResult,
|
|
125
108
|
});
|
|
126
109
|
}
|
|
127
110
|
finally {
|
|
128
111
|
if (!skipResetAutorun) {
|
|
129
|
-
await resetAutorunSettings(modsDir,
|
|
112
|
+
await resetAutorunSettings(modsDir, config.verbose);
|
|
130
113
|
await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", "{}", "--modsPath", modsDir);
|
|
131
114
|
}
|
|
132
115
|
}
|
|
133
116
|
if (result.status === "cancelled") {
|
|
134
117
|
return { exitCode: 0, status: "cancelled" };
|
|
135
118
|
}
|
|
136
|
-
if (
|
|
137
|
-
await writeResultsFile(
|
|
138
|
-
if (
|
|
139
|
-
console.log(`Results written to ${
|
|
119
|
+
if (config.outputFile && result.data) {
|
|
120
|
+
await writeResultsFile(config.outputFile, modToTest, result.data);
|
|
121
|
+
if (config.verbose)
|
|
122
|
+
console.log(`Results written to ${config.outputFile}`);
|
|
140
123
|
}
|
|
141
124
|
let resultStatus = result.status;
|
|
142
125
|
if (resultStatus === "bailed") {
|
|
143
|
-
console.log(chalk.yellow(`Bailed out after ${testConfig.bail} failure(s)`));
|
|
126
|
+
console.log(chalk.yellow(`Bailed out after ${config.testConfig.bail} failure(s)`));
|
|
144
127
|
resultStatus = "failed";
|
|
145
128
|
}
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
129
|
+
if (result.data) {
|
|
130
|
+
const formatter = new OutputFormatter({ quiet: config.quiet });
|
|
131
|
+
formatter.formatSummary(result.data);
|
|
132
|
+
}
|
|
133
|
+
if (result.hasFocusedTests && config.forbidOnly) {
|
|
150
134
|
console.log(chalk.redBright("Error: .only tests are present but --forbid-only is enabled"));
|
|
151
135
|
return { exitCode: 1, status: resultStatus };
|
|
152
136
|
}
|
|
153
137
|
return { exitCode: resultStatus === "passed" ? 0 : 1, status: resultStatus };
|
|
154
138
|
}
|
|
155
|
-
const DEFAULT_WATCH_PATTERNS = ["info.json", "**/*.lua"];
|
|
156
139
|
async function runGraphicsWatchMode(ctx) {
|
|
157
|
-
const
|
|
158
|
-
|
|
159
|
-
console.log(chalk.gray(`Watching ${target.path} for patterns: ${watchPatterns.join(", ")}`));
|
|
140
|
+
const target = await resolveModWatchTarget(ctx.modsDir, ctx.config.modPath, ctx.config.modName);
|
|
141
|
+
console.log(chalk.gray(`Watching ${target.path} for patterns: ${ctx.config.watchPatterns.join(", ")}`));
|
|
160
142
|
await executeTestRun(ctx, { skipResetAutorun: true, resolveOnResult: true });
|
|
161
143
|
const udpClient = dgram.createSocket("udp4");
|
|
162
144
|
const onFileChange = () => {
|
|
163
145
|
console.log(chalk.cyan("File change detected, triggering rerun..."));
|
|
164
|
-
udpClient.send("rerun", ctx.udpPort, "127.0.0.1");
|
|
146
|
+
udpClient.send("rerun", ctx.config.udpPort, "127.0.0.1");
|
|
165
147
|
};
|
|
166
148
|
const watcher = target.type === "directory"
|
|
167
|
-
? watchDirectory(target.path, onFileChange, { patterns: watchPatterns })
|
|
149
|
+
? watchDirectory(target.path, onFileChange, { patterns: ctx.config.watchPatterns })
|
|
168
150
|
: watchFile(target.path, onFileChange);
|
|
169
151
|
process.on("SIGINT", () => {
|
|
170
152
|
watcher.close();
|
|
@@ -174,8 +156,7 @@ async function runGraphicsWatchMode(ctx) {
|
|
|
174
156
|
return new Promise(() => { });
|
|
175
157
|
}
|
|
176
158
|
async function runHeadlessWatchMode(ctx) {
|
|
177
|
-
const
|
|
178
|
-
const target = await resolveModWatchTarget(ctx.modsDir, ctx.options.modPath, ctx.options.modName);
|
|
159
|
+
const target = await resolveModWatchTarget(ctx.modsDir, ctx.config.modPath, ctx.config.modName);
|
|
179
160
|
let abortController;
|
|
180
161
|
const runOnce = async () => {
|
|
181
162
|
abortController?.abort();
|
|
@@ -202,7 +183,7 @@ async function runHeadlessWatchMode(ctx) {
|
|
|
202
183
|
runOnce();
|
|
203
184
|
};
|
|
204
185
|
const watcher = target.type === "directory"
|
|
205
|
-
? watchDirectory(target.path, onFileChange, { patterns: watchPatterns })
|
|
186
|
+
? watchDirectory(target.path, onFileChange, { patterns: ctx.config.watchPatterns })
|
|
206
187
|
: watchFile(target.path, onFileChange);
|
|
207
188
|
process.on("SIGINT", () => {
|
|
208
189
|
watcher.close();
|
|
@@ -210,12 +191,12 @@ async function runHeadlessWatchMode(ctx) {
|
|
|
210
191
|
});
|
|
211
192
|
return new Promise(() => { });
|
|
212
193
|
}
|
|
213
|
-
async function runTests(patterns,
|
|
214
|
-
const ctx = await setupTestRun(patterns,
|
|
215
|
-
if (
|
|
194
|
+
async function runTests(patterns, cliOptions) {
|
|
195
|
+
const ctx = await setupTestRun(patterns, cliOptions);
|
|
196
|
+
if (ctx.config.watch && ctx.config.graphics) {
|
|
216
197
|
await runGraphicsWatchMode(ctx);
|
|
217
198
|
}
|
|
218
|
-
else if (
|
|
199
|
+
else if (ctx.config.watch) {
|
|
219
200
|
await runHeadlessWatchMode(ctx);
|
|
220
201
|
}
|
|
221
202
|
else {
|
package/schema.test.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect } from "vitest";
|
|
2
|
-
import { testRunnerConfigSchema,
|
|
2
|
+
import { testRunnerConfigSchema, fileConfigSchema, parseCliTestOptions } from "./config/index.js";
|
|
3
3
|
describe("testRunnerConfigSchema", () => {
|
|
4
4
|
it("parses valid config with snake_case keys", () => {
|
|
5
5
|
const config = {
|
|
@@ -19,23 +19,23 @@ describe("testRunnerConfigSchema", () => {
|
|
|
19
19
|
expect(() => testRunnerConfigSchema.parse({ unknown_key: true })).toThrow();
|
|
20
20
|
});
|
|
21
21
|
});
|
|
22
|
-
describe("
|
|
22
|
+
describe("fileConfigSchema", () => {
|
|
23
23
|
it("parses config file with snake_case test keys", () => {
|
|
24
24
|
const config = {
|
|
25
25
|
modPath: "./my-mod",
|
|
26
26
|
test: { game_speed: 50, log_passed_tests: true },
|
|
27
27
|
};
|
|
28
|
-
expect(
|
|
28
|
+
expect(fileConfigSchema.parse(config)).toEqual(config);
|
|
29
29
|
});
|
|
30
30
|
it("rejects unknown keys in strict mode", () => {
|
|
31
|
-
expect(() =>
|
|
31
|
+
expect(() => fileConfigSchema.strict().parse({ unknownKey: true })).toThrow();
|
|
32
32
|
});
|
|
33
33
|
it("accepts forbidOnly boolean", () => {
|
|
34
34
|
const config = { forbidOnly: false };
|
|
35
|
-
expect(
|
|
35
|
+
expect(fileConfigSchema.parse(config)).toEqual(config);
|
|
36
36
|
});
|
|
37
37
|
it("defaults forbidOnly to undefined", () => {
|
|
38
|
-
expect(
|
|
38
|
+
expect(fileConfigSchema.parse({}).forbidOnly).toBeUndefined();
|
|
39
39
|
});
|
|
40
40
|
});
|
|
41
41
|
describe("parseCliTestOptions", () => {
|
|
@@ -45,23 +45,29 @@ describe("parseCliTestOptions", () => {
|
|
|
45
45
|
gameSpeed: 100,
|
|
46
46
|
logPassedTests: true,
|
|
47
47
|
};
|
|
48
|
-
expect(parseCliTestOptions(commanderOpts)).toEqual({
|
|
48
|
+
expect(parseCliTestOptions(commanderOpts, [])).toEqual({
|
|
49
49
|
test_pattern: "foo",
|
|
50
50
|
game_speed: 100,
|
|
51
51
|
log_passed_tests: true,
|
|
52
52
|
});
|
|
53
53
|
});
|
|
54
54
|
it("omits undefined values", () => {
|
|
55
|
-
expect(parseCliTestOptions({ gameSpeed: 100 })).toEqual({ game_speed: 100 });
|
|
55
|
+
expect(parseCliTestOptions({ gameSpeed: 100 }, [])).toEqual({ game_speed: 100 });
|
|
56
56
|
});
|
|
57
57
|
it("returns empty object for empty input", () => {
|
|
58
|
-
expect(parseCliTestOptions({})).toEqual({});
|
|
58
|
+
expect(parseCliTestOptions({}, [])).toEqual({});
|
|
59
59
|
});
|
|
60
60
|
it("passes through bail option", () => {
|
|
61
|
-
expect(parseCliTestOptions({ bail: 1 })).toEqual({ bail: 1 });
|
|
62
|
-
expect(parseCliTestOptions({ bail: 3 })).toEqual({ bail: 3 });
|
|
61
|
+
expect(parseCliTestOptions({ bail: 1 }, [])).toEqual({ bail: 1 });
|
|
62
|
+
expect(parseCliTestOptions({ bail: 3 }, [])).toEqual({ bail: 3 });
|
|
63
63
|
});
|
|
64
64
|
it("converts bail=true to bail=1 (commander behavior for --bail without value)", () => {
|
|
65
|
-
expect(parseCliTestOptions({ bail: true })).toEqual({ bail: 1 });
|
|
65
|
+
expect(parseCliTestOptions({ bail: true }, [])).toEqual({ bail: 1 });
|
|
66
|
+
});
|
|
67
|
+
it("joins positional patterns with OR logic", () => {
|
|
68
|
+
expect(parseCliTestOptions({}, ["foo", "bar"]).test_pattern).toBe("(foo)|(bar)");
|
|
69
|
+
});
|
|
70
|
+
it("positional patterns override CLI testPattern", () => {
|
|
71
|
+
expect(parseCliTestOptions({ testPattern: "cli" }, ["pos"]).test_pattern).toBe("(pos)");
|
|
66
72
|
});
|
|
67
73
|
});
|
package/test-output.js
CHANGED
|
@@ -105,9 +105,34 @@ export class OutputFormatter {
|
|
|
105
105
|
formatSummary(data) {
|
|
106
106
|
if (!data.summary)
|
|
107
107
|
return;
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
108
|
+
if (!this.options.quiet) {
|
|
109
|
+
this.printRecapSection(data.tests, "failed", "Failures:");
|
|
110
|
+
this.printRecapSection(data.tests, "todo", "Todo:");
|
|
111
|
+
}
|
|
112
|
+
this.printCountsLine(data.summary);
|
|
113
|
+
}
|
|
114
|
+
printRecapSection(tests, result, header) {
|
|
115
|
+
const matching = tests.filter((t) => t.result === result);
|
|
116
|
+
if (matching.length === 0)
|
|
117
|
+
return;
|
|
118
|
+
console.log();
|
|
119
|
+
console.log(header);
|
|
120
|
+
console.log();
|
|
121
|
+
for (const test of matching) {
|
|
122
|
+
this.formatTestResult(test);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
printCountsLine(summary) {
|
|
126
|
+
const segments = [];
|
|
127
|
+
if (summary.failed > 0)
|
|
128
|
+
segments.push(chalk.red(`${summary.failed} failed`));
|
|
129
|
+
if (summary.todo > 0)
|
|
130
|
+
segments.push(chalk.magenta(`${summary.todo} todo`));
|
|
131
|
+
if (summary.skipped > 0)
|
|
132
|
+
segments.push(chalk.yellow(`${summary.skipped} skipped`));
|
|
133
|
+
segments.push(chalk.green(`${summary.passed} passed`));
|
|
134
|
+
const total = summary.passed + summary.failed + summary.skipped + summary.todo;
|
|
135
|
+
console.log(`Tests: ${segments.join(", ")} (${total} total)`);
|
|
111
136
|
}
|
|
112
137
|
getPrefix(result) {
|
|
113
138
|
switch (result) {
|
package/test-output.test.js
CHANGED
|
@@ -200,6 +200,110 @@ describe("OutputFormatter", () => {
|
|
|
200
200
|
expect(output[0]).toContain("todo test");
|
|
201
201
|
});
|
|
202
202
|
});
|
|
203
|
+
describe("OutputFormatter.formatSummary", () => {
|
|
204
|
+
let consoleSpy;
|
|
205
|
+
let output;
|
|
206
|
+
beforeEach(() => {
|
|
207
|
+
output = [];
|
|
208
|
+
consoleSpy = vi.spyOn(console, "log").mockImplementation((...args) => {
|
|
209
|
+
output.push(args.join(" "));
|
|
210
|
+
});
|
|
211
|
+
});
|
|
212
|
+
afterEach(() => {
|
|
213
|
+
consoleSpy.mockRestore();
|
|
214
|
+
});
|
|
215
|
+
const makeSummary = (overrides = {}) => ({
|
|
216
|
+
ran: 0,
|
|
217
|
+
passed: 0,
|
|
218
|
+
failed: 0,
|
|
219
|
+
skipped: 0,
|
|
220
|
+
todo: 0,
|
|
221
|
+
cancelled: 0,
|
|
222
|
+
describeBlockErrors: 0,
|
|
223
|
+
status: "passed",
|
|
224
|
+
...overrides,
|
|
225
|
+
});
|
|
226
|
+
it("shows only counts line when no failures or todos", () => {
|
|
227
|
+
const formatter = new OutputFormatter({});
|
|
228
|
+
const data = {
|
|
229
|
+
tests: [{ path: "a", result: "passed", errors: [], logs: [] }],
|
|
230
|
+
summary: makeSummary({ ran: 1, passed: 1 }),
|
|
231
|
+
};
|
|
232
|
+
formatter.formatSummary(data);
|
|
233
|
+
expect(output.some((l) => l.includes("Tests:"))).toBe(true);
|
|
234
|
+
expect(output.some((l) => l.includes("1 passed"))).toBe(true);
|
|
235
|
+
expect(output.some((l) => l.includes("Failures:"))).toBe(false);
|
|
236
|
+
expect(output.some((l) => l.includes("Todo:"))).toBe(false);
|
|
237
|
+
});
|
|
238
|
+
it("shows failure recap and counts line when there are failures", () => {
|
|
239
|
+
const formatter = new OutputFormatter({});
|
|
240
|
+
const data = {
|
|
241
|
+
tests: [
|
|
242
|
+
{ path: "a", result: "passed", errors: [], logs: [] },
|
|
243
|
+
{ path: "b", result: "failed", errors: ["err1"], logs: [], durationMs: 0.5 },
|
|
244
|
+
],
|
|
245
|
+
summary: makeSummary({ ran: 2, passed: 1, failed: 1, status: "failed" }),
|
|
246
|
+
};
|
|
247
|
+
formatter.formatSummary(data);
|
|
248
|
+
expect(output.some((l) => l.includes("Failures:"))).toBe(true);
|
|
249
|
+
const failLines = output.filter((l) => l.includes("FAIL"));
|
|
250
|
+
expect(failLines.length).toBeGreaterThanOrEqual(1);
|
|
251
|
+
expect(output.some((l) => l.includes("1 failed"))).toBe(true);
|
|
252
|
+
expect(output.some((l) => l.includes("1 passed"))).toBe(true);
|
|
253
|
+
});
|
|
254
|
+
it("shows todo recap and counts line when there are todos", () => {
|
|
255
|
+
const formatter = new OutputFormatter({});
|
|
256
|
+
const data = {
|
|
257
|
+
tests: [
|
|
258
|
+
{ path: "a", result: "passed", errors: [], logs: [] },
|
|
259
|
+
{ path: "b", result: "todo", errors: [], logs: [] },
|
|
260
|
+
],
|
|
261
|
+
summary: makeSummary({ ran: 1, passed: 1, todo: 1, status: "todo" }),
|
|
262
|
+
};
|
|
263
|
+
formatter.formatSummary(data);
|
|
264
|
+
expect(output.some((l) => l.includes("Todo:"))).toBe(true);
|
|
265
|
+
expect(output.some((l) => l.includes("TODO"))).toBe(true);
|
|
266
|
+
expect(output.some((l) => l.includes("1 todo"))).toBe(true);
|
|
267
|
+
});
|
|
268
|
+
it("omits zero-count categories except passed", () => {
|
|
269
|
+
const formatter = new OutputFormatter({});
|
|
270
|
+
const data = {
|
|
271
|
+
tests: [{ path: "a", result: "passed", errors: [], logs: [] }],
|
|
272
|
+
summary: makeSummary({ ran: 1, passed: 1 }),
|
|
273
|
+
};
|
|
274
|
+
formatter.formatSummary(data);
|
|
275
|
+
const countsLine = output.find((l) => l.includes("Tests:"));
|
|
276
|
+
expect(countsLine).toContain("1 passed");
|
|
277
|
+
expect(countsLine).not.toContain("failed");
|
|
278
|
+
expect(countsLine).not.toContain("skipped");
|
|
279
|
+
expect(countsLine).not.toContain("todo");
|
|
280
|
+
expect(countsLine).toContain("(1 total)");
|
|
281
|
+
});
|
|
282
|
+
it("includes all non-zero categories in counts line", () => {
|
|
283
|
+
const formatter = new OutputFormatter({});
|
|
284
|
+
const data = {
|
|
285
|
+
tests: [
|
|
286
|
+
{ path: "a", result: "passed", errors: [], logs: [] },
|
|
287
|
+
{ path: "b", result: "failed", errors: ["e"], logs: [] },
|
|
288
|
+
{ path: "c", result: "skipped", errors: [], logs: [] },
|
|
289
|
+
{ path: "d", result: "todo", errors: [], logs: [] },
|
|
290
|
+
],
|
|
291
|
+
summary: makeSummary({ ran: 2, passed: 1, failed: 1, skipped: 1, todo: 1, status: "failed" }),
|
|
292
|
+
};
|
|
293
|
+
formatter.formatSummary(data);
|
|
294
|
+
const countsLine = output.find((l) => l.includes("Tests:"));
|
|
295
|
+
expect(countsLine).toContain("1 failed");
|
|
296
|
+
expect(countsLine).toContain("1 todo");
|
|
297
|
+
expect(countsLine).toContain("1 skipped");
|
|
298
|
+
expect(countsLine).toContain("1 passed");
|
|
299
|
+
expect(countsLine).toContain("(4 total)");
|
|
300
|
+
});
|
|
301
|
+
it("does nothing when summary is undefined", () => {
|
|
302
|
+
const formatter = new OutputFormatter({});
|
|
303
|
+
formatter.formatSummary({ tests: [] });
|
|
304
|
+
expect(output).toHaveLength(0);
|
|
305
|
+
});
|
|
306
|
+
});
|
|
203
307
|
describe("OutputPrinter", () => {
|
|
204
308
|
let consoleSpy;
|
|
205
309
|
let output;
|