test-helper.ts 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338
  1. /**
  2. * Integration test helper utilities for Kilo Code CLI
  3. * Inspired by google-gemini/gemini-cli test infrastructure
  4. */
  5. import { expect } from "vitest"
  6. import { execSync } from "node:child_process"
  7. import { mkdirSync, writeFileSync, readFileSync, existsSync, rmSync } from "node:fs"
  8. import { join, dirname } from "node:path"
  9. import { fileURLToPath } from "node:url"
  10. import { env, stdout as processStdout } from "node:process"
  11. import * as pty from "@lydell/node-pty"
  12. import stripAnsi from "strip-ansi"
  13. import { tmpdir } from "node:os"
  14. const __dirname = dirname(fileURLToPath(import.meta.url))
  15. // Get timeout based on environment
  16. function getDefaultTimeout() {
  17. if (env["CI"]) return 60000 // 1 minute in CI
  18. return 15000 // 15s locally
  19. }
  20. /**
  21. * Poll a predicate function until it returns true or times out
  22. */
  23. export async function poll(predicate: () => boolean, timeout: number, interval: number): Promise<boolean> {
  24. const startTime = Date.now()
  25. let attempts = 0
  26. while (Date.now() - startTime < timeout) {
  27. attempts++
  28. const result = predicate()
  29. if (env["VERBOSE"] === "true" && attempts % 5 === 0) {
  30. console.log(`Poll attempt ${attempts}: ${result ? "success" : "waiting..."}`)
  31. }
  32. if (result) {
  33. return true
  34. }
  35. await new Promise((resolve) => setTimeout(resolve, interval))
  36. }
  37. if (env["VERBOSE"] === "true") {
  38. console.log(`Poll timed out after ${attempts} attempts`)
  39. }
  40. return false
  41. }
  42. /**
  43. * Sanitize test name for use as directory name
  44. */
  45. function sanitizeTestName(name: string) {
  46. return name
  47. .toLowerCase()
  48. .replace(/[^a-z0-9]/g, "-")
  49. .replace(/-+/g, "-")
  50. }
  51. /**
  52. * Interactive run helper for PTY-based testing
  53. */
  54. export class InteractiveRun {
  55. ptyProcess: pty.IPty
  56. public output = ""
  57. constructor(ptyProcess: pty.IPty) {
  58. this.ptyProcess = ptyProcess
  59. ptyProcess.onData((data) => {
  60. this.output += data
  61. if (env["KEEP_OUTPUT"] === "true" || env["VERBOSE"] === "true") {
  62. processStdout.write(data)
  63. }
  64. })
  65. }
  66. /**
  67. * Wait for specific text to appear in output
  68. */
  69. async expectText(text: string, timeout?: number) {
  70. if (!timeout) {
  71. timeout = getDefaultTimeout()
  72. }
  73. const found = await poll(() => stripAnsi(this.output).toLowerCase().includes(text.toLowerCase()), timeout, 200)
  74. expect(found, `Did not find expected text: "${text}". Output was:\n${stripAnsi(this.output)}`).toBe(true)
  75. }
  76. /**
  77. * Wait for a regex pattern to match in output
  78. */
  79. async expectPattern(pattern: RegExp, timeout?: number) {
  80. if (!timeout) {
  81. timeout = getDefaultTimeout()
  82. }
  83. const found = await poll(() => pattern.test(stripAnsi(this.output)), timeout, 200)
  84. expect(found, `Did not find expected pattern: ${pattern}. Output was:\n${stripAnsi(this.output)}`).toBe(true)
  85. }
  86. /**
  87. * Type text slowly (character by character) with echo verification
  88. */
  89. async type(text: string) {
  90. let typedSoFar = ""
  91. for (const char of text) {
  92. this.ptyProcess.write(char)
  93. typedSoFar += char
  94. // Wait for the typed sequence to be echoed back
  95. const found = await poll(
  96. () => stripAnsi(this.output).includes(typedSoFar),
  97. 5000, // 5s timeout per character
  98. 10, // check frequently
  99. )
  100. if (!found) {
  101. throw new Error(
  102. `Timed out waiting for typed text to appear in output: "${typedSoFar}".\nStripped output:\n${stripAnsi(
  103. this.output,
  104. )}`,
  105. )
  106. }
  107. }
  108. }
  109. /**
  110. * Simulate typing one character at a time to avoid paste detection
  111. */
  112. async sendKeys(text: string) {
  113. const delay = 5
  114. for (const char of text) {
  115. this.ptyProcess.write(char)
  116. await new Promise((resolve) => setTimeout(resolve, delay))
  117. }
  118. }
  119. /**
  120. * Press Enter key
  121. */
  122. async pressEnter() {
  123. this.ptyProcess.write("\r")
  124. await new Promise((resolve) => setTimeout(resolve, 50))
  125. }
  126. /**
  127. * Press Escape key
  128. */
  129. async pressEscape() {
  130. this.ptyProcess.write("\x1b")
  131. await new Promise((resolve) => setTimeout(resolve, 50))
  132. }
  133. /**
  134. * Send Ctrl+C
  135. */
  136. async sendCtrlC() {
  137. this.ptyProcess.write("\x03")
  138. await new Promise((resolve) => setTimeout(resolve, 100))
  139. }
  140. /**
  141. * Kill the process
  142. */
  143. async kill() {
  144. this.ptyProcess.kill()
  145. }
  146. /**
  147. * Wait for process to exit and return exit code
  148. */
  149. expectExit(): Promise<number> {
  150. return new Promise((resolve, reject) => {
  151. const timer = setTimeout(
  152. () => reject(new Error(`Test timed out: process did not exit within a minute.`)),
  153. 60000,
  154. )
  155. this.ptyProcess.onExit(({ exitCode }) => {
  156. clearTimeout(timer)
  157. resolve(exitCode)
  158. })
  159. })
  160. }
  161. /**
  162. * Get stripped output (without ANSI codes)
  163. */
  164. getStrippedOutput(): string {
  165. return stripAnsi(this.output)
  166. }
  167. }
  168. /**
  169. * Main test rig for setting up and running CLI integration tests
  170. */
  171. export class TestRig {
  172. bundlePath: string
  173. testName: string
  174. testDir: string = ""
  175. sourceDir: string = ""
  176. constructor(testName: string) {
  177. this.bundlePath = join(__dirname, "..", "dist/index.js")
  178. this.testName = testName
  179. this.setupTestDir()
  180. }
  181. setupTestDir() {
  182. const sanitizedName = sanitizeTestName(this.testName)
  183. const baseDir = join(tmpdir(), "kilocode-cli-tests")
  184. this.testDir = join(baseDir, sanitizedName)
  185. mkdirSync(this.testDir, { recursive: true })
  186. this.sourceDir = join(this.testDir, "src")
  187. mkdirSync(this.sourceDir, { recursive: true })
  188. }
  189. /**
  190. * Create a file in the test workspace
  191. */
  192. createFile(fileName: string, content: string): string {
  193. const filePath = join(this.sourceDir, fileName)
  194. const fileDir = dirname(filePath)
  195. mkdirSync(fileDir, { recursive: true })
  196. writeFileSync(filePath, content)
  197. return filePath
  198. }
  199. /**
  200. * Create a directory in the test workspace
  201. */
  202. mkdir(dir: string) {
  203. mkdirSync(join(this.sourceDir, dir), { recursive: true })
  204. }
  205. /**
  206. * Read a file from the test workspace
  207. */
  208. readFile(fileName: string): string {
  209. const filePath = join(this.sourceDir, fileName)
  210. const content = readFileSync(filePath, "utf-8")
  211. if (env["KEEP_OUTPUT"] === "true" || env["VERBOSE"] === "true") {
  212. console.log(`--- FILE: ${filePath} ---`)
  213. console.log(content)
  214. console.log(`--- END FILE: ${filePath} ---`)
  215. }
  216. return content
  217. }
  218. /**
  219. * Sync filesystem (useful before spawning)
  220. */
  221. sync() {
  222. try {
  223. execSync("sync", { cwd: this.sourceDir })
  224. } catch {
  225. // sync may not be available on all platforms
  226. }
  227. }
  228. /**
  229. * Get command and args for running the CLI
  230. */
  231. private _getCommandAndArgs(extraInitialArgs: string[] = []): {
  232. command: string
  233. initialArgs: string[]
  234. } {
  235. const command = "node"
  236. const initialArgs = [this.bundlePath, ...extraInitialArgs]
  237. return { command, initialArgs }
  238. }
  239. /**
  240. * Run CLI in interactive mode with PTY
  241. */
  242. async runInteractive(
  243. extraArgs: string[] = [],
  244. options: {
  245. env?: Record<string, string>
  246. cols?: number
  247. rows?: number
  248. } = {},
  249. ): Promise<InteractiveRun> {
  250. const { command, initialArgs } = this._getCommandAndArgs()
  251. const commandArgs = [...initialArgs, ...extraArgs]
  252. const ptyEnv = {
  253. // Keep colors so we can see the logo properly
  254. FORCE_COLOR: "1",
  255. KILO_EPHEMERAL_MODE: "true",
  256. KILO_DISABLE_SESSIONS: "true",
  257. KILO_PROVIDER_TYPE: "kilocode",
  258. KILOCODE_MODEL: "anthropic/claude-sonnet-4.5",
  259. KILOCODE_TOKEN: "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
  260. ...process.env,
  261. ...env,
  262. ...options.env,
  263. } as { [key: string]: string }
  264. const ptyOptions: pty.IPtyForkOptions = {
  265. name: "xterm-color",
  266. cols: options.cols || 120,
  267. rows: options.rows || 30,
  268. cwd: this.testDir!,
  269. env: ptyEnv,
  270. }
  271. const executable = command === "node" ? process.execPath : command
  272. const ptyProcess = pty.spawn(executable, commandArgs, ptyOptions)
  273. const run = new InteractiveRun(ptyProcess)
  274. await poll(() => run.getStrippedOutput().includes("/help for commands"), 10_000, 100)
  275. await new Promise((resolve) => setTimeout(resolve, 1_000))
  276. return run
  277. }
  278. /**
  279. * Clean up test directory
  280. */
  281. async cleanup() {
  282. if (this.testDir && !env["KEEP_OUTPUT"]) {
  283. try {
  284. rmSync(this.testDir, { recursive: true, force: true })
  285. } catch (error) {
  286. if (env["VERBOSE"] === "true") {
  287. console.warn("Cleanup warning:", (error as Error).message)
  288. }
  289. }
  290. }
  291. }
  292. /**
  293. * Check if the CLI bundle exists
  294. */
  295. bundleExists(): boolean {
  296. return existsSync(this.bundlePath)
  297. }
  298. }