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.
@@ -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 cliConfigFields = {
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: "Factorio data directory, where mods, saves, config etc. will be.",
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 cliConfigSchema = z.object({
101
- ...Object.fromEntries(Object.entries(cliConfigFields).map(([k, v]) => [k, v.schema])),
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 = cliConfigFields;
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 { testRunnerConfigSchema, parseCliTestOptions } from "./test-config.js";
2
- export { cliConfigSchema, registerAllCliOptions } from "./cli-config.js";
3
- export { loadConfig, mergeTestConfig, mergeCliConfig, buildTestConfig } from "./loader.js";
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 { cliConfigSchema, DEFAULT_DATA_DIRECTORY } from "./cli-config.js";
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 loadConfig(configPath) {
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 = cliConfigSchema.strict().safeParse(rawConfig);
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
- export function mergeTestConfig(configFile, cliOptions) {
42
- return {
43
- ...configFile,
44
- test_pattern: cliOptions.test_pattern ?? configFile?.test_pattern,
45
- tag_whitelist: cliOptions.tag_whitelist ?? configFile?.tag_whitelist,
46
- tag_blacklist: cliOptions.tag_blacklist ?? configFile?.tag_blacklist,
47
- default_timeout: cliOptions.default_timeout ?? configFile?.default_timeout,
48
- game_speed: cliOptions.game_speed ?? configFile?.game_speed,
49
- log_passed_tests: cliOptions.log_passed_tests ?? configFile?.log_passed_tests,
50
- log_skipped_tests: cliOptions.log_skipped_tests ?? configFile?.log_skipped_tests,
51
- reorder_failed_first: cliOptions.reorder_failed_first ?? configFile?.reorder_failed_first,
52
- bail: cliOptions.bail ?? configFile?.bail,
53
- };
43
+ function mergeTestConfig(fileConfig, cliOptions) {
44
+ const defined = Object.fromEntries(Object.entries(cliOptions).filter(([, v]) => v !== undefined));
45
+ return { ...fileConfig, ...defined };
54
46
  }
55
- export function mergeCliConfig(fileConfig, options) {
56
- options.modPath ??= fileConfig.modPath;
57
- options.modName ??= fileConfig.modName;
58
- options.factorioPath ??= fileConfig.factorioPath;
59
- options.dataDirectory ??= fileConfig.dataDirectory ?? DEFAULT_DATA_DIRECTORY;
60
- options.mods ??= fileConfig.mods;
61
- options.factorioArgs ??= fileConfig.factorioArgs;
62
- options.verbose ??= fileConfig.verbose;
63
- options.quiet ??= fileConfig.quiet;
64
- return options;
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 buildTestConfig(fileConfig, options, patterns) {
67
- const cliTestOptions = parseCliTestOptions(options);
68
- const testPattern = patterns.length > 0
69
- ? patterns.map((p) => `(${p})`).join("|")
70
- : (cliTestOptions.test_pattern ?? fileConfig.test?.test_pattern);
71
- return mergeTestConfig(fileConfig.test, { ...cliTestOptions, test_pattern: testPattern });
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
  }
@@ -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 name pattern.",
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 { loadConfig, mergeTestConfig, buildTestConfig } from "./config/index.js";
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(loadConfig(path.join(testDir, "nonexistent.json"))).toEqual({});
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(loadConfig(configPath)).toMatchObject({
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(() => loadConfig(configPath)).toThrow();
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(() => loadConfig(configPath)).toThrow(configPath);
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(() => loadConfig(configPath)).toThrow(/unknownKey/);
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(() => loadConfig(configPath)).toThrow(/test/);
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(() => loadConfig(configPath)).toThrow(/game_speed/);
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("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");
58
+ describe("resolveConfig", () => {
59
+ beforeEach(() => {
60
+ fs.mkdirSync(testDir, { recursive: true });
66
61
  });
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);
62
+ afterEach(() => {
63
+ fs.rmSync(testDir, { recursive: true, force: true });
71
64
  });
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();
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
  });
@@ -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
- if (message.endsWith(":focused")) {
90
- return {
91
- status: message.slice(0, -":focused".length),
92
- hasFocusedTests: true,
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: message,
97
- hasFocusedTests: false,
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).on("line", (line) => handler.handleLine(line));
171
- new BufferLineSplitter(factorioProcess.stderr).on("line", (line) => handler.handleLine(line));
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
  }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "factorio-test-cli",
3
- "version": "3.1.1",
3
+ "version": "3.2.0",
4
4
  "description": "A CLI to run FactorioTest.",
5
5
  "license": "MIT",
6
6
  "repository": {
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 { buildTestConfig, loadConfig, mergeCliConfig, registerAllCliOptions, } from "./config/index.js";
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 { getDefaultOutputPath, readPreviousFailedTests, writeResultsFile } from "./test-results.js";
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...]", "Test patterns to filter (OR logic)");
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, options) {
37
- const fileConfig = loadConfig(options.config);
38
- mergeCliConfig(fileConfig, options);
39
- setVerbose(!!options.verbose);
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 (options.modPath === undefined && options.modName === undefined) {
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 = options.factorioPath ?? autoDetectFactorioPath();
47
- const dataDir = path.resolve(options.dataDirectory);
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, options.modPath, options.modName, options.verbose);
51
- const modDependencies = options.modPath ? await installModDependencies(modsDir, path.resolve(options.modPath)) : [];
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 = options.mods
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
- ...(options.mods?.map((m) => (m.match(/^\S+=(?:true|false)$/) ? m : `${m.split(/\s/)[0]}=true`)) ?? []),
68
+ ...(config.mods?.map((m) => (m.match(/^\S+=(?:true|false)$/) ? m : `${m.split(/\s/)[0]}=true`)) ?? []),
65
69
  ];
66
- if (options.verbose)
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 = options.graphics ? "graphics" : "headless";
71
- const savePath = getHeadlessSavePath(options.save ?? fileConfig.save);
72
- const outputPath = options.outputFile === false
73
- ? undefined
74
- : (options.outputFile ?? fileConfig.outputFile ?? getDefaultOutputPath(dataDir));
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, outputPath, factorioArgs, testConfig, options } = ctx;
83
+ const { config, factorioPath, dataDir, modsDir, modToTest, mode, savePath, factorioArgs } = ctx;
98
84
  const { signal, skipResetAutorun, resolveOnResult } = execOptions ?? {};
99
- const reorderEnabled = options.reorderFailedFirst ?? ctx.fileConfig.test?.reorder_failed_first ?? true;
100
- const lastFailedTests = reorderEnabled && outputPath ? await readPreviousFailedTests(outputPath) : [];
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: options.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: options.verbose,
114
- quiet: options.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: options.verbose,
119
- quiet: options.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, options.verbose);
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 (outputPath && result.data) {
133
- await writeResultsFile(outputPath, modToTest, result.data);
134
- if (options.verbose)
135
- console.log(`Results written to ${outputPath}`);
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
- const color = resultStatus == "passed" ? chalk.greenBright : resultStatus == "todo" ? chalk.yellowBright : chalk.redBright;
143
- console.log("Test run result:", color(resultStatus));
144
- const forbidOnly = options.forbidOnly ?? ctx.fileConfig.forbidOnly ?? true;
145
- if (result.hasFocusedTests && forbidOnly) {
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 watchPatterns = ctx.options.watchPatterns ?? ctx.fileConfig.watchPatterns ?? DEFAULT_WATCH_PATTERNS;
154
- const target = await resolveModWatchTarget(ctx.modsDir, ctx.options.modPath, ctx.options.modName);
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 watchPatterns = ctx.options.watchPatterns ?? ctx.fileConfig.watchPatterns ?? DEFAULT_WATCH_PATTERNS;
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, options) {
210
- const ctx = await setupTestRun(patterns, options);
211
- if (options.watch && options.graphics) {
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 (options.watch) {
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, cliConfigSchema, parseCliTestOptions } from "./config/index.js";
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("cliConfigSchema", () => {
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(cliConfigSchema.parse(config)).toEqual(config);
28
+ expect(fileConfigSchema.parse(config)).toEqual(config);
29
29
  });
30
30
  it("rejects unknown keys in strict mode", () => {
31
- expect(() => cliConfigSchema.strict().parse({ unknownKey: true })).toThrow();
31
+ expect(() => fileConfigSchema.strict().parse({ unknownKey: true })).toThrow();
32
32
  });
33
33
  it("accepts forbidOnly boolean", () => {
34
34
  const config = { forbidOnly: false };
35
- expect(cliConfigSchema.parse(config)).toEqual(config);
35
+ expect(fileConfigSchema.parse(config)).toEqual(config);
36
36
  });
37
37
  it("defaults forbidOnly to undefined", () => {
38
- expect(cliConfigSchema.parse({}).forbidOnly).toBeUndefined();
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
- const { status } = data.summary;
109
- const color = status === "passed" ? chalk.greenBright : status === "todo" ? chalk.yellowBright : chalk.redBright;
110
- console.log("Test run result:", color(status));
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) {
@@ -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;