factorio-test-cli 2.0.1 → 3.0.1

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.
@@ -0,0 +1,93 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import { OutputFormatter } from "./output-formatter.js";
3
+ describe("OutputFormatter", () => {
4
+ let consoleSpy;
5
+ let output;
6
+ beforeEach(() => {
7
+ output = [];
8
+ consoleSpy = vi.spyOn(console, "log").mockImplementation((...args) => {
9
+ output.push(args.join(" "));
10
+ });
11
+ });
12
+ afterEach(() => {
13
+ consoleSpy.mockRestore();
14
+ });
15
+ const passedTest = {
16
+ path: "root > test1",
17
+ result: "passed",
18
+ errors: [],
19
+ logs: [],
20
+ durationMs: 1.23,
21
+ };
22
+ const failedTest = {
23
+ path: "root > failing",
24
+ result: "failed",
25
+ errors: ["assertion failed", "at test.ts:10"],
26
+ logs: ["debug output"],
27
+ durationMs: 0.5,
28
+ };
29
+ it("formats passed test with duration", () => {
30
+ const formatter = new OutputFormatter({});
31
+ formatter.formatTestResult(passedTest);
32
+ expect(output).toHaveLength(1);
33
+ expect(output[0]).toContain("PASS");
34
+ expect(output[0]).toContain("root > test1");
35
+ expect(output[0]).toContain("1.2ms");
36
+ });
37
+ it("formats failed test with errors", () => {
38
+ const formatter = new OutputFormatter({});
39
+ formatter.formatTestResult(failedTest);
40
+ expect(output.some((line) => line.includes("FAIL"))).toBe(true);
41
+ expect(output.some((line) => line.includes("assertion failed"))).toBe(true);
42
+ expect(output.some((line) => line.includes("at test.ts:10"))).toBe(true);
43
+ });
44
+ it("shows logs before failed test result", () => {
45
+ const formatter = new OutputFormatter({});
46
+ formatter.formatTestResult(failedTest);
47
+ const logIndex = output.findIndex((line) => line.includes("debug output"));
48
+ const failIndex = output.findIndex((line) => line.includes("FAIL"));
49
+ expect(logIndex).toBeLessThan(failIndex);
50
+ });
51
+ it("hides logs for passed tests by default", () => {
52
+ const formatter = new OutputFormatter({});
53
+ const testWithLogs = { ...passedTest, logs: ["should not appear"] };
54
+ formatter.formatTestResult(testWithLogs);
55
+ expect(output.some((line) => line.includes("should not appear"))).toBe(false);
56
+ });
57
+ it("shows logs for passed tests when showPassedLogs is true", () => {
58
+ const formatter = new OutputFormatter({ showPassedLogs: true });
59
+ const testWithLogs = { ...passedTest, logs: ["visible log"] };
60
+ formatter.formatTestResult(testWithLogs);
61
+ expect(output.some((line) => line.includes("visible log"))).toBe(true);
62
+ });
63
+ it("suppresses all output when quiet is true", () => {
64
+ const formatter = new OutputFormatter({ quiet: true });
65
+ formatter.formatTestResult(passedTest);
66
+ formatter.formatTestResult(failedTest);
67
+ expect(output).toHaveLength(0);
68
+ });
69
+ it("formats skipped test", () => {
70
+ const formatter = new OutputFormatter({});
71
+ const skippedTest = {
72
+ path: "skipped test",
73
+ result: "skipped",
74
+ errors: [],
75
+ logs: [],
76
+ };
77
+ formatter.formatTestResult(skippedTest);
78
+ expect(output[0]).toContain("SKIP");
79
+ expect(output[0]).toContain("skipped test");
80
+ });
81
+ it("formats todo test", () => {
82
+ const formatter = new OutputFormatter({});
83
+ const todoTest = {
84
+ path: "todo test",
85
+ result: "todo",
86
+ errors: [],
87
+ logs: [],
88
+ };
89
+ formatter.formatTestResult(todoTest);
90
+ expect(output[0]).toContain("TODO");
91
+ expect(output[0]).toContain("todo test");
92
+ });
93
+ });
package/package.json CHANGED
@@ -1,32 +1,44 @@
1
1
  {
2
2
  "name": "factorio-test-cli",
3
- "version": "2.0.1",
3
+ "version": "3.0.1",
4
4
  "description": "A CLI to run FactorioTest.",
5
5
  "license": "MIT",
6
- "repository": "https://github.com/GlassBricks/FactorioTest",
6
+ "repository": {
7
+ "type": "git",
8
+ "url": "git+https://github.com/GlassBricks/FactorioTest.git"
9
+ },
7
10
  "type": "module",
8
11
  "bin": {
9
- "factorio-test": "./cli.js"
12
+ "factorio-test": "cli.js"
10
13
  },
11
14
  "engines": {
12
15
  "node": ">=18.0.0"
13
16
  },
14
17
  "files": [
15
- "*.js"
18
+ "**/*.js",
19
+ "headless-save.zip"
16
20
  ],
17
21
  "dependencies": {
18
- "chalk": "^5.3.0",
22
+ "chalk": "^5.6.2",
19
23
  "commander": "^12.1.0",
20
- "factoriomod-debug": "^2.0.3"
24
+ "factoriomod-debug": "^2.0.10",
25
+ "log-update": "^7.0.2",
26
+ "minimatch": "^10.1.1",
27
+ "zod": "^3.24.0"
21
28
  },
22
29
  "devDependencies": {
23
30
  "@commander-js/extra-typings": "^12.1.0",
24
31
  "del-cli": "^6.0.0",
25
- "typescript": "^5.7.2"
32
+ "typescript": "^5.9.3",
33
+ "vitest": "^3.0.0"
26
34
  },
27
35
  "scripts": {
28
36
  "build": "npm run clean && tsc",
37
+ "cli": "tsx cli.ts",
29
38
  "lint": "eslint .",
39
+ "pretest": "npm run build",
40
+ "test": "vitest run",
41
+ "test:watch": "vitest",
30
42
  "prepublishOnly": "npm run build",
31
43
  "clean": "del-cli \"*.js\""
32
44
  }
@@ -0,0 +1,27 @@
1
+ import { spawn } from "child_process";
2
+ import { CliError } from "./cli-error.js";
3
+ let verbose = false;
4
+ export function setVerbose(v) {
5
+ verbose = v;
6
+ }
7
+ export function runScript(...command) {
8
+ return runProcess(true, "npx", ...command);
9
+ }
10
+ export function runProcess(inheritStdio, command, ...args) {
11
+ if (verbose)
12
+ console.log("Running:", command, ...args);
13
+ const proc = spawn(command, args, {
14
+ stdio: inheritStdio ? "inherit" : "ignore",
15
+ });
16
+ return new Promise((resolve, reject) => {
17
+ proc.on("error", reject);
18
+ proc.on("exit", (code) => {
19
+ if (code === 0) {
20
+ resolve();
21
+ }
22
+ else {
23
+ reject(new CliError(`Command exited with code ${code}: ${command} ${args.join(" ")}`));
24
+ }
25
+ });
26
+ });
27
+ }
@@ -0,0 +1,70 @@
1
+ import chalk from "chalk";
2
+ import logUpdate from "log-update";
3
+ export class ProgressRenderer {
4
+ isTTY;
5
+ active = false;
6
+ total = 0;
7
+ ran = 0;
8
+ passed = 0;
9
+ failed = 0;
10
+ skipped = 0;
11
+ todo = 0;
12
+ currentTest;
13
+ constructor(isTTY = process.stdout.isTTY ?? false) {
14
+ this.isTTY = isTTY;
15
+ }
16
+ handleEvent(event) {
17
+ if (event.type === "testRunStarted") {
18
+ this.total = event.total;
19
+ }
20
+ else if (event.type === "testStarted") {
21
+ this.currentTest = event.test.path;
22
+ this.render();
23
+ }
24
+ }
25
+ handleTestFinished(test) {
26
+ this.currentTest = undefined;
27
+ this.ran++;
28
+ if (test.result === "passed")
29
+ this.passed++;
30
+ else if (test.result === "failed")
31
+ this.failed++;
32
+ else if (test.result === "skipped")
33
+ this.skipped++;
34
+ else if (test.result === "todo")
35
+ this.todo++;
36
+ }
37
+ withPermanentOutput(callback) {
38
+ if (!this.isTTY || !this.active) {
39
+ callback();
40
+ return;
41
+ }
42
+ logUpdate.clear();
43
+ callback();
44
+ this.render();
45
+ }
46
+ finish() {
47
+ if (this.active)
48
+ logUpdate.clear();
49
+ }
50
+ render() {
51
+ if (!this.isTTY)
52
+ return;
53
+ this.active = true;
54
+ const percent = this.total > 0 ? Math.floor((this.ran / this.total) * 100) : 0;
55
+ const barWidth = 20;
56
+ const filled = Math.floor((percent / 100) * barWidth);
57
+ const bar = "█".repeat(filled) + "░".repeat(barWidth - filled);
58
+ const counts = [
59
+ chalk.green(`✓${this.passed}`),
60
+ this.failed > 0 ? chalk.red(`✗${this.failed}`) : null,
61
+ this.skipped > 0 ? chalk.yellow(`○${this.skipped}`) : null,
62
+ this.todo > 0 ? chalk.magenta(`◌${this.todo}`) : null,
63
+ ]
64
+ .filter(Boolean)
65
+ .join(" ");
66
+ const progress = `[${bar}] ${percent}% ${this.ran}/${this.total} ${counts}`;
67
+ const current = this.currentTest ? `Running: ${this.currentTest}` : "";
68
+ logUpdate(progress + "\n" + current);
69
+ }
70
+ }
@@ -0,0 +1,88 @@
1
+ import { describe, it, expect, vi, beforeEach } from "vitest";
2
+ import logUpdate from "log-update";
3
+ import { ProgressRenderer } from "./progress-renderer.js";
4
+ vi.mock("log-update", () => ({
5
+ default: Object.assign(vi.fn(), { clear: vi.fn() }),
6
+ }));
7
+ describe("ProgressRenderer", () => {
8
+ beforeEach(() => {
9
+ vi.mocked(logUpdate).mockClear();
10
+ vi.mocked(logUpdate.clear).mockClear();
11
+ });
12
+ describe("withPermanentOutput", () => {
13
+ it("clears and re-renders around permanent output when active", () => {
14
+ const renderer = new ProgressRenderer(true);
15
+ renderer.handleEvent({ type: "testRunStarted", total: 10 });
16
+ renderer.handleEvent({ type: "testStarted", test: { path: "test" } });
17
+ vi.mocked(logUpdate).mockClear();
18
+ vi.mocked(logUpdate.clear).mockClear();
19
+ let callbackExecuted = false;
20
+ renderer.withPermanentOutput(() => {
21
+ callbackExecuted = true;
22
+ });
23
+ expect(logUpdate.clear).toHaveBeenCalledTimes(1);
24
+ expect(callbackExecuted).toBe(true);
25
+ expect(logUpdate).toHaveBeenCalledTimes(1);
26
+ });
27
+ it("executes callback without clear when not active", () => {
28
+ const renderer = new ProgressRenderer(true);
29
+ let callbackExecuted = false;
30
+ renderer.withPermanentOutput(() => {
31
+ callbackExecuted = true;
32
+ });
33
+ expect(callbackExecuted).toBe(true);
34
+ expect(logUpdate.clear).not.toHaveBeenCalled();
35
+ });
36
+ it("executes callback without clear when not TTY", () => {
37
+ const renderer = new ProgressRenderer(false);
38
+ let callbackExecuted = false;
39
+ renderer.withPermanentOutput(() => {
40
+ callbackExecuted = true;
41
+ });
42
+ expect(callbackExecuted).toBe(true);
43
+ expect(logUpdate.clear).not.toHaveBeenCalled();
44
+ expect(logUpdate).not.toHaveBeenCalled();
45
+ });
46
+ });
47
+ describe("render", () => {
48
+ it("renders nothing when not TTY", () => {
49
+ const renderer = new ProgressRenderer(false);
50
+ renderer.handleEvent({ type: "testRunStarted", total: 10 });
51
+ renderer.handleEvent({ type: "testStarted", test: { path: "test" } });
52
+ expect(logUpdate).not.toHaveBeenCalled();
53
+ });
54
+ it("renders progress bar when TTY via withPermanentOutput", () => {
55
+ const renderer = new ProgressRenderer(true);
56
+ renderer.handleEvent({ type: "testRunStarted", total: 10 });
57
+ renderer.handleEvent({ type: "testStarted", test: { path: "test" } });
58
+ vi.mocked(logUpdate).mockClear();
59
+ renderer.handleTestFinished({ path: "test", result: "passed", errors: [], logs: [] });
60
+ renderer.withPermanentOutput(() => { });
61
+ expect(logUpdate).toHaveBeenCalled();
62
+ const output = vi.mocked(logUpdate).mock.calls[0][0];
63
+ expect(output).toContain("10%");
64
+ expect(output).toContain("1/10");
65
+ });
66
+ it("includes current test when running", () => {
67
+ const renderer = new ProgressRenderer(true);
68
+ renderer.handleEvent({ type: "testRunStarted", total: 10 });
69
+ renderer.handleEvent({ type: "testStarted", test: { path: "describe > my test" } });
70
+ const output = vi.mocked(logUpdate).mock.calls[0][0];
71
+ expect(output).toContain("Running: describe > my test");
72
+ });
73
+ });
74
+ describe("finish", () => {
75
+ it("clears on finish when active", () => {
76
+ const renderer = new ProgressRenderer(true);
77
+ renderer.handleEvent({ type: "testRunStarted", total: 10 });
78
+ renderer.handleEvent({ type: "testStarted", test: { path: "test" } });
79
+ renderer.finish();
80
+ expect(logUpdate.clear).toHaveBeenCalled();
81
+ });
82
+ it("does not clear when not active", () => {
83
+ const renderer = new ProgressRenderer(true);
84
+ renderer.finish();
85
+ expect(logUpdate.clear).not.toHaveBeenCalled();
86
+ });
87
+ });
88
+ });
@@ -0,0 +1,30 @@
1
+ import * as fsp from "fs/promises";
2
+ import * as path from "path";
3
+ export async function writeResultsFile(outputPath, modName, data) {
4
+ const content = {
5
+ timestamp: new Date().toISOString(),
6
+ modName,
7
+ summary: data.summary,
8
+ tests: data.tests.map((t) => ({
9
+ path: t.path,
10
+ result: t.result,
11
+ ...(t.durationMs !== undefined && { durationMs: t.durationMs }),
12
+ ...(t.errors.length > 0 && { errors: t.errors }),
13
+ })),
14
+ };
15
+ await fsp.mkdir(path.dirname(outputPath), { recursive: true });
16
+ await fsp.writeFile(outputPath, JSON.stringify(content, null, 2));
17
+ }
18
+ export async function readPreviousFailedTests(outputPath) {
19
+ try {
20
+ const content = await fsp.readFile(outputPath, "utf-8");
21
+ const parsed = JSON.parse(content);
22
+ return parsed.tests.filter((t) => t.result === "failed").map((t) => t.path);
23
+ }
24
+ catch {
25
+ return [];
26
+ }
27
+ }
28
+ export function getDefaultOutputPath(dataDir) {
29
+ return path.join(dataDir, "test-results.json");
30
+ }
@@ -0,0 +1,89 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
2
+ import * as fsp from "fs/promises";
3
+ import { writeResultsFile, readPreviousFailedTests, getDefaultOutputPath, } from "./results-writer.js";
4
+ vi.mock("fs/promises");
5
+ describe("writeResultsFile", () => {
6
+ beforeEach(() => {
7
+ vi.mocked(fsp.mkdir).mockResolvedValue(undefined);
8
+ vi.mocked(fsp.writeFile).mockResolvedValue();
9
+ });
10
+ afterEach(() => {
11
+ vi.clearAllMocks();
12
+ });
13
+ it("writes results with correct structure", async () => {
14
+ const data = {
15
+ tests: [
16
+ { path: "test1", result: "passed", errors: [], logs: [], durationMs: 1 },
17
+ { path: "test2", result: "failed", errors: ["error"], logs: [], durationMs: 2 },
18
+ ],
19
+ summary: {
20
+ ran: 2,
21
+ passed: 1,
22
+ failed: 1,
23
+ skipped: 0,
24
+ todo: 0,
25
+ cancelled: 0,
26
+ describeBlockErrors: 0,
27
+ status: "failed",
28
+ },
29
+ };
30
+ await writeResultsFile("/out/results.json", "test-mod", data);
31
+ expect(fsp.mkdir).toHaveBeenCalledWith("/out", { recursive: true });
32
+ const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1]);
33
+ expect(written.modName).toBe("test-mod");
34
+ expect(written.tests).toHaveLength(2);
35
+ expect(written.tests[0].errors).toBeUndefined();
36
+ expect(written.tests[1].errors).toEqual(["error"]);
37
+ });
38
+ it("omits duration when not present", async () => {
39
+ const data = {
40
+ tests: [{ path: "test1", result: "skipped", errors: [], logs: [] }],
41
+ };
42
+ await writeResultsFile("/out/results.json", "test-mod", data);
43
+ const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1]);
44
+ expect(written.tests[0].durationMs).toBeUndefined();
45
+ });
46
+ it("omits errors array when empty", async () => {
47
+ const data = {
48
+ tests: [{ path: "test1", result: "passed", errors: [], logs: [], durationMs: 5 }],
49
+ };
50
+ await writeResultsFile("/out/results.json", "test-mod", data);
51
+ const written = JSON.parse(vi.mocked(fsp.writeFile).mock.calls[0][1]);
52
+ expect(written.tests[0].errors).toBeUndefined();
53
+ });
54
+ });
55
+ describe("readPreviousFailedTests", () => {
56
+ afterEach(() => {
57
+ vi.clearAllMocks();
58
+ });
59
+ it("returns failed test paths", async () => {
60
+ const content = {
61
+ timestamp: "2026-01-24T00:00:00Z",
62
+ modName: "test",
63
+ summary: undefined,
64
+ tests: [
65
+ { path: "passing", result: "passed" },
66
+ { path: "failing1", result: "failed" },
67
+ { path: "failing2", result: "failed" },
68
+ ],
69
+ };
70
+ vi.mocked(fsp.readFile).mockResolvedValue(JSON.stringify(content));
71
+ const result = await readPreviousFailedTests("/path/to/results.json");
72
+ expect(result).toEqual(["failing1", "failing2"]);
73
+ });
74
+ it("returns empty array on file not found", async () => {
75
+ vi.mocked(fsp.readFile).mockRejectedValue(new Error("ENOENT"));
76
+ const result = await readPreviousFailedTests("/path/to/results.json");
77
+ expect(result).toEqual([]);
78
+ });
79
+ it("returns empty array on invalid JSON", async () => {
80
+ vi.mocked(fsp.readFile).mockResolvedValue("not valid json");
81
+ const result = await readPreviousFailedTests("/path/to/results.json");
82
+ expect(result).toEqual([]);
83
+ });
84
+ });
85
+ describe("getDefaultOutputPath", () => {
86
+ it("returns path in data directory", () => {
87
+ expect(getDefaultOutputPath("/data/dir")).toBe("/data/dir/test-results.json");
88
+ });
89
+ });