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", () => {