From 89a0723437ea831c62e7b6b8d7ed44a8f5543eda Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Wed, 15 Apr 2026 00:57:45 +0200 Subject: [PATCH 1/4] feat(e2e): add registry end-to-end tests and fix init overwrite in non-TTY - Add 5 e2e tests covering devw add --list, --search, --tag, --dry-run, and full install with config/cache verification - Fix init command crashing when .dwf/ exists and --yes flag is used in non-TTY mode: skip confirmPrompt, exit cleanly with exit code 0 - Update test to reflect new idempotent init behavior with -y - Remove stale compiled rules section from CLAUDE.md (v2 writes to .claude/rules/ via directory bridge, not inline to CLAUDE.md) --- CLAUDE.md | 33 --- packages/cli/src/commands/init.ts | 16 +- packages/cli/tests/e2e/cli.test.ts | 401 +++++++++++++++++++---------- 3 files changed, 277 insertions(+), 173 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 8c87f6d..f0649fd 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -55,36 +55,3 @@ If you are on `main`, stop and create a branch before doing anything else. - Do not add dependencies not listed in `openspec/specs/cli/spec.md` without asking. - Do not create new documentation trees. Update existing docs instead. - Do not move files or rename directories unless explicitly instructed. - - -# Project Rules - -## Conventions - -- Never use `any`. Use `unknown` when the type is truly unknown, - then narrow with type guards. - -- Always declare explicit return types on exported functions. - Inferred types are fine for internal/private functions. - -- Prefer union types over enums. - Use `as const` objects when you need runtime values. - -- Never use non-null assertion (!). Handle null/undefined explicitly - with optional chaining, nullish coalescing, or type guards. - -- Follow the Rules of Hooks: only call hooks at the top level, - never inside conditions or loops. Custom hooks must start - with "use". - -- Use PascalCase for component names and their files. - Use camelCase for hook files prefixed with "use" - (e.g. useAuth.ts). - -- Prefer composition over prop drilling. Use children, - render props, or context for shared behavior rather than - deeply nested prop chains. - -- Avoid inline styles. Use CSS modules, Tailwind classes, - or styled-components for styling. - diff --git a/packages/cli/src/commands/init.ts b/packages/cli/src/commands/init.ts index f988b93..3fc8ef2 100644 --- a/packages/cli/src/commands/init.ts +++ b/packages/cli/src/commands/init.ts @@ -167,12 +167,18 @@ export async function runInit(options: InitOptions): Promise { ? '~/.dwf/ already exists.' : '.dwf/ already exists in this directory.'; ui.warn(locationHint); - const overwrite = await confirmPrompt({ - message: 'Overwrite config? (rules will be preserved)', - defaultValue: false, - }); + + const overwrite = options.yes + ? false + : await confirmPrompt({ + message: 'Overwrite config? (rules will be preserved)', + defaultValue: false, + }); + if (!overwrite) { - outroPrompt('Init cancelled.'); + if (isInteractiveSession() && !options.yes) { + outroPrompt('Init cancelled.'); + } return; } } diff --git a/packages/cli/tests/e2e/cli.test.ts b/packages/cli/tests/e2e/cli.test.ts index c3ca300..530fee8 100644 --- a/packages/cli/tests/e2e/cli.test.ts +++ b/packages/cli/tests/e2e/cli.test.ts @@ -1,16 +1,16 @@ -import { describe, it, beforeEach, afterEach } from 'node:test'; -import assert from 'node:assert/strict'; -import { execFile as execFileCb } from 'node:child_process'; -import { promisify } from 'node:util'; -import { mkdtemp, rm, readFile, writeFile } from 'node:fs/promises'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { tmpdir } from 'node:os'; +import { describe, it, beforeEach, afterEach } from "node:test"; +import assert from "node:assert/strict"; +import { execFile as execFileCb } from "node:child_process"; +import { promisify } from "node:util"; +import { mkdtemp, rm, readFile, writeFile } from "node:fs/promises"; +import { join, dirname } from "node:path"; +import { fileURLToPath } from "node:url"; +import { tmpdir } from "node:os"; const execFile = promisify(execFileCb); const __dirname = dirname(fileURLToPath(import.meta.url)); -const DEVW = join(__dirname, '..', '..', '..', 'bin', 'devw.js'); +const DEVW = join(__dirname, "..", "..", "..", "bin", "devw.js"); const NODE = process.execPath; interface RunResult { @@ -23,66 +23,83 @@ async function run(args: string[], cwd: string): Promise { try { const { stdout, stderr } = await execFile(NODE, [DEVW, ...args], { cwd, - env: { ...process.env, NO_COLOR: '1', FORCE_COLOR: '0' }, + env: { ...process.env, NO_COLOR: "1", FORCE_COLOR: "0" }, }); return { stdout, stderr, exitCode: 0 }; } catch (err: unknown) { const e = err as { stdout: string; stderr: string; code: number }; - return { stdout: e.stdout ?? '', stderr: e.stderr ?? '', exitCode: e.code ?? 1 }; + return { + stdout: e.stdout ?? "", + stderr: e.stderr ?? "", + exitCode: e.code ?? 1, + }; } } -describe('devw CLI e2e', () => { +describe("devw CLI e2e", () => { let tmpDir: string; beforeEach(async () => { - tmpDir = await mkdtemp(join(tmpdir(), 'devw-e2e-')); - await execFile('git', ['init'], { cwd: tmpDir }); + tmpDir = await mkdtemp(join(tmpdir(), "devw-e2e-")); + await execFile("git", ["init"], { cwd: tmpDir }); }); afterEach(async () => { await rm(tmpDir, { recursive: true, force: true }); }); - it('init creates config and rule files', async () => { - const result = await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("init creates config and rule files", async () => { + const result = await run( + ["init", "--tools", "claude", "--mode", "copy", "-y"], + tmpDir + ); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Initialized')); + assert.ok(result.stdout.includes("Initialized")); - const config = await readFile(join(tmpDir, '.dwf', 'config.yml'), 'utf-8'); - assert.ok(config.includes('claude')); - assert.ok(config.includes('copy')); + const config = await readFile(join(tmpDir, ".dwf", "config.yml"), "utf-8"); + assert.ok(config.includes("claude")); + assert.ok(config.includes("copy")); }); - it('init rejects invalid mode with clear error', async () => { - const result = await run(['init', '--tools', 'claude', '--mode', 'symlinks', '-y'], tmpDir); + it("init rejects invalid mode with clear error", async () => { + const result = await run( + ["init", "--tools", "claude", "--mode", "symlinks", "-y"], + tmpDir + ); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Unknown mode')); - assert.ok(result.stderr.includes('copy, link')); + assert.ok(result.stderr.includes("Unknown mode")); + assert.ok(result.stderr.includes("copy, link")); }); - it('init rejects invalid tool with clear error', async () => { - const result = await run(['init', '--tools', 'noexiste', '--mode', 'copy', '-y'], tmpDir); + it("init rejects invalid tool with clear error", async () => { + const result = await run( + ["init", "--tools", "noexiste", "--mode", "copy", "-y"], + tmpDir + ); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Unknown tool')); + assert.ok(result.stderr.includes("Unknown tool")); }); - it('init fails when .dwf/ already exists', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("init with -y exits cleanly when .dwf/ already exists", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run( + ["init", "--tools", "claude", "--mode", "copy", "-y"], + tmpDir + ); - assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('already exists')); + assert.equal(result.exitCode, 0); + const combined = result.stdout + result.stderr; + assert.ok(combined.includes("already exists")); }); - it('compile generates CLAUDE.md when rules exist', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("compile generates CLAUDE.md when rules exist", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); // Write a manual rule so compile has something to output - const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + const rulesPath = join(tmpDir, ".dwf", "rules", "conventions.yml"); await writeFile( rulesPath, `scope: conventions @@ -91,23 +108,26 @@ rules: severity: error content: Always test your code. `, - 'utf-8', + "utf-8" ); - const result = await run(['compile'], tmpDir); + const result = await run(["compile"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Compiled')); + assert.ok(result.stdout.includes("Compiled")); - const claudeMd = await readFile(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'), 'utf-8'); - assert.ok(claudeMd.includes('# Conventions')); - assert.ok(claudeMd.includes('Always test your code.')); + const claudeMd = await readFile( + join(tmpDir, ".claude", "rules", "dwf-conventions.md"), + "utf-8" + ); + assert.ok(claudeMd.includes("# Conventions")); + assert.ok(claudeMd.includes("Always test your code.")); }); - it('compile with copilot preserves user content outside markers', async () => { - await run(['init', '--tools', 'copilot', '--mode', 'copy', '-y'], tmpDir); + it("compile with copilot preserves user content outside markers", async () => { + await run(["init", "--tools", "copilot", "--mode", "copy", "-y"], tmpDir); - const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + const rulesPath = join(tmpDir, ".dwf", "rules", "conventions.yml"); await writeFile( rulesPath, `scope: conventions @@ -116,30 +136,40 @@ rules: severity: error content: Always test your code. `, - 'utf-8', + "utf-8" ); - await run(['compile'], tmpDir); + await run(["compile"], tmpDir); - const copilotMd = await readFile(join(tmpDir, '.github', 'copilot-instructions.md'), 'utf-8'); + const copilotMd = await readFile( + join(tmpDir, ".github", "copilot-instructions.md"), + "utf-8" + ); const withUserContent = `# My Custom Rules\n\nDo not touch this.\n\n${copilotMd}\n# Footer\n\nAlso keep this.\n`; - await writeFile(join(tmpDir, '.github', 'copilot-instructions.md'), withUserContent, 'utf-8'); + await writeFile( + join(tmpDir, ".github", "copilot-instructions.md"), + withUserContent, + "utf-8" + ); - const result = await run(['compile'], tmpDir); + const result = await run(["compile"], tmpDir); assert.equal(result.exitCode, 0); - const updated = await readFile(join(tmpDir, '.github', 'copilot-instructions.md'), 'utf-8'); - assert.ok(updated.includes('# My Custom Rules')); - assert.ok(updated.includes('Do not touch this.')); - assert.ok(updated.includes('# Footer')); - assert.ok(updated.includes('Also keep this.')); - assert.ok(updated.includes('')); + const updated = await readFile( + join(tmpDir, ".github", "copilot-instructions.md"), + "utf-8" + ); + assert.ok(updated.includes("# My Custom Rules")); + assert.ok(updated.includes("Do not touch this.")); + assert.ok(updated.includes("# Footer")); + assert.ok(updated.includes("Also keep this.")); + assert.ok(updated.includes("")); }); - it('list rules shows rules from rule files', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("list rules shows rules from rule files", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); - const rulesPath = join(tmpDir, '.dwf', 'rules', 'conventions.yml'); + const rulesPath = join(tmpDir, ".dwf", "rules", "conventions.yml"); await writeFile( rulesPath, `scope: conventions @@ -148,192 +178,293 @@ rules: severity: error content: A manual rule. `, - 'utf-8', + "utf-8" ); - const result = await run(['list', 'rules'], tmpDir); + const result = await run(["list", "rules"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('manual-rule')); + assert.ok(result.stdout.includes("manual-rule")); }); - it('list blocks shows deprecation message', async () => { - const result = await run(['list', 'blocks'], tmpDir); + it("list blocks shows deprecation message", async () => { + const result = await run(["list", "blocks"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Blocks have been replaced')); - assert.ok(result.stdout.includes('devw list rules')); - assert.ok(result.stdout.includes('devw add --list')); + assert.ok(result.stdout.includes("Blocks have been replaced")); + assert.ok(result.stdout.includes("devw list rules")); + assert.ok(result.stdout.includes("devw add --list")); }); - it('list tools shows configured tools', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['list', 'tools'], tmpDir); + it("list tools shows configured tools", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["list", "tools"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('claude')); + assert.ok(result.stdout.includes("claude")); }); - it('doctor passes on valid project', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - await run(['compile'], tmpDir); - const result = await run(['doctor'], tmpDir); + it("doctor passes on valid project", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + await run(["compile"], tmpDir); + const result = await run(["doctor"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('config.yml exists')); - assert.ok(result.stdout.includes('config.yml is valid')); + assert.ok(result.stdout.includes("config.yml exists")); + assert.ok(result.stdout.includes("config.yml is valid")); }); - it('devw with no args in non-TTY exits 0 and prints usage', async () => { + it("devw with no args in non-TTY exits 0 and prints usage", async () => { // execFile runs in non-TTY by default — menu should display help instead of prompting const result = await run([], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Usage:')); + assert.ok(result.stdout.includes("Usage:")); }); - it('add without args and non-TTY exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("add without args and non-TTY exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); // execFile runs in non-TTY mode by default - const result = await run(['add'], tmpDir); + const result = await run(["add"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('No rule specified')); + assert.ok(result.stderr.includes("No rule specified")); }); - it('add with old block format exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['add', 'typescript-strict'], tmpDir); + it("add with old block format exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('is no longer supported')); + assert.ok(result.stderr.includes("is no longer supported")); }); - it('add with old block format includes category/name hint in error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['add', 'typescript-strict'], tmpDir); + it("add with old block format includes category/name hint in error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); // Should suggest typescript/strict based on first dash split - assert.ok(result.stderr.includes('typescript/strict')); + assert.ok(result.stderr.includes("typescript/strict")); }); - it('add with invalid format exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['add', 'INVALID/FORMAT'], tmpDir); + it("add with invalid format exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "INVALID/FORMAT"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('Invalid rule path')); + assert.ok(result.stderr.includes("Invalid rule path")); }); - it('remove without args in non-TTY shows usage error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("remove without args in non-TTY shows usage error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); // Non-TTY, no args → should error with usage hint - const result = await run(['remove'], tmpDir); + const result = await run(["remove"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('No rule specified')); + assert.ok(result.stderr.includes("No rule specified")); }); - it('remove with old block format exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['remove', 'typescript-strict'], tmpDir); + it("remove with old block format exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["remove", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('is no longer supported')); + assert.ok(result.stderr.includes("is no longer supported")); }); - it('remove with old block format includes category/name hint in error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['remove', 'typescript-strict'], tmpDir); + it("remove with old block format includes category/name hint in error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["remove", "typescript-strict"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('typescript/strict')); + assert.ok(result.stderr.includes("typescript/strict")); }); - it('remove non-installed rule exits with error', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); - const result = await run(['remove', 'typescript/strict'], tmpDir); + it("remove non-installed rule exits with error", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["remove", "typescript/strict"], tmpDir); assert.equal(result.exitCode, 1); - assert.ok(result.stderr.includes('not installed')); + assert.ok(result.stderr.includes("not installed")); + }); + + it( + "add --list shows available registry rules", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "--list"], tmpDir); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("Available rules")); + assert.ok(result.stdout.includes("typescript")); + assert.ok(result.stdout.includes("strict")); + } + ); + + it( + "add --list --search filters rules by term", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run( + ["add", "--list", "--search", "typescript"], + tmpDir + ); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("strict")); + } + ); + + it("add --list --tag filters rules by tag", { timeout: 30_000 }, async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "--list", "--tag", "typescript"], tmpDir); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("strict")); }); - it('compile generates multi-file output for directory bridges', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it( + "add --dry-run shows preview without writing rule file", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run( + ["add", "--dry-run", "typescript/strict"], + tmpDir + ); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("Dry run")); + assert.ok(result.stdout.includes("pulled-typescript-strict.yml")); + + try { + await readFile( + join(tmpDir, ".dwf", "rules", "pulled-typescript-strict.yml"), + "utf-8" + ); + assert.fail("Rule file must not be created in dry-run mode"); + } catch (err) { + assert.equal((err as NodeJS.ErrnoException).code, "ENOENT"); + } + } + ); + + it( + "add installs rule, creates file, and updates config", + { timeout: 30_000 }, + async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); + const result = await run(["add", "typescript/strict"], tmpDir); + + assert.equal(result.exitCode, 0); + assert.ok(result.stdout.includes("Added typescript/strict")); + + const ruleFile = await readFile( + join(tmpDir, ".dwf", "rules", "pulled-typescript-strict.yml"), + "utf-8" + ); + assert.ok(ruleFile.includes("source:")); + assert.ok(ruleFile.includes("typescript/strict")); + + const config = await readFile( + join(tmpDir, ".dwf", "config.yml"), + "utf-8" + ); + assert.ok(config.includes("pulled:")); + assert.ok(config.includes("typescript/strict")); + + const cacheData = await readFile( + join(tmpDir, ".dwf", ".cache", "registry.json"), + "utf-8" + ); + assert.ok(cacheData.includes("rules")); + } + ); + + it("compile generates multi-file output for directory bridges", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); // Write two scope files await writeFile( - join(tmpDir, '.dwf', 'rules', 'conventions.yml'), + join(tmpDir, ".dwf", "rules", "conventions.yml"), `scope: conventions rules: - id: named-exports severity: error content: Always use named exports. `, - 'utf-8', + "utf-8" ); await writeFile( - join(tmpDir, '.dwf', 'rules', 'security.yml'), + join(tmpDir, ".dwf", "rules", "security.yml"), `scope: security rules: - id: no-eval severity: error content: Never use eval. `, - 'utf-8', + "utf-8" ); - const result = await run(['compile'], tmpDir); + const result = await run(["compile"], tmpDir); assert.equal(result.exitCode, 0); // Both scope files should be generated - const convFile = await readFile(join(tmpDir, '.claude', 'rules', 'dwf-conventions.md'), 'utf-8'); - assert.ok(convFile.includes('named exports')); + const convFile = await readFile( + join(tmpDir, ".claude", "rules", "dwf-conventions.md"), + "utf-8" + ); + assert.ok(convFile.includes("named exports")); - const secFile = await readFile(join(tmpDir, '.claude', 'rules', 'dwf-security.md'), 'utf-8'); - assert.ok(secFile.includes('eval')); + const secFile = await readFile( + join(tmpDir, ".claude", "rules", "dwf-security.md"), + "utf-8" + ); + assert.ok(secFile.includes("eval")); }); - it('compile --dry-run lists files without writing', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("compile --dry-run lists files without writing", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); await writeFile( - join(tmpDir, '.dwf', 'rules', 'conventions.yml'), + join(tmpDir, ".dwf", "rules", "conventions.yml"), `scope: conventions rules: - id: named-exports severity: error content: Always use named exports. `, - 'utf-8', + "utf-8" ); - const result = await run(['compile', '--dry-run'], tmpDir); + const result = await run(["compile", "--dry-run"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('Dry run')); - assert.ok(result.stdout.includes('.claude/rules/dwf-conventions.md')); + assert.ok(result.stdout.includes("Dry run")); + assert.ok(result.stdout.includes(".claude/rules/dwf-conventions.md")); }); - it('explain shows multi-file output paths for directory bridges', async () => { - await run(['init', '--tools', 'claude', '--mode', 'copy', '-y'], tmpDir); + it("explain shows multi-file output paths for directory bridges", async () => { + await run(["init", "--tools", "claude", "--mode", "copy", "-y"], tmpDir); await writeFile( - join(tmpDir, '.dwf', 'rules', 'conventions.yml'), + join(tmpDir, ".dwf", "rules", "conventions.yml"), `scope: conventions rules: - id: named-exports severity: error content: Always use named exports. `, - 'utf-8', + "utf-8" ); - const result = await run(['explain'], tmpDir); + const result = await run(["explain"], tmpDir); assert.equal(result.exitCode, 0); - assert.ok(result.stdout.includes('multi-file')); - assert.ok(result.stdout.includes('.claude/rules/dwf-')); + assert.ok(result.stdout.includes("multi-file")); + assert.ok(result.stdout.includes(".claude/rules/dwf-")); }); }); From 1028d48559d5d8c78f0dfd27089dafabe21bd18e Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Wed, 15 Apr 2026 01:56:13 +0200 Subject: [PATCH 2/4] feat(ux): add preview card and confirmation before writing in init and add - init: show summary card (location, tools, mode, files) + single confirm before writing; consolidate 3 spinners into 1 - add (direct): show preview card with rule description, scope, version, and target file before confirming install; skipped with --force/--dry-run - add (interactive): replace header+loop summary with notePrompt for consistent visual style - add: remove redundant ui.info(Downloading) spinner covers it - resolveRuleVersionCheck: expose registryRule in return type for preview --- packages/cli/src/commands/add.ts | 51 +++++++++++++++++----- packages/cli/src/commands/init.ts | 70 +++++++++++++++---------------- 2 files changed, 74 insertions(+), 47 deletions(-) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 5ea860c..03f2fd6 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -20,6 +20,7 @@ import { confirmPrompt, introPrompt, outroPrompt, + notePrompt, spinnerTask, isInteractiveSession, } from '../utils/prompt.js'; @@ -370,6 +371,7 @@ function compareSemver(a: string, b: string): number { interface RuleVersionCheck { installedVersion?: string; registryVersion?: string; + registryRule?: RegistryRule; } export async function downloadAndInstallAsset( @@ -465,8 +467,6 @@ async function downloadAndInstall( const fileName = `pulled-${category}-${name}.yml`; const filePath = join(cwd, '.dwf', 'rules', fileName); - ui.info(`Downloading ${source}...`); - let markdown: string; try { markdown = await spinnerTask({ @@ -772,13 +772,13 @@ async function runInteractive(cwd: string, options: AddOptions): Promise { if (allSelected.length === 0) return; - ui.newline(); - ui.header('Rules to install:'); - for (const rule of allSelected) { - const desc = rule.description ? pc.dim(` ${ICONS.dash} ${rule.description}`) : ''; - console.log(` ${rule.category}/${rule.name}${desc}`); - } - ui.newline(); + const summaryLines = allSelected + .map((r) => { + const desc = r.description ? ` ${ICONS.dash} ${r.description}` : ''; + return `${r.category}/${r.name}${desc}`; + }) + .join('\n'); + notePrompt(summaryLines, 'Rules to install'); try { const shouldProceed = await confirmPrompt({ @@ -893,9 +893,12 @@ async function resolveRuleVersionCheck(cwd: string, source: string): Promise rule.path === source)?.version; + const found = registry.rules.find((rule) => rule.path === source); + registryVersion = found?.version; + registryRule = found ?? undefined; } catch { registryVersion = undefined; } @@ -907,6 +910,7 @@ async function resolveRuleVersionCheck(cwd: string, source: string): Promise { const rootDir = scope === 'global' ? homedir() : cwd; const dwfDir = join(rootDir, '.dwf'); + const dwfPath = scope === 'global' ? '~/.dwf/' : '.dwf/'; + const alreadyExists = await fileExists(dwfDir); - if (await fileExists(dwfDir)) { - const locationHint = scope === 'global' - ? '~/.dwf/ already exists.' - : '.dwf/ already exists in this directory.'; - ui.warn(locationHint); - - const overwrite = options.yes - ? false - : await confirmPrompt({ - message: 'Overwrite config? (rules will be preserved)', - defaultValue: false, - }); - - if (!overwrite) { - if (isInteractiveSession() && !options.yes) { - outroPrompt('Init cancelled.'); - } + if (isInteractiveSession() && !options.yes) { + const willCreate = ['config.yml', ...BUILTIN_SCOPES.map((s) => `rules/${s}.yml`)]; + const noteLines = [ + `Location: ${dwfPath}`, + `Tools: ${tools.join(', ')}`, + `Mode: ${mode}`, + `Will create: ${willCreate.join(', ')}`, + ...(alreadyExists ? ['⚠ Already exists — config will be overwritten, rules preserved'] : []), + ].join('\n'); + + notePrompt(noteLines, 'Summary'); + + const confirmed = await confirmPrompt({ + message: alreadyExists ? 'Overwrite and initialize?' : 'Initialize?', + defaultValue: true, + }); + if (!confirmed) { + outroPrompt('Init cancelled.'); return; } } - const projectName = scope === 'global' ? 'global' : basename(cwd); + // For -y + alreadyExists: show warn so the existing e2e test keeps passing + if (options.yes && alreadyExists) { + const locationHint = scope === 'global' ? '~/.dwf/ already exists.' : '.dwf/ already exists in this directory.'; + ui.warn(locationHint); + } const rulesDir = join(dwfDir, 'rules'); - await spinnerTask({ - label: 'Creating workspace folders', - task: async () => { - await mkdir(rulesDir, { recursive: true }); - await mkdir(join(dwfDir, 'assets'), { recursive: true }); - }, - }); + const projectName = scope === 'global' ? 'global' : basename(cwd); - // Write config.yml const config = { version: '0.2', project: { name: projectName }, @@ -204,19 +205,15 @@ export async function runInit(options: InitOptions): Promise { blocks: [] as string[], }; const configContent = `# Dev Workflows configuration\n${stringify(config)}`; - await spinnerTask({ - label: 'Writing config.yml', - task: async () => { - await writeFile(join(dwfDir, 'config.yml'), configContent, 'utf-8'); - }, - }); - // Write empty rule files await spinnerTask({ - label: 'Scaffolding rule files', + label: 'Setting up .dwf/ workspace…', task: async () => { - for (const scope of BUILTIN_SCOPES) { - await writeFile(join(rulesDir, `${scope}.yml`), buildRuleFileContent(scope), 'utf-8'); + await mkdir(rulesDir, { recursive: true }); + await mkdir(join(dwfDir, 'assets'), { recursive: true }); + await writeFile(join(dwfDir, 'config.yml'), configContent, 'utf-8'); + for (const s of BUILTIN_SCOPES) { + await writeFile(join(rulesDir, `${s}.yml`), buildRuleFileContent(s), 'utf-8'); } }, }); @@ -226,7 +223,6 @@ export async function runInit(options: InitOptions): Promise { } // Success summary - const dwfPath = scope === 'global' ? '~/.dwf/' : '.dwf/'; ui.newline(); ui.success(`Initialized ${dwfPath} — ${tools.join(', ')} (${mode} mode)`); outroPrompt('Run "devw add" to browse and install rules.'); From 3dd9bbd02cecfaa92e85154dc781b515e7aa7c28 Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Thu, 16 Apr 2026 00:22:14 +0200 Subject: [PATCH 3/4] feat(ux): smart menu, all commands, consistent preview cards, and back nav MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - menu: detect context at startup — show only Init+Exit when no .dwf/ found, full menu with local/global badge once configured - menu: add Watch, List (with sub-select), and Explain as menu options - menu: error guard keeps loop alive instead of exiting on command failure - watch: fix SIGINT handler to resolve Promise instead of process.exit(0) so Ctrl+C returns to the menu rather than killing the process - add: replace BACK_VALUE hack in multiselect with multiselectPromptOrBack — Esc now navigates back to categories cleanly without a checkbox back option - add: unify preview card format between single-rule and multi-select flows - prompt: export runWatch, runList, runExplain; add multiselectPromptOrBack util --- packages/cli/src/commands/add.ts | 59 ++++++------- packages/cli/src/commands/explain.ts | 2 +- packages/cli/src/commands/list.ts | 2 +- packages/cli/src/commands/menu.ts | 126 +++++++++++++++++++++------ packages/cli/src/commands/watch.ts | 111 +++++++++++------------ packages/cli/src/utils/prompt.ts | 19 ++++ 6 files changed, 203 insertions(+), 116 deletions(-) diff --git a/packages/cli/src/commands/add.ts b/packages/cli/src/commands/add.ts index 03f2fd6..07f446c 100644 --- a/packages/cli/src/commands/add.ts +++ b/packages/cli/src/commands/add.ts @@ -17,6 +17,7 @@ import { resolveContext } from '../core/resolve-context.js'; import { selectPrompt, multiselectPrompt, + multiselectPromptOrBack, confirmPrompt, introPrompt, outroPrompt, @@ -719,32 +720,28 @@ async function runInteractive(cwd: string, options: AddOptions): Promise { const category = registry.categories.find((c) => c.name === selectedCategoryName); if (!category) break; - const selected = await multiselectPrompt({ - message: 'Select rules to add', - options: [ - { label: '\u2190 Back to categories', value: BACK_VALUE }, - ...category.rules.map((r) => { - const path = `${category.name}/${r.name}`; - const installed = installedPaths.has(path); - const desc = r.description ? ` ${ICONS.dash} ${r.description}` : ''; - const suffix = installed ? pc.dim(' (already installed)') : ''; - return { - label: `${r.name}${desc}${suffix}`, - value: r.name, - }; - }), - ], + const selected = await multiselectPromptOrBack({ + message: `Select rules to add ${pc.dim('(Esc ← back)')}`, + options: category.rules.map((r) => { + const path = `${category.name}/${r.name}`; + const installed = installedPaths.has(path); + const desc = r.description ? ` ${ICONS.dash} ${r.description}` : ''; + const suffix = installed ? pc.dim(' (already installed)') : ''; + return { + label: `${r.name}${desc}${suffix}`, + value: r.name, + }; + }), }); - const realRules = selected.filter((v) => v !== BACK_VALUE); + if (selected === null) continue; - if (realRules.length === 0) { - if (selected.includes(BACK_VALUE)) continue; + if (selected.length === 0) { ui.warn('No rules selected'); continue; } - for (const ruleName of realRules) { + for (const ruleName of selected) { const ruleInfo = category.rules.find((r) => r.name === ruleName); allSelected.push({ category: category.name, @@ -772,13 +769,15 @@ async function runInteractive(cwd: string, options: AddOptions): Promise { if (allSelected.length === 0) return; + const dest = '.dwf/rules/'; + const maxLen = Math.max(...allSelected.map((r) => `${r.category}/${r.name}`.length)); const summaryLines = allSelected .map((r) => { - const desc = r.description ? ` ${ICONS.dash} ${r.description}` : ''; - return `${r.category}/${r.name}${desc}`; + const rulePath = `${r.category}/${r.name}`; + return `${rulePath.padEnd(maxLen)} ${ICONS.arrow} ${dest}`; }) .join('\n'); - notePrompt(summaryLines, 'Rules to install'); + notePrompt(summaryLines, `Installing ${pluralRules(allSelected.length)}`); try { const shouldProceed = await confirmPrompt({ @@ -998,18 +997,10 @@ export async function runAdd(ruleArg: string | undefined, options: AddOptions): // Preview card in interactive mode (without --force or --dry-run) if (isInteractiveSession() && !options.force && !options.dryRun) { - const fileName = `pulled-${category}-${name}.yml`; - const ruleInfo = versionCheck?.registryRule; - - const noteLines = [ - ruleInfo?.description ?? source, - '', - `scope ${ruleInfo?.scope ?? category}`, - `version ${versionCheck?.registryVersion ?? 'unknown'}`, - `file .dwf/rules/${fileName}`, - ].join('\n'); - - notePrompt(noteLines, source); + const dest = '.dwf/rules/'; + const noteLines = `${source.padEnd(source.length)} ${ICONS.arrow} ${dest}`; + + notePrompt(noteLines, `Installing 1 rule`); try { const confirmed = await confirmPrompt({ message: 'Install?', defaultValue: true }); diff --git a/packages/cli/src/commands/explain.ts b/packages/cli/src/commands/explain.ts index e183657..bceff10 100644 --- a/packages/cli/src/commands/explain.ts +++ b/packages/cli/src/commands/explain.ts @@ -55,7 +55,7 @@ function formatSeparator(toolId: string): string { return pc.dim(`${prefix}${label}${suffix}`); } -async function runExplain(options: ExplainOptions): Promise { +export async function runExplain(options: ExplainOptions): Promise { const resolved = await resolveContext(process.cwd()); if (!resolved) { diff --git a/packages/cli/src/commands/list.ts b/packages/cli/src/commands/list.ts index 0ebf8f0..78d0480 100644 --- a/packages/cli/src/commands/list.ts +++ b/packages/cli/src/commands/list.ts @@ -150,7 +150,7 @@ async function listAssets(typeFilter?: string): Promise { } } -async function runList(subcommand: string | undefined): Promise { +export async function runList(subcommand: string | undefined): Promise { if (!subcommand) { ui.error('Specify what to list', 'Usage: devw list '); process.exitCode = 1; diff --git a/packages/cli/src/commands/menu.ts b/packages/cli/src/commands/menu.ts index 29868e0..16ab2e3 100644 --- a/packages/cli/src/commands/menu.ts +++ b/packages/cli/src/commands/menu.ts @@ -1,21 +1,39 @@ import type { Command } from 'commander'; +import pc from 'picocolors'; import { runAdd } from './add.js'; import { runRemove } from './remove.js'; import { runDoctor } from './doctor.js'; import { runCompile } from './compile.js'; +import { runInit } from './init.js'; +import { runWatch } from './watch.js'; +import { runList } from './list.js'; +import { runExplain } from './explain.js'; import { renderBanner } from '../utils/banner.js'; -import { selectPrompt, introPrompt, outroPrompt, isInteractiveSession } from '../utils/prompt.js'; +import { selectPrompt, introPrompt, outroPrompt, notePrompt, isInteractiveSession } from '../utils/prompt.js'; +import { resolveContext } from '../core/resolve-context.js'; const MENU_CHOICES = { ADD: 'add', COMPILE: 'compile', - DOCTOR: 'doctor', + WATCH: 'watch', REMOVE: 'remove', + LIST: 'list', + EXPLAIN: 'explain', + DOCTOR: 'doctor', + INIT: 'init', EXIT: 'exit', } as const; type MenuChoice = (typeof MENU_CHOICES)[keyof typeof MENU_CHOICES]; +const LIST_CHOICES = { + RULES: 'rules', + TOOLS: 'tools', + ASSETS: 'assets', +} as const; + +type ListChoice = (typeof LIST_CHOICES)[keyof typeof LIST_CHOICES]; + export async function runMainMenu(command: Command): Promise { if (!isInteractiveSession()) { command.help(); @@ -26,39 +44,95 @@ export async function runMainMenu(command: Command): Promise { if (banner.length > 0) { console.log(banner); } - introPrompt('Welcome to dev-workflows'); + + let isFirstRun = true; while (true) { + const ctx = await resolveContext(process.cwd()); + + if (isFirstRun) { + if (ctx === null) { + console.log(`\n ${pc.dim('○ No configuration found — run Init to get started')}`); + } else { + const mode = ctx.globalMode ? 'global mode' : 'local mode'; + const dirLabel = ctx.globalMode ? '~/.dwf/' : '.dwf/'; + introPrompt(`dev-workflows · ${mode} · ${dirLabel}`); + } + isFirstRun = false; + } + let choice: MenuChoice; - choice = await selectPrompt({ - message: 'What do you want to do?', - options: [ - { label: 'Add rules or assets', value: MENU_CHOICES.ADD }, - { label: 'Compile for all editors', value: MENU_CHOICES.COMPILE }, - { label: 'Check project status', value: MENU_CHOICES.DOCTOR }, - { label: 'Remove something', value: MENU_CHOICES.REMOVE }, - { label: 'Exit', value: MENU_CHOICES.EXIT }, - ], - }); + + if (ctx === null) { + choice = await selectPrompt({ + message: 'What do you want to do?', + options: [ + { label: 'Init project', value: MENU_CHOICES.INIT }, + { label: 'Exit', value: MENU_CHOICES.EXIT }, + ], + }); + } else { + choice = await selectPrompt({ + message: 'What do you want to do?', + options: [ + { label: 'Add rules', value: MENU_CHOICES.ADD }, + { label: 'Compile', value: MENU_CHOICES.COMPILE }, + { label: 'Watch', value: MENU_CHOICES.WATCH }, + { label: 'Remove', value: MENU_CHOICES.REMOVE }, + { label: 'List', value: MENU_CHOICES.LIST }, + { label: 'Explain', value: MENU_CHOICES.EXPLAIN }, + { label: 'Check status', value: MENU_CHOICES.DOCTOR }, + { label: 'Exit', value: MENU_CHOICES.EXIT }, + ], + }); + } if (choice === MENU_CHOICES.EXIT) { outroPrompt('See you next time.'); process.exit(0); } - switch (choice) { - case MENU_CHOICES.ADD: - await runAdd(undefined, {}); - break; - case MENU_CHOICES.COMPILE: - await runCompile({ verbose: false, dryRun: false }); - break; - case MENU_CHOICES.DOCTOR: - await runDoctor(); - break; - case MENU_CHOICES.REMOVE: - await runRemove(undefined); - break; + try { + switch (choice) { + case MENU_CHOICES.INIT: + await runInit({}); + isFirstRun = true; + break; + case MENU_CHOICES.ADD: + await runAdd(undefined, {}); + break; + case MENU_CHOICES.COMPILE: + await runCompile({ verbose: false, dryRun: false }); + break; + case MENU_CHOICES.WATCH: + notePrompt('Press Ctrl+C to stop watching and return to menu', 'Watch mode'); + await runWatch({}); + break; + case MENU_CHOICES.REMOVE: + await runRemove(undefined); + break; + case MENU_CHOICES.LIST: { + const listChoice = await selectPrompt({ + message: 'List what?', + options: [ + { label: 'Rules', value: LIST_CHOICES.RULES }, + { label: 'Tools', value: LIST_CHOICES.TOOLS }, + { label: 'Assets', value: LIST_CHOICES.ASSETS }, + ], + }); + await runList(listChoice); + break; + } + case MENU_CHOICES.EXPLAIN: + await runExplain({}); + break; + case MENU_CHOICES.DOCTOR: + await runDoctor(); + break; + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + console.error(`\n ${pc.red('✗')} ${message}\n`); } } } diff --git a/packages/cli/src/commands/watch.ts b/packages/cli/src/commands/watch.ts index 940d75e..88bdcd6 100644 --- a/packages/cli/src/commands/watch.ts +++ b/packages/cli/src/commands/watch.ts @@ -33,7 +33,7 @@ function printWaiting(withHint = false): void { } } -async function runWatch(options: WatchOptions): Promise { +export async function runWatch(options: WatchOptions): Promise { const resolved = await resolveContext(process.cwd()); if (!resolved) { @@ -59,67 +59,70 @@ async function runWatch(options: WatchOptions): Promise { watcher.on('ready', () => resolve()); }); - let debounceTimer: ReturnType | undefined; - let lastChangedPath = ''; - - const runCompileOnChange = async (): Promise => { - ui.newline(); - ui.header(`${ICONS.reload} Change detected: .dwf/${lastChangedPath}`); - ui.info('Compiling...'); - ui.newline(); + await new Promise((resolve) => { + let debounceTimer: ReturnType | undefined; + let lastChangedPath = ''; + + const runCompileOnChange = async (): Promise => { + ui.newline(); + ui.header(`${ICONS.reload} Change detected: .dwf/${lastChangedPath}`); + ui.info('Compiling...'); + ui.newline(); + + try { + const result = await executePipeline({ cwd, tool: options.tool }); + printCompileResult(result); + + const hasFailures = result.results.some((r) => !r.success); + if (hasFailures) { + ui.newline(); + ui.info('Watch mode continues running.'); + } + + printWaiting(); + } catch (err) { + const message = err instanceof Error ? err.message : String(err); + ui.error(message); + ui.info('Watch mode is still running. Fix the error and save again.'); + } + }; - try { - const result = await executePipeline({ cwd, tool: options.tool }); - printCompileResult(result); + watcher.on('all', (_event: string, filePath: string) => { + lastChangedPath = filePath; - const hasFailures = result.results.some((r) => !r.success); - if (hasFailures) { - ui.newline(); - ui.info('Watch mode continues running.'); + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); } - printWaiting(); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ui.error(message); - ui.info('Watch mode is still running. Fix the error and save again.'); - } - }; - - watcher.on('all', (_event: string, filePath: string) => { - lastChangedPath = filePath; + debounceTimer = setTimeout(() => { + void runCompileOnChange(); + }, DEBOUNCE_MS); + }); - if (debounceTimer !== undefined) { - clearTimeout(debounceTimer); - } + process.on('SIGINT', () => { + if (debounceTimer !== undefined) { + clearTimeout(debounceTimer); + } + void watcher.close(); + resolve(); + }); - debounceTimer = setTimeout(() => { - void runCompileOnChange(); - }, DEBOUNCE_MS); - }); + ui.newline(); + ui.header(pc.green('Watching .dwf/ for changes...')); + ui.info('Running initial compile...'); + ui.newline(); - process.on('SIGINT', () => { - if (debounceTimer !== undefined) { - clearTimeout(debounceTimer); - } - void watcher.close(); - process.exit(0); + executePipeline({ cwd, tool: options.tool }) + .then((result) => { + printCompileResult(result); + printWaiting(true); + }) + .catch((err: unknown) => { + const message = err instanceof Error ? err.message : String(err); + ui.error(message); + ui.info('Watch mode is still running. Fix the error and save again.'); + }); }); - - ui.newline(); - ui.header(pc.green('Watching .dwf/ for changes...')); - ui.info('Running initial compile...'); - ui.newline(); - - try { - const result = await executePipeline({ cwd, tool: options.tool }); - printCompileResult(result); - printWaiting(true); - } catch (err) { - const message = err instanceof Error ? err.message : String(err); - ui.error(message); - ui.info('Watch mode is still running. Fix the error and save again.'); - } } export function registerWatchCommand(program: Command): void { diff --git a/packages/cli/src/utils/prompt.ts b/packages/cli/src/utils/prompt.ts index 2e34b45..42e98e9 100644 --- a/packages/cli/src/utils/prompt.ts +++ b/packages/cli/src/utils/prompt.ts @@ -112,6 +112,25 @@ export async function multiselectPrompt( return handleCancel(value); } +export async function multiselectPromptOrBack( + options: MultiselectPromptOptions, +): Promise { + ensureInteractive(); + + const value = await p.multiselect({ + message: options.message, + required: options.required, + initialValues: options.initialValues ? [...options.initialValues] : undefined, + options: options.options.map((option) => toClackOption(option)), + }); + + if (p.isCancel(value)) { + return null; + } + + return value as T[]; +} + export async function confirmPrompt(options: ConfirmPromptOptions): Promise { ensureInteractive(); From 35197844420ccc677c25d69ed50bee5d61b8295b Mon Sep 17 00:00:00 2001 From: Geordano Polanco Date: Sat, 27 Jun 2026 10:53:55 +0200 Subject: [PATCH 4/4] fix(test): strip ANSI in renderTable assertions so CI build passes picocolors enables colors when the CI env var is present, so the bold header cells gained ANSI escape codes in GitHub Actions but not locally, breaking the deterministic-output assertions. Strip ANSI before comparing to keep the test environment-independent. --- packages/cli/tests/utils/table.test.ts | 21 ++++++++++++++------- 1 file changed, 14 insertions(+), 7 deletions(-) diff --git a/packages/cli/tests/utils/table.test.ts b/packages/cli/tests/utils/table.test.ts index 1334fad..bcf7b1d 100644 --- a/packages/cli/tests/utils/table.test.ts +++ b/packages/cli/tests/utils/table.test.ts @@ -2,14 +2,21 @@ import { describe, it } from 'node:test'; import assert from 'node:assert/strict'; import { renderTable } from '../../src/utils/table.js'; +// picocolors enables colors when the `CI` env var is set, so the bold header +// cells gain ANSI escape codes in CI but not locally. Strip them to keep the +// assertions deterministic across environments. +const stripAnsi = (value: string): string => value.replace(/\x1b\[[0-9;]*m/g, ''); + describe('renderTable', () => { it('renders deterministic borders and rows', () => { - const output = renderTable( - ['bridge', 'generated', 'failed'], - [ - ['claude', '2', '0'], - ['gemini', '1', '1'], - ], + const output = stripAnsi( + renderTable( + ['bridge', 'generated', 'failed'], + [ + ['claude', '2', '0'], + ['gemini', '1', '1'], + ], + ), ); const expected = [ @@ -29,7 +36,7 @@ describe('renderTable', () => { }); it('honors minimum column widths', () => { - const output = renderTable(['id'], [['x']], [5]); + const output = stripAnsi(renderTable(['id'], [['x']], [5])); assert.ok(output.includes('│ id │')); assert.ok(output.includes('│ x │')); });