|
| 1 | +// kilocode_change - new file |
| 2 | +import { describe, expect, test } from "bun:test" |
| 3 | +import { formatTable } from "../../src/cli/cmd/roll-call" |
| 4 | + |
| 5 | +// NOTE: We test formatTable function directly. The color() helper and TTY detection |
| 6 | +// are tested indirectly through the actual roll-call command execution, as they |
| 7 | +// depend on process.stderr.isTTY which changes based on how the process is invoked. |
| 8 | + |
| 9 | +describe("formatTable", () => { |
| 10 | + describe("column width calculation", () => { |
| 11 | + test("columns grow to fit content", () => { |
| 12 | + const rows = [["kilo/provider/model-name", "YES", "Hi", "100ms"]] |
| 13 | + const result = formatTable(rows, 120) |
| 14 | + |
| 15 | + // Model column should be wide enough for the model name |
| 16 | + expect(result.header).toContain("Model") |
| 17 | + expect(result.rows[0]).toContain("kilo/provider/model-name") |
| 18 | + // All columns should be present |
| 19 | + expect(result.rows[0]).toContain("YES") |
| 20 | + expect(result.rows[0]).toContain("Hi") |
| 21 | + expect(result.rows[0]).toContain("100ms") |
| 22 | + }) |
| 23 | + |
| 24 | + test("short snippet does not force minimum width", () => { |
| 25 | + const rows = [["m", "YES", "Hi", "1ms"]] |
| 26 | + const result = formatTable(rows, 120) |
| 27 | + |
| 28 | + // Table should be compact when content is short |
| 29 | + // Header is: "Model | Access | Snippet | Latency" |
| 30 | + // Minimum widths are header lengths: Model=5, Access=6, Snippet=7, Latency=7 |
| 31 | + // With separators: 5 + 6 + 7 + 7 + 9 = 34 |
| 32 | + expect(result.header.length).toBeLessThan(50) |
| 33 | + expect(result.separator.length).toBe(result.header.length) |
| 34 | + }) |
| 35 | + |
| 36 | + test("separator length matches header length", () => { |
| 37 | + const rows = [ |
| 38 | + ["kilo/openai/gpt-4", "YES", "Hello there!", "500ms"], |
| 39 | + ["kilo/anthropic/claude", "NO", "(Error)", "100ms"], |
| 40 | + ] |
| 41 | + const result = formatTable(rows, 120) |
| 42 | + |
| 43 | + expect(result.separator.length).toBe(result.header.length) |
| 44 | + expect(result.separator).toMatch(/^-+$/) |
| 45 | + }) |
| 46 | + |
| 47 | + test("all rows have same length as header", () => { |
| 48 | + const rows = [ |
| 49 | + ["short", "YES", "Hello", "10ms"], |
| 50 | + ["kilo/very/long/provider/model-name", "NO", "(Some error message)", "1000ms"], |
| 51 | + ] |
| 52 | + const result = formatTable(rows, 120) |
| 53 | + |
| 54 | + for (const row of result.rows) { |
| 55 | + expect(row.length).toBe(result.header.length) |
| 56 | + } |
| 57 | + }) |
| 58 | + }) |
| 59 | + |
| 60 | + describe("snippet truncation", () => { |
| 61 | + test("long snippet is truncated with ellipsis when exceeding terminal width", () => { |
| 62 | + const longSnippet = "This is a very long snippet that should be truncated because it exceeds the available width" |
| 63 | + const rows = [["kilo/provider/model", "YES", longSnippet, "100ms"]] |
| 64 | + const result = formatTable(rows, 80) // narrow terminal |
| 65 | + |
| 66 | + expect(result.rows[0]).toContain("...") |
| 67 | + expect(result.rows[0].length).toBeLessThanOrEqual(80) |
| 68 | + }) |
| 69 | + |
| 70 | + test("snippet is not truncated when table fits terminal width", () => { |
| 71 | + const snippet = "Short response" |
| 72 | + const rows = [["m", "YES", snippet, "1ms"]] |
| 73 | + const result = formatTable(rows, 120) |
| 74 | + |
| 75 | + expect(result.rows[0]).toContain(snippet) |
| 76 | + expect(result.rows[0]).not.toContain("...") |
| 77 | + }) |
| 78 | + |
| 79 | + test("table fits within terminal width when content exceeds available space", () => { |
| 80 | + // This test reproduces the "off by 2" bug where table was 2 chars too wide |
| 81 | + const rows = [ |
| 82 | + ["kilo/openrouter/free", "YES", "Le temps passe vi...", "802ms"], |
| 83 | + ["kilo/arcee-ai/trinity-large-preview:free", "YES", '"Le soleil brille...', "1527ms"], |
| 84 | + ["kilo/minimax/minimax-m2.5:free", "YES", "Voici une phrase ...", "2615ms"], |
| 85 | + ["kilo/stepfun/step-3.5-flash:free", "YES", "Aujourd'hui, je s...", "3561ms"], |
| 86 | + ["kilo/corethink:free", "NO", "(Invalid JSON res...", "18490ms"], |
| 87 | + ["kilo/z-ai/glm-5:free", "NO", "(The operation ti...", "25010ms"], |
| 88 | + ] |
| 89 | + const terminalWidth = 80 |
| 90 | + const result = formatTable(rows, terminalWidth) |
| 91 | + |
| 92 | + // Header, separator, and all rows must fit within terminal width |
| 93 | + expect(result.header.length).toBeLessThanOrEqual(terminalWidth) |
| 94 | + expect(result.separator.length).toBeLessThanOrEqual(terminalWidth) |
| 95 | + for (const row of result.rows) { |
| 96 | + expect(row.length).toBeLessThanOrEqual(terminalWidth) |
| 97 | + } |
| 98 | + }) |
| 99 | + |
| 100 | + test("truncate handles very short maxLen gracefully", () => { |
| 101 | + const rows = [["m", "YES", "Hello World", "1ms"]] |
| 102 | + // This shouldn't crash even with extreme truncation |
| 103 | + const result = formatTable(rows, 30) |
| 104 | + expect(result.rows[0]).toBeDefined() |
| 105 | + }) |
| 106 | + }) |
| 107 | + |
| 108 | + describe("error messages", () => { |
| 109 | + test("error message in parentheses is displayed", () => { |
| 110 | + const rows = [["kilo/provider/model", "NO", "(Connection refused)", "500ms"]] |
| 111 | + const result = formatTable(rows, 120) |
| 112 | + |
| 113 | + expect(result.rows[0]).toContain("(Connection refused)") |
| 114 | + }) |
| 115 | + |
| 116 | + test("empty snippet cell is handled", () => { |
| 117 | + const rows = [["kilo/provider/model", "NO", "", "500ms"]] |
| 118 | + const result = formatTable(rows, 120) |
| 119 | + |
| 120 | + // Should not crash and row should be formatted |
| 121 | + expect(result.rows[0]).toContain("kilo/provider/model") |
| 122 | + expect(result.rows[0]).toContain("NO") |
| 123 | + expect(result.rows[0]).toContain("500ms") |
| 124 | + }) |
| 125 | + }) |
| 126 | + |
| 127 | + describe("sanitization", () => { |
| 128 | + test("strips ANSI color codes from snippet", () => { |
| 129 | + const rows = [["model", "YES", "\x1b[92mGreen text\x1b[0m", "100ms"]] |
| 130 | + const result = formatTable(rows, 120) |
| 131 | + |
| 132 | + expect(result.rows[0]).toContain("Green text") |
| 133 | + expect(result.rows[0]).not.toContain("\x1b") |
| 134 | + }) |
| 135 | + |
| 136 | + test("strips null bytes and control characters", () => { |
| 137 | + const rows = [["model", "YES", "Hello\x00World\x01Test", "100ms"]] |
| 138 | + const result = formatTable(rows, 120) |
| 139 | + |
| 140 | + expect(result.rows[0]).toContain("HelloWorldTest") |
| 141 | + expect(result.rows[0]).not.toContain("\x00") |
| 142 | + expect(result.rows[0]).not.toContain("\x01") |
| 143 | + }) |
| 144 | + |
| 145 | + test("width calculation uses sanitized content", () => { |
| 146 | + // ANSI codes add bytes but not visible width |
| 147 | + const withAnsi = "\x1b[92mHi\x1b[0m" // "Hi" with color codes = 11 bytes but 2 visible chars |
| 148 | + const rows = [["m", "YES", withAnsi, "1ms"]] |
| 149 | + const result = formatTable(rows, 120) |
| 150 | + |
| 151 | + // Snippet column should be sized for "Hi" (2 chars), not 11 bytes |
| 152 | + // Header "Snippet" is 7 chars, so minimum width is 7 |
| 153 | + const snippetColStart = result.header.indexOf("Snippet") |
| 154 | + const accessColEnd = result.header.indexOf("Access") + "Access".length |
| 155 | + const snippetWidth = result.header.indexOf(" | Latency") - snippetColStart |
| 156 | + |
| 157 | + expect(snippetWidth).toBe(7) // "Snippet" header length, not inflated by ANSI codes |
| 158 | + }) |
| 159 | + |
| 160 | + test("strips newlines from content", () => { |
| 161 | + const rows = [["model", "YES", "Line1\nLine2", "100ms"]] |
| 162 | + const result = formatTable(rows, 120) |
| 163 | + |
| 164 | + expect(result.rows[0]).toContain("Line1Line2") |
| 165 | + expect(result.rows[0]).not.toContain("\n") |
| 166 | + }) |
| 167 | + }) |
| 168 | + |
| 169 | + describe("edge cases", () => { |
| 170 | + test("empty rows array", () => { |
| 171 | + const result = formatTable([], 120) |
| 172 | + |
| 173 | + expect(result.header).toContain("Model") |
| 174 | + expect(result.rows).toHaveLength(0) |
| 175 | + }) |
| 176 | + |
| 177 | + test("handles undefined cells gracefully", () => { |
| 178 | + const rows = [["model", "YES", undefined as unknown as string, "100ms"]] |
| 179 | + const result = formatTable(rows, 120) |
| 180 | + |
| 181 | + // Should not crash |
| 182 | + expect(result.rows[0]).toContain("model") |
| 183 | + }) |
| 184 | + |
| 185 | + test("very narrow terminal still produces valid output", () => { |
| 186 | + const rows = [["kilo/provider/model", "YES", "Hello", "100ms"]] |
| 187 | + const result = formatTable(rows, 40) |
| 188 | + |
| 189 | + // Should produce valid output even if truncated |
| 190 | + expect(result.header.length).toBeGreaterThan(0) |
| 191 | + expect(result.rows[0].length).toBe(result.header.length) |
| 192 | + }) |
| 193 | + |
| 194 | + test("terminal width of 120 (default) handles typical content", () => { |
| 195 | + const rows = [ |
| 196 | + ["kilo/openai/gpt-4", "YES", "Hello! How can I help you today?", "500ms"], |
| 197 | + ["kilo/anthropic/claude-3-opus", "YES", "Hi there! I'm Claude, an AI assistant...", "1200ms"], |
| 198 | + ["kilo/google/gemini-pro", "NO", "(Rate limit exceeded)", "100ms"], |
| 199 | + ] |
| 200 | + const result = formatTable(rows, 120) |
| 201 | + |
| 202 | + // All rows should fit within 120 chars |
| 203 | + expect(result.header.length).toBeLessThanOrEqual(120) |
| 204 | + for (const row of result.rows) { |
| 205 | + expect(row.length).toBeLessThanOrEqual(120) |
| 206 | + } |
| 207 | + }) |
| 208 | + }) |
| 209 | +}) |
0 commit comments