factorio-test-cli 3.1.1 → 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 +5 -2
- 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 +58 -73
- 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
|
@@ -4,7 +4,7 @@ const testConfigFields = {
|
|
|
4
4
|
schema: z.string().optional(),
|
|
5
5
|
cli: {
|
|
6
6
|
flags: "--test-pattern <pattern>",
|
|
7
|
-
description: "Filter tests by
|
|
7
|
+
description: "Filter tests by Lua pattern (escape - as %-).",
|
|
8
8
|
},
|
|
9
9
|
},
|
|
10
10
|
tag_whitelist: {
|
|
@@ -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.")
|
|
@@ -23,6 +24,10 @@ When using variadic options (--mods, --factorio-args, etc.) with filter patterns
|
|
|
23
24
|
use -- to separate them:
|
|
24
25
|
factorio-test run -p ./my-mod --mods quality space-age -- "inventory"
|
|
25
26
|
|
|
27
|
+
Patterns use Lua pattern syntax (not regex). Special characters like - must be
|
|
28
|
+
escaped with %:
|
|
29
|
+
factorio-test run -p ./my-mod "my%-test" Match "my-test" (escape the dash)
|
|
30
|
+
|
|
26
31
|
Examples:
|
|
27
32
|
factorio-test run -p ./my-mod Run all tests
|
|
28
33
|
factorio-test run -p ./my-mod -v Run with verbose output
|
|
@@ -30,27 +35,26 @@ Examples:
|
|
|
30
35
|
factorio-test run -p ./my-mod -b Bail on first failure
|
|
31
36
|
factorio-test run -p ./my-mod "inventory" Run tests matching "inventory"
|
|
32
37
|
`)
|
|
33
|
-
.argument("[filter...]", "
|
|
38
|
+
.argument("[filter...]", "Lua patterns to filter tests (OR logic)");
|
|
34
39
|
registerAllCliOptions(thisCommand);
|
|
35
40
|
thisCommand.action((patterns, options) => runTests(patterns, options));
|
|
36
|
-
async function setupTestRun(patterns,
|
|
37
|
-
const
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
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) {
|
|
41
45
|
throw new CliError("Only one of --mod-path or --mod-name can be specified.");
|
|
42
46
|
}
|
|
43
|
-
if (
|
|
47
|
+
if (config.modPath === undefined && config.modName === undefined) {
|
|
44
48
|
throw new CliError("One of --mod-path or --mod-name must be specified.");
|
|
45
49
|
}
|
|
46
|
-
const factorioPath =
|
|
47
|
-
const dataDir =
|
|
50
|
+
const factorioPath = config.factorioPath ?? autoDetectFactorioPath();
|
|
51
|
+
const dataDir = config.dataDirectory;
|
|
48
52
|
const modsDir = path.join(dataDir, "mods");
|
|
49
53
|
await fsp.mkdir(modsDir, { recursive: true });
|
|
50
|
-
const modToTest = await configureModToTest(modsDir,
|
|
51
|
-
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)) : [];
|
|
52
56
|
await installFactorioTest(modsDir);
|
|
53
|
-
const configModRequirements =
|
|
57
|
+
const configModRequirements = config.mods
|
|
54
58
|
?.filter((m) => !m.match(/^\S+=(?:true|false)$/))
|
|
55
59
|
.map(parseModRequirement)
|
|
56
60
|
.filter((r) => r != null) ?? [];
|
|
@@ -61,106 +65,88 @@ async function setupTestRun(patterns, options) {
|
|
|
61
65
|
"factorio-test=true",
|
|
62
66
|
`${modToTest}=true`,
|
|
63
67
|
...modDependencies.map((m) => `${m}=true`),
|
|
64
|
-
...(
|
|
68
|
+
...(config.mods?.map((m) => (m.match(/^\S+=(?:true|false)$/) ? m : `${m.split(/\s/)[0]}=true`)) ?? []),
|
|
65
69
|
];
|
|
66
|
-
if (
|
|
70
|
+
if (config.verbose)
|
|
67
71
|
console.log("Adjusting mods");
|
|
68
72
|
await runScript("fmtk", "mods", "adjust", "--modsPath", modsDir, "--disableExtra", ...enableModsOptions);
|
|
69
73
|
await ensureConfigIni(dataDir);
|
|
70
|
-
const mode =
|
|
71
|
-
const savePath = getHeadlessSavePath(
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
const testConfig = buildTestConfig(fileConfig, options, patterns);
|
|
76
|
-
const udpPort = options.watch && options.graphics ? (options.udpPort ?? fileConfig.udpPort ?? 14434) : undefined;
|
|
77
|
-
const factorioArgs = [...(options.factorioArgs ?? [])];
|
|
78
|
-
if (udpPort !== undefined) {
|
|
79
|
-
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}`);
|
|
80
79
|
}
|
|
81
|
-
return {
|
|
82
|
-
factorioPath,
|
|
83
|
-
dataDir,
|
|
84
|
-
modsDir,
|
|
85
|
-
modToTest,
|
|
86
|
-
mode,
|
|
87
|
-
savePath,
|
|
88
|
-
outputPath,
|
|
89
|
-
factorioArgs,
|
|
90
|
-
testConfig,
|
|
91
|
-
options,
|
|
92
|
-
fileConfig,
|
|
93
|
-
udpPort,
|
|
94
|
-
};
|
|
80
|
+
return { config, factorioPath, dataDir, modsDir, modToTest, mode, savePath, factorioArgs };
|
|
95
81
|
}
|
|
96
82
|
async function executeTestRun(ctx, execOptions) {
|
|
97
|
-
const { factorioPath, dataDir, modsDir, modToTest, mode, savePath,
|
|
83
|
+
const { config, factorioPath, dataDir, modsDir, modToTest, mode, savePath, factorioArgs } = ctx;
|
|
98
84
|
const { signal, skipResetAutorun, resolveOnResult } = execOptions ?? {};
|
|
99
|
-
const reorderEnabled =
|
|
100
|
-
const lastFailedTests = reorderEnabled &&
|
|
85
|
+
const reorderEnabled = config.testConfig.reorder_failed_first ?? true;
|
|
86
|
+
const lastFailedTests = reorderEnabled && config.outputFile ? await readPreviousFailedTests(config.outputFile) : [];
|
|
101
87
|
await setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest, mode, {
|
|
102
|
-
verbose:
|
|
88
|
+
verbose: config.verbose,
|
|
103
89
|
lastFailedTests,
|
|
104
90
|
});
|
|
105
|
-
if (Object.keys(testConfig).length > 0) {
|
|
106
|
-
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);
|
|
107
93
|
}
|
|
108
94
|
let result;
|
|
109
95
|
try {
|
|
110
96
|
result =
|
|
111
97
|
mode === "headless"
|
|
112
98
|
? await runFactorioTestsHeadless(factorioPath, dataDir, savePath, factorioArgs, {
|
|
113
|
-
verbose:
|
|
114
|
-
quiet:
|
|
99
|
+
verbose: config.verbose,
|
|
100
|
+
quiet: config.quiet,
|
|
115
101
|
signal,
|
|
102
|
+
outputTimeout: config.outputTimeout,
|
|
116
103
|
})
|
|
117
104
|
: await runFactorioTestsGraphics(factorioPath, dataDir, savePath, factorioArgs, {
|
|
118
|
-
verbose:
|
|
119
|
-
quiet:
|
|
105
|
+
verbose: config.verbose,
|
|
106
|
+
quiet: config.quiet,
|
|
120
107
|
resolveOnResult,
|
|
121
108
|
});
|
|
122
109
|
}
|
|
123
110
|
finally {
|
|
124
111
|
if (!skipResetAutorun) {
|
|
125
|
-
await resetAutorunSettings(modsDir,
|
|
112
|
+
await resetAutorunSettings(modsDir, config.verbose);
|
|
126
113
|
await runScript("fmtk", "settings", "set", "runtime-global", "factorio-test-config", "{}", "--modsPath", modsDir);
|
|
127
114
|
}
|
|
128
115
|
}
|
|
129
116
|
if (result.status === "cancelled") {
|
|
130
117
|
return { exitCode: 0, status: "cancelled" };
|
|
131
118
|
}
|
|
132
|
-
if (
|
|
133
|
-
await writeResultsFile(
|
|
134
|
-
if (
|
|
135
|
-
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}`);
|
|
136
123
|
}
|
|
137
124
|
let resultStatus = result.status;
|
|
138
125
|
if (resultStatus === "bailed") {
|
|
139
|
-
console.log(chalk.yellow(`Bailed out after ${testConfig.bail} failure(s)`));
|
|
126
|
+
console.log(chalk.yellow(`Bailed out after ${config.testConfig.bail} failure(s)`));
|
|
140
127
|
resultStatus = "failed";
|
|
141
128
|
}
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
129
|
+
if (result.data) {
|
|
130
|
+
const formatter = new OutputFormatter({ quiet: config.quiet });
|
|
131
|
+
formatter.formatSummary(result.data);
|
|
132
|
+
}
|
|
133
|
+
if (result.hasFocusedTests && config.forbidOnly) {
|
|
146
134
|
console.log(chalk.redBright("Error: .only tests are present but --forbid-only is enabled"));
|
|
147
135
|
return { exitCode: 1, status: resultStatus };
|
|
148
136
|
}
|
|
149
137
|
return { exitCode: resultStatus === "passed" ? 0 : 1, status: resultStatus };
|
|
150
138
|
}
|
|
151
|
-
const DEFAULT_WATCH_PATTERNS = ["info.json", "**/*.lua"];
|
|
152
139
|
async function runGraphicsWatchMode(ctx) {
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
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(", ")}`));
|
|
156
142
|
await executeTestRun(ctx, { skipResetAutorun: true, resolveOnResult: true });
|
|
157
143
|
const udpClient = dgram.createSocket("udp4");
|
|
158
144
|
const onFileChange = () => {
|
|
159
145
|
console.log(chalk.cyan("File change detected, triggering rerun..."));
|
|
160
|
-
udpClient.send("rerun", ctx.udpPort, "127.0.0.1");
|
|
146
|
+
udpClient.send("rerun", ctx.config.udpPort, "127.0.0.1");
|
|
161
147
|
};
|
|
162
148
|
const watcher = target.type === "directory"
|
|
163
|
-
? watchDirectory(target.path, onFileChange, { patterns: watchPatterns })
|
|
149
|
+
? watchDirectory(target.path, onFileChange, { patterns: ctx.config.watchPatterns })
|
|
164
150
|
: watchFile(target.path, onFileChange);
|
|
165
151
|
process.on("SIGINT", () => {
|
|
166
152
|
watcher.close();
|
|
@@ -170,8 +156,7 @@ async function runGraphicsWatchMode(ctx) {
|
|
|
170
156
|
return new Promise(() => { });
|
|
171
157
|
}
|
|
172
158
|
async function runHeadlessWatchMode(ctx) {
|
|
173
|
-
const
|
|
174
|
-
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);
|
|
175
160
|
let abortController;
|
|
176
161
|
const runOnce = async () => {
|
|
177
162
|
abortController?.abort();
|
|
@@ -198,7 +183,7 @@ async function runHeadlessWatchMode(ctx) {
|
|
|
198
183
|
runOnce();
|
|
199
184
|
};
|
|
200
185
|
const watcher = target.type === "directory"
|
|
201
|
-
? watchDirectory(target.path, onFileChange, { patterns: watchPatterns })
|
|
186
|
+
? watchDirectory(target.path, onFileChange, { patterns: ctx.config.watchPatterns })
|
|
202
187
|
: watchFile(target.path, onFileChange);
|
|
203
188
|
process.on("SIGINT", () => {
|
|
204
189
|
watcher.close();
|
|
@@ -206,12 +191,12 @@ async function runHeadlessWatchMode(ctx) {
|
|
|
206
191
|
});
|
|
207
192
|
return new Promise(() => { });
|
|
208
193
|
}
|
|
209
|
-
async function runTests(patterns,
|
|
210
|
-
const ctx = await setupTestRun(patterns,
|
|
211
|
-
if (
|
|
194
|
+
async function runTests(patterns, cliOptions) {
|
|
195
|
+
const ctx = await setupTestRun(patterns, cliOptions);
|
|
196
|
+
if (ctx.config.watch && ctx.config.graphics) {
|
|
212
197
|
await runGraphicsWatchMode(ctx);
|
|
213
198
|
}
|
|
214
|
-
else if (
|
|
199
|
+
else if (ctx.config.watch) {
|
|
215
200
|
await runHeadlessWatchMode(ctx);
|
|
216
201
|
}
|
|
217
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;
|