From 40927723039b7421e12aa30dc372c3d065a03587 Mon Sep 17 00:00:00 2001
From: ak68a
Date: Wed, 25 Mar 2026 17:02:41 -0500
Subject: [PATCH 1/2] test(cli-tools): add tests for formatters, prompts, and
updateEnvFile
Cover all utility functions that previously had zero tests:
formatters: sectionHeader divider width and step numbering,
successMessage/errorMessage prefixes, wordWrap at custom and
default widths, demoHeader/demoFooter non-empty output, link
URL preservation.
update-env-file: create new file, update existing keys in-place,
append new keys, preserve comments and blank lines, multiple keys,
values containing equals signs. Uses real temp directories.
prompts: log with default wrapping, log with wrap disabled,
logJson formatted output.
---
tools/cli-tools/src/formatters.test.ts | 94 +++++++++++++++++++++
tools/cli-tools/src/prompts.test.ts | 52 ++++++++++++
tools/cli-tools/src/update-env-file.test.ts | 79 +++++++++++++++++
3 files changed, 225 insertions(+)
create mode 100644 tools/cli-tools/src/formatters.test.ts
create mode 100644 tools/cli-tools/src/prompts.test.ts
create mode 100644 tools/cli-tools/src/update-env-file.test.ts
diff --git a/tools/cli-tools/src/formatters.test.ts b/tools/cli-tools/src/formatters.test.ts
new file mode 100644
index 0000000..29ada0d
--- /dev/null
+++ b/tools/cli-tools/src/formatters.test.ts
@@ -0,0 +1,94 @@
+import stripAnsi from "strip-ansi"
+import { describe, expect, it } from "vitest"
+
+import {
+ demoFooter,
+ demoHeader,
+ errorMessage,
+ link,
+ sectionHeader,
+ successMessage,
+ wordWrap,
+} from "./formatters"
+
+describe("sectionHeader", () => {
+ it("creates a header with dividers matching the message width", () => {
+ const header = sectionHeader("Test Section")
+ const plain = stripAnsi(header)
+
+ const lines = plain.trim().split("\n")
+ expect(lines).toHaveLength(3)
+ // Divider length matches the message length
+ expect(lines[0]!.length).toBe(lines[1]!.length)
+ })
+
+ it("includes a step number when provided", () => {
+ const header = sectionHeader("Do the thing", { step: 3 })
+ const plain = stripAnsi(header)
+
+ expect(plain).toContain("Step 3:")
+ expect(plain).toContain("Do the thing")
+ })
+
+ it("omits step prefix when no step is given", () => {
+ const header = sectionHeader("No step here")
+ const plain = stripAnsi(header)
+
+ expect(plain).not.toContain("Step")
+ })
+})
+
+describe("successMessage", () => {
+ it("prefixes with a check mark", () => {
+ const msg = stripAnsi(successMessage("it worked"))
+ expect(msg).toBe("✓ it worked")
+ })
+})
+
+describe("errorMessage", () => {
+ it("prefixes with an X", () => {
+ const msg = stripAnsi(errorMessage("it broke"))
+ expect(msg).toBe("✗ it broke")
+ })
+})
+
+describe("wordWrap", () => {
+ it("wraps long text to the specified width", () => {
+ const longText = "word ".repeat(30).trim()
+ const wrapped = wordWrap(longText, 20)
+
+ for (const line of wrapped.split("\n")) {
+ expect(line.length).toBeLessThanOrEqual(20)
+ }
+ })
+
+ it("defaults to 80 characters", () => {
+ const longText = "word ".repeat(50).trim()
+ const wrapped = wordWrap(longText)
+
+ for (const line of wrapped.split("\n")) {
+ expect(line.length).toBeLessThanOrEqual(80)
+ }
+ })
+})
+
+describe("demoHeader", () => {
+ it("returns a non-empty string", () => {
+ const header = demoHeader("ACK")
+ expect(header.length).toBeGreaterThan(0)
+ })
+})
+
+describe("demoFooter", () => {
+ it("returns a non-empty string", () => {
+ const footer = demoFooter("Done")
+ expect(footer.length).toBeGreaterThan(0)
+ })
+})
+
+describe("link", () => {
+ it("returns the URL with formatting applied", () => {
+ const result = link("https://example.com")
+ expect(stripAnsi(result)).toBe("https://example.com")
+ })
+})
diff --git a/tools/cli-tools/src/prompts.test.ts b/tools/cli-tools/src/prompts.test.ts
new file mode 100644
index 0000000..807d9f2
--- /dev/null
+++ b/tools/cli-tools/src/prompts.test.ts
@@ -0,0 +1,52 @@
+import stripAnsi from "strip-ansi"
+import { beforeEach, describe, expect, it, vi } from "vitest"
+
+import { log, logJson } from "./prompts"
+
+// Capture console.log output
+const logged: string[] = []
+vi.spyOn(console, "log").mockImplementation((...args: unknown[]) => {
+ logged.push(args.map(String).join(" "))
+})
+
+describe("log", () => {
+ beforeEach(() => {
+ logged.length = 0
+ })
+
+ it("prints a message to the console", () => {
+ log("hello")
+ expect(logged.some((l) => stripAnsi(l).includes("hello"))).toBe(true)
+ })
+
+ it("wraps text by default", () => {
+ const long = "word ".repeat(30).trim()
+ log(long)
+
+ // With wrapping, output should have multiple lines
+ const output = logged.join("\n")
+ expect(stripAnsi(output).split("\n").length).toBeGreaterThan(1)
+ })
+
+ it("skips wrapping when wrap is false", () => {
+ const long = "word ".repeat(30).trim()
+ log(long, { wrap: false })
+
+ // Without wrapping, the full string appears on one line
+ expect(logged.some((l) => stripAnsi(l) === long)).toBe(true)
+ })
+})
+
+describe("logJson", () => {
+ beforeEach(() => {
+ logged.length = 0
+ })
+
+ it("prints formatted JSON", () => {
+ logJson({ key: "value" })
+
+ const output = stripAnsi(logged.join("\n"))
+ expect(output).toContain('"key"')
+ expect(output).toContain('"value"')
+ })
+})
diff --git a/tools/cli-tools/src/update-env-file.test.ts b/tools/cli-tools/src/update-env-file.test.ts
new file mode 100644
index 0000000..7ccd295
--- /dev/null
+++ b/tools/cli-tools/src/update-env-file.test.ts
@@ -0,0 +1,79 @@
+import fs from "node:fs/promises"
+import os from "node:os"
+import path from "node:path"
+
+import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"
+
+import { updateEnvFile } from "./update-env-file"
+
+let tmpDir: string
+let envPath: string
+
+beforeEach(async () => {
+ tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), "cli-tools-test-"))
+ envPath = path.join(tmpDir, ".env")
+})
+
+afterEach(async () => {
+ await fs.rm(tmpDir, { recursive: true, force: true })
+})
+
+// Suppress console output during tests
+vi.spyOn(console, "log").mockImplementation(() => {})
+vi.spyOn(console, "error").mockImplementation(() => {})
+
+describe("updateEnvFile", () => {
+ it("creates a new .env file when none exists", async () => {
+ await updateEnvFile({ API_KEY: "abc123" }, envPath)
+
+ const content = await fs.readFile(envPath, "utf8")
+ expect(content).toContain("API_KEY=abc123")
+ })
+
+ it("updates an existing key in-place", async () => {
+ await fs.writeFile(envPath, "API_KEY=old\nOTHER=keep\n")
+
+ await updateEnvFile({ API_KEY: "new" }, envPath)
+
+ const content = await fs.readFile(envPath, "utf8")
+ expect(content).toContain("API_KEY=new")
+ expect(content).toContain("OTHER=keep")
+ expect(content).not.toContain("API_KEY=old")
+ })
+
+ it("appends new keys that dont exist yet", async () => {
+ await fs.writeFile(envPath, "EXISTING=yes\n")
+
+ await updateEnvFile({ NEW_KEY: "hello" }, envPath)
+
+ const content = await fs.readFile(envPath, "utf8")
+ expect(content).toContain("EXISTING=yes")
+ expect(content).toContain("NEW_KEY=hello")
+ })
+
+ it("preserves comments and blank lines", async () => {
+ await fs.writeFile(envPath, "# This is a comment\n\nAPI_KEY=old\n")
+
+ await updateEnvFile({ API_KEY: "new" }, envPath)
+
+ const content = await fs.readFile(envPath, "utf8")
+ expect(content).toContain("# This is a comment")
+ expect(content).toContain("API_KEY=new")
+ })
+
+ it("handles multiple keys at once", async () => {
+ await updateEnvFile({ KEY_A: "a", KEY_B: "b", KEY_C: "c" }, envPath)
+
+ const content = await fs.readFile(envPath, "utf8")
+ expect(content).toContain("KEY_A=a")
+ expect(content).toContain("KEY_B=b")
+ expect(content).toContain("KEY_C=c")
+ })
+
+ it("handles values containing equals signs", async () => {
+ await updateEnvFile({ URL: "https://example.com?a=1&b=2" }, envPath)
+
+ const content = await fs.readFile(envPath, "utf8")
+ expect(content).toContain("URL=https://example.com?a=1&b=2")
+ })
+})
From df576552e91dbb94e6e74c8197047d7cd8fe5cf8 Mon Sep 17 00:00:00 2001
From: ak68a
Date: Wed, 25 Mar 2026 17:04:17 -0500
Subject: [PATCH 2/2] test(cli-tools): add missing coverage for log with
multiple messages and custom width
---
tools/cli-tools/src/prompts.test.ts | 23 +++++++++++++++++++++++
1 file changed, 23 insertions(+)
diff --git a/tools/cli-tools/src/prompts.test.ts b/tools/cli-tools/src/prompts.test.ts
index 807d9f2..9c0f40a 100644
--- a/tools/cli-tools/src/prompts.test.ts
+++ b/tools/cli-tools/src/prompts.test.ts
@@ -35,6 +35,29 @@ describe("log", () => {
// Without wrapping, the full string appears on one line
expect(logged.some((l) => stripAnsi(l) === long)).toBe(true)
})
+
+ it("accepts multiple messages", () => {
+ log("first", "second", "third")
+
+ const output = stripAnsi(logged.join(" "))
+ expect(output).toContain("first")
+ expect(output).toContain("second")
+ expect(output).toContain("third")
+ })
+
+ it("respects custom width", () => {
+ const long = "word ".repeat(30).trim()
+ log(long, { width: 20 })
+
+ // Each logged line should be within the custom width
+ for (const entry of logged) {
+ for (const line of stripAnsi(entry).split("\n")) {
+ if (line.length > 0) {
+ expect(line.length).toBeLessThanOrEqual(20)
+ }
+ }
+ }
+ })
})
describe("logJson", () => {