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.
@@ -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
  }
@@ -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.2",
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.")
@@ -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, options) {
41
- const fileConfig = loadConfig(options.config);
42
- mergeCliConfig(fileConfig, options);
43
- setVerbose(!!options.verbose);
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 (options.modPath === undefined && options.modName === undefined) {
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 = options.factorioPath ?? autoDetectFactorioPath();
51
- const dataDir = path.resolve(options.dataDirectory);
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, options.modPath, options.modName, options.verbose);
55
- 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)) : [];
56
56
  await installFactorioTest(modsDir);
57
- const configModRequirements = options.mods
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
- ...(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`)) ?? []),
69
69
  ];
70
- if (options.verbose)
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 = options.graphics ? "graphics" : "headless";
75
- const savePath = getHeadlessSavePath(options.save ?? fileConfig.save);
76
- const outputPath = options.outputFile === false
77
- ? undefined
78
- : (options.outputFile ?? fileConfig.outputFile ?? getDefaultOutputPath(dataDir));
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, outputPath, factorioArgs, testConfig, options } = ctx;
83
+ const { config, factorioPath, dataDir, modsDir, modToTest, mode, savePath, factorioArgs } = ctx;
102
84
  const { signal, skipResetAutorun, resolveOnResult } = execOptions ?? {};
103
- const reorderEnabled = options.reorderFailedFirst ?? ctx.fileConfig.test?.reorder_failed_first ?? true;
104
- 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) : [];
105
87
  await setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest, mode, {
106
- verbose: options.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: options.verbose,
118
- quiet: options.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: options.verbose,
123
- quiet: options.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, options.verbose);
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 (outputPath && result.data) {
137
- await writeResultsFile(outputPath, modToTest, result.data);
138
- if (options.verbose)
139
- 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}`);
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
- const color = resultStatus == "passed" ? chalk.greenBright : resultStatus == "todo" ? chalk.yellowBright : chalk.redBright;
147
- console.log("Test run result:", color(resultStatus));
148
- const forbidOnly = options.forbidOnly ?? ctx.fileConfig.forbidOnly ?? true;
149
- 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) {
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 watchPatterns = ctx.options.watchPatterns ?? ctx.fileConfig.watchPatterns ?? DEFAULT_WATCH_PATTERNS;
158
- const target = await resolveModWatchTarget(ctx.modsDir, ctx.options.modPath, ctx.options.modName);
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 watchPatterns = ctx.options.watchPatterns ?? ctx.fileConfig.watchPatterns ?? DEFAULT_WATCH_PATTERNS;
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, options) {
214
- const ctx = await setupTestRun(patterns, options);
215
- 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) {
216
197
  await runGraphicsWatchMode(ctx);
217
198
  }
218
- else if (options.watch) {
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, 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;