factorio-test-cli 2.0.0 → 3.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/mod-setup.js ADDED
@@ -0,0 +1,246 @@
1
+ import * as fsp from "fs/promises";
2
+ import * as fs from "fs";
3
+ import * as path from "path";
4
+ import { runScript, runProcess } from "./process-utils.js";
5
+ import { getFactorioPlayerDataPath } from "./factorio-discovery.js";
6
+ import { CliError } from "./cli-error.js";
7
+ const MIN_FACTORIO_TEST_VERSION = "3.0.0";
8
+ function parseVersion(version) {
9
+ const parts = version.split(".").map(Number);
10
+ return [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0];
11
+ }
12
+ function compareVersions(a, b) {
13
+ const [aMajor, aMinor, aPatch] = parseVersion(a);
14
+ const [bMajor, bMinor, bPatch] = parseVersion(b);
15
+ if (aMajor !== bMajor)
16
+ return aMajor - bMajor;
17
+ if (aMinor !== bMinor)
18
+ return aMinor - bMinor;
19
+ return aPatch - bPatch;
20
+ }
21
+ export async function configureModToTest(modsDir, modPath, modName, verbose) {
22
+ if (modPath) {
23
+ if (verbose)
24
+ console.log("Creating mod symlink", modPath);
25
+ return configureModPath(modPath, modsDir);
26
+ }
27
+ else {
28
+ await configureModName(modsDir, modName);
29
+ return modName;
30
+ }
31
+ }
32
+ async function configureModPath(modPath, modsDir) {
33
+ modPath = path.resolve(modPath);
34
+ const infoJsonFile = path.join(modPath, "info.json");
35
+ let infoJson;
36
+ try {
37
+ infoJson = JSON.parse(await fsp.readFile(infoJsonFile, "utf8"));
38
+ }
39
+ catch (e) {
40
+ throw new CliError(`Could not read info.json file from ${modPath}`, { cause: e });
41
+ }
42
+ const modName = infoJson.name;
43
+ if (typeof modName !== "string") {
44
+ throw new CliError(`info.json file at ${infoJsonFile} does not contain a string property "name".`);
45
+ }
46
+ const resultPath = path.join(modsDir, modName);
47
+ const stat = await fsp.stat(resultPath).catch(() => undefined);
48
+ if (stat)
49
+ await fsp.rm(resultPath, { recursive: true });
50
+ await fsp.symlink(modPath, resultPath, "junction");
51
+ return modName;
52
+ }
53
+ async function configureModName(modsDir, modName) {
54
+ const exists = await checkModExists(modsDir, modName);
55
+ if (!exists) {
56
+ throw new CliError(`Mod ${modName} not found in ${modsDir}.`);
57
+ }
58
+ }
59
+ export async function checkModExists(modsDir, modName) {
60
+ const stat = await fsp.stat(modsDir).catch(() => undefined);
61
+ if (!stat?.isDirectory())
62
+ return false;
63
+ const files = await fsp.readdir(modsDir);
64
+ return files.some((f) => {
65
+ const fileStat = fs.statSync(path.join(modsDir, f), { throwIfNoEntry: false });
66
+ if (fileStat?.isDirectory()) {
67
+ return f === modName || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+$`));
68
+ }
69
+ if (fileStat?.isFile()) {
70
+ return f === modName + ".zip" || f.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+\\.zip$`));
71
+ }
72
+ return false;
73
+ });
74
+ }
75
+ async function getInstalledModVersion(modsDir, modName) {
76
+ const stat = await fsp.stat(modsDir).catch(() => undefined);
77
+ if (!stat?.isDirectory())
78
+ return undefined;
79
+ const files = await fsp.readdir(modsDir);
80
+ for (const f of files) {
81
+ const fullPath = path.join(modsDir, f);
82
+ const fileStat = fs.statSync(fullPath, { throwIfNoEntry: false });
83
+ if (fileStat?.isDirectory()) {
84
+ if (f === modName) {
85
+ const infoPath = path.join(fullPath, "info.json");
86
+ try {
87
+ const info = JSON.parse(await fsp.readFile(infoPath, "utf8"));
88
+ if (info.version)
89
+ return info.version;
90
+ }
91
+ catch {
92
+ continue;
93
+ }
94
+ }
95
+ const versionedMatch = f.match(new RegExp(`^${modName}_(\\d+\\.\\d+\\.\\d+)$`));
96
+ if (versionedMatch) {
97
+ const infoPath = path.join(fullPath, "info.json");
98
+ try {
99
+ const info = JSON.parse(await fsp.readFile(infoPath, "utf8"));
100
+ if (info.version)
101
+ return info.version;
102
+ }
103
+ catch {
104
+ return versionedMatch[1];
105
+ }
106
+ }
107
+ }
108
+ if (fileStat?.isFile()) {
109
+ const zipMatch = f.match(new RegExp(`^${modName}_(\\d+\\.\\d+\\.\\d+)\\.zip$`));
110
+ if (zipMatch)
111
+ return zipMatch[1];
112
+ }
113
+ }
114
+ return undefined;
115
+ }
116
+ export async function installFactorioTest(modsDir) {
117
+ await fsp.mkdir(modsDir, { recursive: true });
118
+ const playerDataPath = getFactorioPlayerDataPath();
119
+ let version = await getInstalledModVersion(modsDir, "factorio-test");
120
+ if (!version) {
121
+ console.log("Downloading factorio-test from mod portal using fmtk.");
122
+ await runScript("fmtk", "mods", "install", "--modsPath", modsDir, "--playerData", playerDataPath, "factorio-test");
123
+ version = await getInstalledModVersion(modsDir, "factorio-test");
124
+ }
125
+ else if (compareVersions(version, MIN_FACTORIO_TEST_VERSION) < 0) {
126
+ console.log(`factorio-test ${version} is outdated, downloading latest version.`);
127
+ await runScript("fmtk", "mods", "install", "--force", "--modsPath", modsDir, "--playerData", playerDataPath, "factorio-test");
128
+ version = await getInstalledModVersion(modsDir, "factorio-test");
129
+ }
130
+ if (!version || compareVersions(version, MIN_FACTORIO_TEST_VERSION) < 0) {
131
+ throw new CliError(`factorio-test mod version ${version ?? "unknown"} is below minimum required ${MIN_FACTORIO_TEST_VERSION}`);
132
+ }
133
+ }
134
+ export async function ensureConfigIni(dataDir) {
135
+ const filePath = path.join(dataDir, "config.ini");
136
+ if (!fs.existsSync(filePath)) {
137
+ console.log("Creating config.ini file");
138
+ await fsp.writeFile(filePath, `; This file was auto-generated by factorio-test cli
139
+
140
+ [path]
141
+ read-data=__PATH__executable__/../../data
142
+ write-data=${dataDir}
143
+
144
+ [general]
145
+ locale=
146
+ `);
147
+ }
148
+ else {
149
+ const content = await fsp.readFile(filePath, "utf8");
150
+ const newContent = content.replace(/^write-data=.*$/m, `write-data=${dataDir}`);
151
+ if (content !== newContent) {
152
+ await fsp.writeFile(filePath, newContent);
153
+ }
154
+ }
155
+ }
156
+ export async function setSettingsForAutorun(factorioPath, dataDir, modsDir, modToTest, mode, options) {
157
+ const settingsDat = path.join(modsDir, "mod-settings.dat");
158
+ if (!fs.existsSync(settingsDat)) {
159
+ if (options?.verbose)
160
+ console.log("Creating mod-settings.dat file by running factorio");
161
+ const dummySaveFile = path.join(dataDir, "____dummy_save_file.zip");
162
+ await runProcess(false, factorioPath, "--create", dummySaveFile, "--mod-directory", modsDir, "-c", path.join(dataDir, "config.ini"));
163
+ if (fs.existsSync(dummySaveFile)) {
164
+ await fsp.rm(dummySaveFile);
165
+ }
166
+ }
167
+ if (options?.verbose)
168
+ console.log("Setting autorun settings");
169
+ const autoStartConfig = JSON.stringify({
170
+ mod: modToTest,
171
+ headless: mode === "headless",
172
+ ...(options?.lastFailedTests?.length && { last_failed_tests: options.lastFailedTests }),
173
+ });
174
+ await runScript("fmtk", "settings", "set", "startup", "factorio-test-auto-start-config", autoStartConfig, "--modsPath", modsDir);
175
+ await runScript("fmtk", "settings", "unset", "startup", "factorio-test-auto-start", "--modsPath", modsDir);
176
+ }
177
+ export async function resetAutorunSettings(modsDir, verbose) {
178
+ if (verbose)
179
+ console.log("Disabling auto-start settings");
180
+ await runScript("fmtk", "settings", "set", "startup", "factorio-test-auto-start-config", "{}", "--modsPath", modsDir);
181
+ }
182
+ export function parseRequiredDependencies(dependencies) {
183
+ const result = [];
184
+ for (const dep of dependencies) {
185
+ const trimmed = dep.trim();
186
+ if (trimmed.startsWith("?") || trimmed.startsWith("!") || trimmed.startsWith("(?)")) {
187
+ continue;
188
+ }
189
+ const withoutPrefix = trimmed.startsWith("~") ? trimmed.slice(1).trim() : trimmed;
190
+ const modName = withoutPrefix.split(/\s/)[0];
191
+ if (modName && modName !== "base") {
192
+ result.push(modName);
193
+ }
194
+ }
195
+ return result;
196
+ }
197
+ export async function installModDependencies(modsDir, modPath, verbose) {
198
+ const infoJsonPath = path.join(modPath, "info.json");
199
+ let infoJson;
200
+ try {
201
+ infoJson = JSON.parse(await fsp.readFile(infoJsonPath, "utf8"));
202
+ }
203
+ catch {
204
+ return [];
205
+ }
206
+ const dependencies = infoJson.dependencies;
207
+ if (!Array.isArray(dependencies))
208
+ return [];
209
+ const required = parseRequiredDependencies(dependencies);
210
+ const playerDataPath = getFactorioPlayerDataPath();
211
+ for (const modName of required) {
212
+ const exists = await checkModExists(modsDir, modName);
213
+ if (exists)
214
+ continue;
215
+ if (verbose)
216
+ console.log(`Installing dependency: ${modName}`);
217
+ await runScript("fmtk", "mods", "install", "--modsPath", modsDir, "--playerData", playerDataPath, modName);
218
+ }
219
+ return required;
220
+ }
221
+ export async function resolveModWatchTarget(modsDir, modPath, modName) {
222
+ if (modPath) {
223
+ return { type: "directory", path: path.resolve(modPath) };
224
+ }
225
+ const files = await fsp.readdir(modsDir);
226
+ for (const file of files) {
227
+ const fullPath = path.join(modsDir, file);
228
+ const fileStat = await fsp.lstat(fullPath).catch(() => undefined);
229
+ if (!fileStat)
230
+ continue;
231
+ const isDirectoryMatch = fileStat.isDirectory() || fileStat.isSymbolicLink()
232
+ ? file === modName || file.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+$`))
233
+ : false;
234
+ if (isDirectoryMatch) {
235
+ const targetPath = fileStat.isSymbolicLink() ? await fsp.realpath(fullPath) : fullPath;
236
+ return { type: "directory", path: targetPath };
237
+ }
238
+ const isFileMatch = fileStat.isFile()
239
+ ? file === modName + ".zip" || file.match(new RegExp(`^${modName}_\\d+\\.\\d+\\.\\d+\\.zip$`))
240
+ : false;
241
+ if (isFileMatch) {
242
+ return { type: "file", path: fullPath };
243
+ }
244
+ }
245
+ throw new CliError(`Could not find mod ${modName} in ${modsDir} for watching`);
246
+ }
@@ -0,0 +1,30 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseRequiredDependencies } from "./mod-setup.js";
3
+ describe("parseRequiredDependencies", () => {
4
+ it.each([
5
+ [[], []],
6
+ [["some-mod"], ["some-mod"]],
7
+ [["some-mod >= 1.0.0"], ["some-mod"]],
8
+ [["~ some-mod >= 1.0.0"], ["some-mod"]],
9
+ [["? optional-mod >= 1.0.0"], []],
10
+ [["! incompatible-mod"], []],
11
+ [["(?) hidden-optional >= 1.0.0"], []],
12
+ [["base >= 1.1.0"], []],
13
+ [[" mod-name >= 1.0.0 "], ["mod-name"]],
14
+ [["~ soft-mod"], ["soft-mod"]],
15
+ [
16
+ [
17
+ "base >= 1.1.0",
18
+ "required-mod >= 2.0.0",
19
+ "? optional-mod",
20
+ "! incompatible-mod",
21
+ "(?) hidden-optional",
22
+ "~ soft-required >= 1.0.0",
23
+ "another-required",
24
+ ],
25
+ ["required-mod", "soft-required", "another-required"],
26
+ ],
27
+ ])("parseRequiredDependencies(%j) => %j", (input, expected) => {
28
+ expect(parseRequiredDependencies(input)).toEqual(expected);
29
+ });
30
+ });
@@ -0,0 +1,94 @@
1
+ import chalk from "chalk";
2
+ function formatDuration(ms) {
3
+ if (ms >= 1000) {
4
+ return `${(ms / 1000).toFixed(2)}s`;
5
+ }
6
+ return `${ms.toFixed(1)}ms`;
7
+ }
8
+ export class OutputFormatter {
9
+ options;
10
+ constructor(options) {
11
+ this.options = options;
12
+ }
13
+ formatTestResult(test) {
14
+ if (this.options.quiet)
15
+ return;
16
+ const showLogs = test.result === "failed" || this.options.showPassedLogs;
17
+ if (showLogs && test.logs.length > 0) {
18
+ for (const log of test.logs) {
19
+ console.log(" " + log);
20
+ }
21
+ }
22
+ const prefix = this.getPrefix(test.result);
23
+ const duration = test.durationMs !== undefined ? ` (${formatDuration(test.durationMs)})` : "";
24
+ console.log(`${prefix} ${test.path}${duration}`);
25
+ if (test.result === "failed") {
26
+ for (const error of test.errors) {
27
+ console.log(" " + error);
28
+ }
29
+ }
30
+ }
31
+ formatSummary(data) {
32
+ if (!data.summary)
33
+ return;
34
+ const { status } = data.summary;
35
+ const color = status === "passed" ? chalk.greenBright : status === "todo" ? chalk.yellowBright : chalk.redBright;
36
+ console.log("Test run result:", color(status));
37
+ }
38
+ getPrefix(result) {
39
+ switch (result) {
40
+ case "passed":
41
+ return chalk.green("PASS");
42
+ case "failed":
43
+ return chalk.red("FAIL");
44
+ case "skipped":
45
+ return chalk.yellow("SKIP");
46
+ case "todo":
47
+ return chalk.magenta("TODO");
48
+ }
49
+ }
50
+ }
51
+ export class OutputPrinter {
52
+ options;
53
+ formatter;
54
+ isMessageFirstLine = true;
55
+ constructor(options) {
56
+ this.options = options;
57
+ this.formatter = new OutputFormatter({
58
+ verbose: options.verbose,
59
+ quiet: options.quiet,
60
+ showPassedLogs: options.verbose,
61
+ });
62
+ }
63
+ printTestResult(test) {
64
+ if (this.options.quiet || this.options.useProgressBar)
65
+ return;
66
+ this.formatter.formatTestResult(test);
67
+ }
68
+ printFailures(tests) {
69
+ for (const test of tests) {
70
+ if (test.result === "failed") {
71
+ this.formatter.formatTestResult(test);
72
+ }
73
+ }
74
+ }
75
+ printMessage(line) {
76
+ if (this.options.quiet)
77
+ return;
78
+ if (this.isMessageFirstLine) {
79
+ console.log(line.slice(line.indexOf(": ") + 2));
80
+ this.isMessageFirstLine = false;
81
+ }
82
+ else {
83
+ console.log(" " + line);
84
+ }
85
+ }
86
+ resetMessage() {
87
+ this.isMessageFirstLine = true;
88
+ }
89
+ printVerbose(line) {
90
+ if (this.options.verbose) {
91
+ console.log(line);
92
+ }
93
+ }
94
+ }
@@ -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.0",
3
+ "version": "3.0.0",
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
+ }