cli.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417
  1. /**
  2. * CLI Integration Tests
  3. *
  4. * Tests all qmd CLI commands using a temporary test database via INDEX_PATH.
  5. * These tests spawn actual qmd processes to verify end-to-end functionality.
  6. */
  7. import { describe, test, expect, beforeAll, afterAll, beforeEach } from "bun:test";
  8. import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
  9. import { tmpdir } from "os";
  10. import { join } from "path";
  11. // Test fixtures directory and database path
  12. let testDir: string;
  13. let testDbPath: string;
  14. let fixturesDir: string;
  15. let testCounter = 0; // Unique counter for each test run
  16. // Get the directory where this test file lives (same as qmd.ts)
  17. const qmdDir = import.meta.dir;
  18. const qmdScript = join(qmdDir, "qmd.ts");
  19. // Helper to run qmd command with test database
  20. async function runQmd(
  21. args: string[],
  22. options: { cwd?: string; env?: Record<string, string>; dbPath?: string } = {}
  23. ): Promise<{ stdout: string; stderr: string; exitCode: number }> {
  24. const workingDir = options.cwd || fixturesDir;
  25. const dbPath = options.dbPath || testDbPath;
  26. const proc = Bun.spawn(["bun", qmdScript, ...args], {
  27. cwd: workingDir,
  28. env: {
  29. ...process.env,
  30. INDEX_PATH: dbPath,
  31. PWD: workingDir, // Must explicitly set PWD since getPwd() checks this
  32. ...options.env,
  33. },
  34. stdout: "pipe",
  35. stderr: "pipe",
  36. });
  37. const stdout = await new Response(proc.stdout).text();
  38. const stderr = await new Response(proc.stderr).text();
  39. const exitCode = await proc.exited;
  40. return { stdout, stderr, exitCode };
  41. }
  42. // Get a fresh database path for isolated tests
  43. function getFreshDbPath(): string {
  44. testCounter++;
  45. return join(testDir, `test-${testCounter}.sqlite`);
  46. }
  47. // Setup test fixtures
  48. beforeAll(async () => {
  49. // Create temp directory structure
  50. testDir = await mkdtemp(join(tmpdir(), "qmd-test-"));
  51. testDbPath = join(testDir, "test.sqlite");
  52. fixturesDir = join(testDir, "fixtures");
  53. await mkdir(fixturesDir, { recursive: true });
  54. await mkdir(join(fixturesDir, "notes"), { recursive: true });
  55. await mkdir(join(fixturesDir, "docs"), { recursive: true });
  56. // Create test markdown files
  57. await writeFile(
  58. join(fixturesDir, "README.md"),
  59. `# Test Project
  60. This is a test project for QMD CLI testing.
  61. ## Features
  62. - Full-text search with BM25
  63. - Vector similarity search
  64. - Hybrid search with reranking
  65. `
  66. );
  67. await writeFile(
  68. join(fixturesDir, "notes", "meeting.md"),
  69. `# Team Meeting Notes
  70. Date: 2024-01-15
  71. ## Attendees
  72. - Alice
  73. - Bob
  74. - Charlie
  75. ## Discussion Topics
  76. - Project timeline review
  77. - Resource allocation
  78. - Technical debt prioritization
  79. ## Action Items
  80. 1. Alice to update documentation
  81. 2. Bob to fix authentication bug
  82. 3. Charlie to review pull requests
  83. `
  84. );
  85. await writeFile(
  86. join(fixturesDir, "notes", "ideas.md"),
  87. `# Product Ideas
  88. ## Feature Requests
  89. - Dark mode support
  90. - Keyboard shortcuts
  91. - Export to PDF
  92. ## Technical Improvements
  93. - Improve search performance
  94. - Add caching layer
  95. - Optimize database queries
  96. `
  97. );
  98. await writeFile(
  99. join(fixturesDir, "docs", "api.md"),
  100. `# API Documentation
  101. ## Endpoints
  102. ### GET /search
  103. Search for documents.
  104. Parameters:
  105. - q: Search query (required)
  106. - limit: Max results (default: 10)
  107. ### GET /document/:id
  108. Retrieve a specific document.
  109. ### POST /index
  110. Index new documents.
  111. `
  112. );
  113. });
  114. // Cleanup after all tests
  115. afterAll(async () => {
  116. if (testDir) {
  117. await rm(testDir, { recursive: true, force: true });
  118. }
  119. });
  120. describe("CLI Help", () => {
  121. test("shows help with --help flag", async () => {
  122. const { stdout, exitCode } = await runQmd(["--help"]);
  123. expect(exitCode).toBe(0);
  124. expect(stdout).toContain("Usage:");
  125. expect(stdout).toContain("qmd add");
  126. expect(stdout).toContain("qmd search");
  127. });
  128. test("shows help with no arguments", async () => {
  129. const { stdout, exitCode } = await runQmd([]);
  130. expect(exitCode).toBe(1);
  131. expect(stdout).toContain("Usage:");
  132. });
  133. });
  134. describe("CLI Add Command", () => {
  135. test("adds files from current directory", async () => {
  136. const { stdout, exitCode } = await runQmd(["add", "."]);
  137. expect(exitCode).toBe(0);
  138. expect(stdout).toContain("Collection:");
  139. expect(stdout).toContain("Indexed:");
  140. });
  141. test("adds files with custom glob pattern", async () => {
  142. const { stdout, exitCode } = await runQmd(["add", "notes/*.md"]);
  143. expect(exitCode).toBe(0);
  144. expect(stdout).toContain("Collection:");
  145. // Should find meeting.md and ideas.md in notes/
  146. expect(stdout).toContain("notes/*.md");
  147. });
  148. test("adds files with --drop flag recreates collection", async () => {
  149. // First add
  150. await runQmd(["add", "."]);
  151. // Then drop and re-add
  152. const { stdout, exitCode } = await runQmd(["add", "--drop", "."]);
  153. expect(exitCode).toBe(0);
  154. expect(stdout).toContain("Dropped collection:");
  155. });
  156. });
  157. describe("CLI Status Command", () => {
  158. beforeEach(async () => {
  159. // Ensure we have indexed files
  160. await runQmd(["add", "."]);
  161. });
  162. test("shows index status", async () => {
  163. const { stdout, exitCode } = await runQmd(["status"]);
  164. expect(exitCode).toBe(0);
  165. // Should show collection info
  166. expect(stdout).toContain("Collection");
  167. });
  168. });
  169. describe("CLI Search Command", () => {
  170. beforeEach(async () => {
  171. // Ensure we have indexed files
  172. await runQmd(["add", "."]);
  173. });
  174. test("searches for documents with BM25", async () => {
  175. const { stdout, exitCode } = await runQmd(["search", "meeting"]);
  176. expect(exitCode).toBe(0);
  177. // Should find meeting.md
  178. expect(stdout.toLowerCase()).toContain("meeting");
  179. });
  180. test("searches with limit option", async () => {
  181. const { stdout, exitCode } = await runQmd(["search", "-n", "1", "test"]);
  182. expect(exitCode).toBe(0);
  183. });
  184. test("searches with all results option", async () => {
  185. const { stdout, exitCode } = await runQmd(["search", "--all", "the"]);
  186. expect(exitCode).toBe(0);
  187. });
  188. test("returns no results message for non-matching query", async () => {
  189. const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123"]);
  190. expect(exitCode).toBe(0);
  191. expect(stdout).toContain("No results");
  192. });
  193. test("requires query argument", async () => {
  194. const { stdout, stderr, exitCode } = await runQmd(["search"]);
  195. expect(exitCode).toBe(1);
  196. // Error message goes to stderr
  197. expect(stderr).toContain("Usage:");
  198. });
  199. });
  200. describe("CLI Get Command", () => {
  201. beforeEach(async () => {
  202. // Ensure we have indexed files
  203. await runQmd(["add", "."]);
  204. });
  205. test("retrieves document content by path", async () => {
  206. const { stdout, exitCode } = await runQmd(["get", "README.md"]);
  207. expect(exitCode).toBe(0);
  208. expect(stdout).toContain("Test Project");
  209. });
  210. test("retrieves document from subdirectory", async () => {
  211. const { stdout, exitCode } = await runQmd(["get", "notes/meeting.md"]);
  212. expect(exitCode).toBe(0);
  213. expect(stdout).toContain("Team Meeting");
  214. });
  215. test("handles non-existent file", async () => {
  216. const { stdout, exitCode } = await runQmd(["get", "nonexistent.md"]);
  217. // Should indicate file not found
  218. expect(exitCode).toBe(1);
  219. });
  220. });
  221. describe("CLI Multi-Get Command", () => {
  222. beforeEach(async () => {
  223. // Ensure we have indexed files
  224. await runQmd(["add", "."]);
  225. });
  226. test("retrieves multiple documents by pattern", async () => {
  227. const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md"]);
  228. expect(exitCode).toBe(0);
  229. // Should contain content from both notes files
  230. expect(stdout).toContain("Meeting");
  231. expect(stdout).toContain("Ideas");
  232. });
  233. test("retrieves documents by comma-separated paths", async () => {
  234. const { stdout, exitCode } = await runQmd([
  235. "multi-get",
  236. "README.md,notes/meeting.md",
  237. ]);
  238. expect(exitCode).toBe(0);
  239. expect(stdout).toContain("Test Project");
  240. expect(stdout).toContain("Team Meeting");
  241. });
  242. });
  243. describe("CLI Update Command", () => {
  244. let localDbPath: string;
  245. beforeEach(async () => {
  246. // Use a fresh database for this test suite
  247. localDbPath = getFreshDbPath();
  248. // Ensure we have indexed files
  249. await runQmd(["add", "."], { dbPath: localDbPath });
  250. });
  251. test("updates all collections", async () => {
  252. const { stdout, exitCode } = await runQmd(["update"], { dbPath: localDbPath });
  253. expect(exitCode).toBe(0);
  254. expect(stdout).toContain("Updating");
  255. });
  256. });
  257. describe("CLI Add-Context Command", () => {
  258. beforeEach(async () => {
  259. // Ensure we have indexed files
  260. await runQmd(["add", "."]);
  261. });
  262. test("adds context to a path", async () => {
  263. const { stdout, exitCode } = await runQmd([
  264. "add-context",
  265. "notes",
  266. "Personal notes and meeting logs",
  267. ]);
  268. expect(exitCode).toBe(0);
  269. });
  270. test("requires path and text arguments", async () => {
  271. const { stderr, exitCode } = await runQmd(["add-context"]);
  272. expect(exitCode).toBe(1);
  273. // Error message goes to stderr
  274. expect(stderr).toContain("Usage:");
  275. });
  276. });
  277. describe("CLI Cleanup Command", () => {
  278. beforeEach(async () => {
  279. // Ensure we have indexed files
  280. await runQmd(["add", "."]);
  281. });
  282. test("cleans up orphaned entries", async () => {
  283. const { stdout, exitCode } = await runQmd(["cleanup"]);
  284. expect(exitCode).toBe(0);
  285. });
  286. });
  287. describe("CLI Error Handling", () => {
  288. test("handles unknown command", async () => {
  289. const { stderr, exitCode } = await runQmd(["unknowncommand"]);
  290. expect(exitCode).toBe(1);
  291. // Should indicate unknown command
  292. expect(stderr).toContain("Unknown command");
  293. });
  294. test("uses INDEX_PATH environment variable", async () => {
  295. // Verify the test DB path is being used by creating a separate index
  296. const customDbPath = join(testDir, "custom.sqlite");
  297. const { exitCode } = await runQmd(["add", "."], {
  298. env: { INDEX_PATH: customDbPath },
  299. });
  300. expect(exitCode).toBe(0);
  301. // The custom database should exist
  302. const file = Bun.file(customDbPath);
  303. expect(await file.exists()).toBe(true);
  304. });
  305. });
  306. describe("CLI Output Formats", () => {
  307. beforeEach(async () => {
  308. await runQmd(["add", "."]);
  309. });
  310. test("search with --json flag outputs JSON", async () => {
  311. const { stdout, exitCode } = await runQmd(["search", "--json", "test"]);
  312. expect(exitCode).toBe(0);
  313. // Should be valid JSON
  314. const parsed = JSON.parse(stdout);
  315. expect(Array.isArray(parsed)).toBe(true);
  316. });
  317. test("search with --files flag outputs file paths", async () => {
  318. const { stdout, exitCode } = await runQmd(["search", "--files", "meeting"]);
  319. expect(exitCode).toBe(0);
  320. expect(stdout).toContain(".md");
  321. });
  322. test("search output includes snippets by default", async () => {
  323. const { stdout, exitCode } = await runQmd(["search", "API"]);
  324. expect(exitCode).toBe(0);
  325. // If results found, should have snippet content
  326. if (!stdout.includes("No results")) {
  327. expect(stdout.toLowerCase()).toContain("api");
  328. }
  329. });
  330. });
  331. describe("CLI Search with Collection Filter", () => {
  332. let localDbPath: string;
  333. beforeEach(async () => {
  334. // Use a fresh database for this test suite
  335. localDbPath = getFreshDbPath();
  336. // Create multiple collections
  337. await runQmd(["add", "notes/*.md"], { dbPath: localDbPath });
  338. await runQmd(["add", "docs/*.md"], { dbPath: localDbPath });
  339. });
  340. test("filters search by collection name", async () => {
  341. const { stdout, exitCode } = await runQmd([
  342. "search",
  343. "-c",
  344. "notes",
  345. "meeting",
  346. ], { dbPath: localDbPath });
  347. expect(exitCode).toBe(0);
  348. // Should find results from notes collection
  349. expect(stdout.toLowerCase()).toContain("meeting");
  350. });
  351. });