/** * CLI Integration Tests * * Tests all qmd CLI commands using a temporary test database via INDEX_PATH. * These tests spawn actual qmd processes to verify end-to-end functionality. */ import { describe, test, expect, beforeAll, afterAll, beforeEach } from "vitest"; import { mkdtemp, rm, writeFile, mkdir } from "fs/promises"; import { existsSync, lstatSync, readFileSync, symlinkSync, writeFileSync, unlinkSync } from "fs"; import { tmpdir } from "os"; import { join, dirname } from "path"; import { fileURLToPath } from "url"; import { spawn } from "child_process"; import { setTimeout as sleep } from "timers/promises"; import { buildEditorUri, termLink } from "../src/cli/qmd.ts"; // Test fixtures directory and database path let testDir: string; let testDbPath: string; let testConfigDir: string; let fixturesDir: string; let testCounter = 0; // Unique counter for each test run // Get the directory where this test file lives const thisDir = dirname(fileURLToPath(import.meta.url)); const projectRoot = join(thisDir, ".."); const qmdScript = join(projectRoot, "src", "cli", "qmd.ts"); // Resolve tsx binary from project's node_modules (not cwd-dependent) const tsxBin = (() => { const candidate = join(projectRoot, "node_modules", ".bin", "tsx"); if (existsSync(candidate)) { return candidate; } return join(process.cwd(), "node_modules", ".bin", "tsx"); })(); // Helper to run qmd command with test database async function runQmd( args: string[], options: { cwd?: string; env?: Record; dbPath?: string; configDir?: string } = {} ): Promise<{ stdout: string; stderr: string; exitCode: number }> { const workingDir = options.cwd || fixturesDir; const dbPath = options.dbPath || testDbPath; const configDir = options.configDir || testConfigDir; const proc = spawn(tsxBin, [qmdScript, ...args], { cwd: workingDir, env: { ...process.env, INDEX_PATH: dbPath, QMD_CONFIG_DIR: configDir, // Use test config directory PWD: workingDir, // Must explicitly set PWD since getPwd() checks this ...options.env, }, stdio: ["ignore", "pipe", "pipe"], }); const stdoutPromise = new Promise((resolve, reject) => { let data = ""; proc.stdout?.on("data", (chunk: Buffer) => { data += chunk.toString(); }); proc.once("error", reject); proc.stdout?.once("end", () => resolve(data)); }); const stderrPromise = new Promise((resolve, reject) => { let data = ""; proc.stderr?.on("data", (chunk: Buffer) => { data += chunk.toString(); }); proc.once("error", reject); proc.stderr?.once("end", () => resolve(data)); }); const exitCode = await new Promise((resolve, reject) => { proc.once("error", reject); proc.on("close", (code) => resolve(code ?? 1)); }); const stdout = await stdoutPromise; const stderr = await stderrPromise; return { stdout, stderr, exitCode }; } // Get a fresh database path for isolated tests function getFreshDbPath(): string { testCounter++; return join(testDir, `test-${testCounter}.sqlite`); } // Create an isolated test environment (db + config dir) async function createIsolatedTestEnv(prefix: string): Promise<{ dbPath: string; configDir: string }> { testCounter++; const dbPath = join(testDir, `${prefix}-${testCounter}.sqlite`); const configDir = join(testDir, `${prefix}-config-${testCounter}`); await mkdir(configDir, { recursive: true }); await writeFile(join(configDir, "index.yml"), "collections: {}\n"); return { dbPath, configDir }; } // Setup test fixtures beforeAll(async () => { // Create temp directory structure testDir = await mkdtemp(join(tmpdir(), "qmd-test-")); testDbPath = join(testDir, "test.sqlite"); testConfigDir = join(testDir, "config"); fixturesDir = join(testDir, "fixtures"); await mkdir(testConfigDir, { recursive: true }); await mkdir(fixturesDir, { recursive: true }); await mkdir(join(fixturesDir, "notes"), { recursive: true }); await mkdir(join(fixturesDir, "docs"), { recursive: true }); // Create empty YAML config for tests await writeFile( join(testConfigDir, "index.yml"), "collections: {}\n" ); // Create test markdown files await writeFile( join(fixturesDir, "README.md"), `# Test Project This is a test project for QMD CLI testing. ## Features - Full-text search with BM25 - Vector similarity search - Hybrid search with reranking ` ); await writeFile( join(fixturesDir, "notes", "meeting.md"), `# Team Meeting Notes Date: 2024-01-15 ## Attendees - Alice - Bob - Charlie ## Discussion Topics - Project timeline review - Resource allocation - Technical debt prioritization ## Action Items 1. Alice to update documentation 2. Bob to fix authentication bug 3. Charlie to review pull requests ` ); await writeFile( join(fixturesDir, "notes", "ideas.md"), `# Product Ideas ## Feature Requests - Dark mode support - Keyboard shortcuts - Export to PDF ## Technical Improvements - Improve search performance - Add caching layer - Optimize database queries ` ); await writeFile( join(fixturesDir, "docs", "api.md"), `# API Documentation ## Endpoints ### GET /search Search for documents. Parameters: - q: Search query (required) - limit: Max results (default: 10) ### GET /document/:id Retrieve a specific document. ### POST /index Index new documents. ` ); // Create test files for path normalization tests await writeFile( join(fixturesDir, "test1.md"), `# Test Document 1 This is the first test document. It has multiple lines for testing line numbers. Line 6 is here. Line 7 is here. ` ); await writeFile( join(fixturesDir, "test2.md"), `# Test Document 2 This is the second test document. ` ); }); // Cleanup after all tests afterAll(async () => { if (testDir) { await rm(testDir, { recursive: true, force: true }); } }); // Reset YAML config before each test to ensure isolation beforeEach(async () => { // Reset to empty collections config await writeFile( join(testConfigDir, "index.yml"), "collections: {}\n" ); }); describe("CLI Help", () => { test("shows help with --help flag", async () => { const { stdout, exitCode } = await runQmd(["--help"]); expect(exitCode).toBe(0); expect(stdout).toContain("Usage:"); expect(stdout).toContain("qmd collection add"); expect(stdout).toContain("qmd search"); expect(stdout).toContain("qmd skill show/install"); }); test("shows help with no arguments", async () => { const { stdout, exitCode } = await runQmd([]); expect(exitCode).toBe(1); expect(stdout).toContain("Usage:"); }); }); describe("CLI Embed", () => { test("rejects invalid --max-docs-per-batch", async () => { const { stderr, exitCode } = await runQmd(["embed", "--max-docs-per-batch", "0"]); expect(exitCode).toBe(1); expect(stderr).toContain("maxDocsPerBatch"); }); test("rejects invalid --max-batch-mb", async () => { const { stderr, exitCode } = await runQmd(["embed", "--max-batch-mb", "0"]); expect(exitCode).toBe(1); expect(stderr).toContain("maxBatchBytes"); }); }); describe("CLI Skill Commands", () => { test("shows embedded skill with --skill alias", async () => { const { stdout, exitCode } = await runQmd(["--skill"]); expect(exitCode).toBe(0); expect(stdout).toContain("QMD Skill (embedded)"); expect(stdout).toContain("name: qmd"); expect(stdout).toContain("allowed-tools: Bash(qmd:*), mcp__qmd__*"); }); test("shows skill help with -h", async () => { const { stdout, exitCode } = await runQmd(["skill", "-h"]); expect(exitCode).toBe(0); expect(stdout).toContain("Usage: qmd skill [options]"); expect(stdout).toContain("install"); expect(stdout).toContain("--global"); }); test("installs the skill into the current project", async () => { const projectDir = join(testDir, "skill-project"); await mkdir(projectDir, { recursive: true }); const { stdout, exitCode } = await runQmd(["skill", "install"], { cwd: projectDir }); expect(exitCode).toBe(0); const skillDir = join(projectDir, ".agents", "skills", "qmd"); expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("name: qmd"); expect(readFileSync(join(skillDir, "references", "mcp-setup.md"), "utf-8")).toContain("Claude Code"); expect(existsSync(join(projectDir, ".claude", "skills", "qmd"))).toBe(false); expect(stdout).toContain(`✓ Installed QMD skill to ${skillDir}`); expect(stdout).toContain("Tip: create a Claude symlink manually"); }); test("installs globally and creates the Claude symlink with --yes", async () => { const fakeHome = join(testDir, "skill-home"); await mkdir(fakeHome, { recursive: true }); const { stdout, exitCode } = await runQmd(["skill", "install", "--global", "--yes"], { env: { HOME: fakeHome }, }); expect(exitCode).toBe(0); const skillDir = join(fakeHome, ".agents", "skills", "qmd"); const claudeLink = join(fakeHome, ".claude", "skills", "qmd"); expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("name: qmd"); expect(lstatSync(claudeLink).isSymbolicLink()).toBe(true); expect(readFileSync(join(claudeLink, "SKILL.md"), "utf-8")).toContain("name: qmd"); expect(stdout).toContain(`✓ Installed QMD skill to ${skillDir}`); expect(stdout).toContain(`✓ Linked Claude skill at ${claudeLink}`); }); test("skips Claude qmd symlink when .claude/skills already points to .agents/skills", async () => { const fakeHome = join(testDir, "skill-home-shared"); await mkdir(join(fakeHome, ".agents"), { recursive: true }); await mkdir(join(fakeHome, ".claude"), { recursive: true }); symlinkSync(join(fakeHome, ".agents", "skills"), join(fakeHome, ".claude", "skills"), "dir"); const { stdout, exitCode } = await runQmd(["skill", "install", "--global", "--yes"], { env: { HOME: fakeHome }, }); expect(exitCode).toBe(0); const skillDir = join(fakeHome, ".agents", "skills", "qmd"); expect(lstatSync(skillDir).isSymbolicLink()).toBe(false); expect(readFileSync(join(skillDir, "SKILL.md"), "utf-8")).toContain("name: qmd"); expect(stdout).toContain(`✓ Claude already sees the skill via ${join(fakeHome, ".claude", "skills")}`); }); test("refuses to overwrite an existing install without --force", async () => { const projectDir = join(testDir, "skill-project-force"); await mkdir(projectDir, { recursive: true }); const first = await runQmd(["skill", "install"], { cwd: projectDir }); expect(first.exitCode).toBe(0); const second = await runQmd(["skill", "install"], { cwd: projectDir }); expect(second.exitCode).toBe(1); expect(second.stderr).toContain("Skill already exists"); expect(second.stderr).toContain("--force"); }); }); describe("CLI Add Command", () => { test("adds files from current directory", async () => { const { stdout, exitCode } = await runQmd(["collection", "add", "."]); expect(exitCode).toBe(0); expect(stdout).toContain("Collection:"); expect(stdout).toContain("Indexed:"); }); test("adds files with custom glob pattern", async () => { const { stdout, stderr, exitCode } = await runQmd(["collection", "add", ".", "--mask", "notes/*.md"]); if (exitCode !== 0) { console.error("Command failed:", stderr); } expect(exitCode).toBe(0); expect(stdout).toContain("Collection:"); // Should find meeting.md and ideas.md in notes/ expect(stdout).toContain("notes/*.md"); }); test("can recreate collection with remove and add", async () => { // First add await runQmd(["collection", "add", "."]); // Remove it await runQmd(["collection", "remove", "fixtures"]); // Re-add const { stdout, exitCode } = await runQmd(["collection", "add", "."]); expect(exitCode).toBe(0); expect(stdout).toContain("Collection 'fixtures' created successfully"); }); }); describe("CLI Status Command", () => { beforeEach(async () => { // Ensure we have indexed files await runQmd(["collection", "add", "."]); }); test("shows index status", async () => { const { stdout, exitCode } = await runQmd(["status"]); expect(exitCode).toBe(0); // Should show collection info expect(stdout).toContain("Collection"); }); test("skips device probing by default", async () => { const { stdout, exitCode } = await runQmd(["status"]); expect(exitCode).toBe(0); expect(stdout).not.toContain("Device"); }); }); describe("CLI Search Command", () => { beforeEach(async () => { // Ensure we have indexed files await runQmd(["collection", "add", "."]); }); test("searches for documents with BM25", async () => { const { stdout, exitCode } = await runQmd(["search", "meeting"]); expect(exitCode).toBe(0); // Should find meeting.md expect(stdout.toLowerCase()).toContain("meeting"); }); test("searches with limit option", async () => { const { stdout, exitCode } = await runQmd(["search", "-n", "1", "test"]); expect(exitCode).toBe(0); }); test("searches with all results option", async () => { const { stdout, exitCode } = await runQmd(["search", "--all", "the"]); expect(exitCode).toBe(0); }); test("returns no results message for non-matching query", async () => { const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123"]); expect(exitCode).toBe(0); expect(stdout).toContain("No results"); }); test("returns empty JSON array for non-matching query with --json", async () => { const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--json"]); expect(exitCode).toBe(0); expect(JSON.parse(stdout)).toEqual([]); }); test("returns CSV header only for non-matching query with --csv", async () => { const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--csv"]); expect(exitCode).toBe(0); expect(stdout.trim()).toBe("docid,score,file,title,context,line,snippet"); }); test("returns empty XML container for non-matching query with --xml", async () => { const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--xml"]); expect(exitCode).toBe(0); expect(stdout.trim()).toBe(""); }); test("returns empty output for non-matching query with --md", async () => { const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--md"]); expect(exitCode).toBe(0); expect(stdout.trim()).toBe(""); }); test("returns empty output for non-matching query with --files", async () => { const { stdout, exitCode } = await runQmd(["search", "xyznonexistent123", "--files"]); expect(exitCode).toBe(0); expect(stdout.trim()).toBe(""); }); test("returns min-score threshold message for default CLI output", async () => { const { stdout, exitCode } = await runQmd(["search", "test", "--min-score", "2"]); expect(exitCode).toBe(0); expect(stdout).toContain("No results found above minimum score threshold."); }); test("returns format-safe empty output when --min-score filters all results", async () => { const json = await runQmd(["search", "test", "--json", "--min-score", "2"]); expect(json.exitCode).toBe(0); expect(JSON.parse(json.stdout)).toEqual([]); const csv = await runQmd(["search", "test", "--csv", "--min-score", "2"]); expect(csv.exitCode).toBe(0); expect(csv.stdout.trim()).toBe("docid,score,file,title,context,line,snippet"); const xml = await runQmd(["search", "test", "--xml", "--min-score", "2"]); expect(xml.exitCode).toBe(0); expect(xml.stdout.trim()).toBe(""); const md = await runQmd(["search", "test", "--md", "--min-score", "2"]); expect(md.exitCode).toBe(0); expect(md.stdout.trim()).toBe(""); const files = await runQmd(["search", "test", "--files", "--min-score", "2"]); expect(files.exitCode).toBe(0); expect(files.stdout.trim()).toBe(""); }); test("requires query argument", async () => { const { stdout, stderr, exitCode } = await runQmd(["search"]); expect(exitCode).toBe(1); // Error message goes to stderr expect(stderr).toContain("Usage:"); }); }); describe("CLI Get Command", () => { beforeEach(async () => { // Ensure we have indexed files await runQmd(["collection", "add", "."]); }); test("retrieves document content by path", async () => { const { stdout, exitCode } = await runQmd(["get", "README.md"]); expect(exitCode).toBe(0); expect(stdout).toContain("Test Project"); }); test("retrieves document from subdirectory", async () => { const { stdout, exitCode } = await runQmd(["get", "notes/meeting.md"]); expect(exitCode).toBe(0); expect(stdout).toContain("Team Meeting"); }); test("handles non-existent file", async () => { const { stdout, exitCode } = await runQmd(["get", "nonexistent.md"]); // Should indicate file not found expect(exitCode).toBe(1); }); }); describe("CLI Multi-Get Command", () => { let localDbPath: string; beforeEach(async () => { // Use fresh database for each test localDbPath = getFreshDbPath(); // Ensure we have indexed files const addResult = await runQmd(["collection", "add", ".", "--name", "fixtures"], { dbPath: localDbPath }); if (addResult.exitCode !== 0) { throw new Error(`Failed to add collection: ${addResult.stderr}`); } }); test("retrieves multiple documents by pattern", async () => { // Test glob pattern matching const { stdout, stderr, exitCode } = await runQmd(["multi-get", "notes/*.md"], { dbPath: localDbPath }); expect(exitCode).toBe(0); // Should contain content from both notes files expect(stdout).toContain("Meeting"); expect(stdout).toContain("Ideas"); }); test("retrieves documents by comma-separated paths", async () => { const { stdout, exitCode } = await runQmd([ "multi-get", "README.md,notes/meeting.md", ], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("Test Project"); expect(stdout).toContain("Team Meeting"); }); }); describe("CLI Update Command", () => { let localDbPath: string; beforeEach(async () => { // Use a fresh database for this test suite localDbPath = getFreshDbPath(); // Ensure we have indexed files await runQmd(["collection", "add", "."], { dbPath: localDbPath }); }); test("updates all collections", async () => { const { stdout, exitCode } = await runQmd(["update"], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("Updating"); }); test("deactivates stale docs when collection has zero matching files", async () => { const { dbPath, configDir } = await createIsolatedTestEnv("update-empty"); const collectionDir = join(testDir, `update-empty-${Date.now()}`); await mkdir(collectionDir, { recursive: true }); const docPath = join(collectionDir, "only.md"); const token = `stale-proof-${Date.now()}`; await writeFile( docPath, `--- date: 2026-03-06 --- # Empty Collection Deactivation ${token} ` ); const add = await runQmd( ["collection", "add", collectionDir, "--name", "empty-check"], { dbPath, configDir } ); expect(add.exitCode).toBe(0); const before = await runQmd(["get", "qmd://empty-check/only.md"], { dbPath, configDir }); expect(before.exitCode).toBe(0); expect(before.stdout).toContain(token); unlinkSync(docPath); const update = await runQmd(["update"], { dbPath, configDir }); expect(update.exitCode).toBe(0); expect(update.stdout).toContain("0 new, 0 updated, 0 unchanged, 1 removed"); const after = await runQmd(["get", "qmd://empty-check/only.md"], { dbPath, configDir }); expect(after.exitCode).toBe(1); }); }); describe("CLI Add-Context Command", () => { let localDbPath: string; let localConfigDir: string; const collName = "fixtures"; beforeAll(async () => { const env = await createIsolatedTestEnv("context-cmd"); localDbPath = env.dbPath; localConfigDir = env.configDir; // Add collection with known name const { exitCode, stderr } = await runQmd( ["collection", "add", fixturesDir, "--name", collName], { dbPath: localDbPath, configDir: localConfigDir } ); if (exitCode !== 0) console.error("collection add failed:", stderr); expect(exitCode).toBe(0); }); test("adds context to a path", async () => { // Add context to the collection root using virtual path const { stdout, exitCode } = await runQmd([ "context", "add", `qmd://${collName}/`, "Personal notes and meeting logs", ], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); expect(stdout).toContain("✓ Added context"); }); test("requires path and text arguments", async () => { const { stderr, exitCode } = await runQmd(["context", "add"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(1); // Error message goes to stderr expect(stderr).toContain("Usage:"); }); }); describe("CLI Cleanup Command", () => { beforeEach(async () => { // Ensure we have indexed files await runQmd(["collection", "add", "."]); }); test("cleans up orphaned entries", async () => { const { stdout, exitCode } = await runQmd(["cleanup"]); expect(exitCode).toBe(0); }); }); describe("CLI Error Handling", () => { test("handles unknown command", async () => { const { stderr, exitCode } = await runQmd(["unknowncommand"]); expect(exitCode).toBe(1); // Should indicate unknown command expect(stderr).toContain("Unknown command"); }); test("uses INDEX_PATH environment variable", async () => { // Verify the test DB path is being used by creating a separate index const customDbPath = join(testDir, "custom.sqlite"); const { exitCode } = await runQmd(["collection", "add", "."], { env: { INDEX_PATH: customDbPath }, }); expect(exitCode).toBe(0); // The custom database should exist expect(existsSync(customDbPath)).toBe(true); }); }); describe("CLI Output Formats", () => { beforeEach(async () => { await runQmd(["collection", "add", "."]); }); test("search with --json flag outputs JSON", async () => { const { stdout, exitCode } = await runQmd(["search", "--json", "test"]); expect(exitCode).toBe(0); // Should be valid JSON const parsed = JSON.parse(stdout); expect(Array.isArray(parsed)).toBe(true); }); test("search with --files flag outputs file paths", async () => { const { stdout, exitCode } = await runQmd(["search", "--files", "meeting"]); expect(exitCode).toBe(0); expect(stdout).toContain(".md"); }); test("search output includes snippets by default", async () => { const { stdout, exitCode } = await runQmd(["search", "API"]); expect(exitCode).toBe(0); // If results found, should have snippet content if (!stdout.includes("No results")) { expect(stdout.toLowerCase()).toContain("api"); } }); }); describe("CLI Search with Collection Filter", () => { let localDbPath: string; beforeEach(async () => { // Use a fresh database for this test suite localDbPath = getFreshDbPath(); // Create multiple collections with explicit names await runQmd(["collection", "add", ".", "--name", "notes", "--mask", "notes/*.md"], { dbPath: localDbPath }); await runQmd(["collection", "add", ".", "--name", "docs", "--mask", "docs/*.md"], { dbPath: localDbPath }); }); test("filters search by collection name", async () => { const { stdout, stderr, exitCode } = await runQmd([ "search", "-c", "notes", "meeting", ], { dbPath: localDbPath }); if (exitCode !== 0) { console.log("Collection filter search failed:"); console.log("stdout:", stdout); console.log("stderr:", stderr); } expect(exitCode).toBe(0); }); }); describe("CLI Context Management", () => { let localDbPath: string; beforeEach(async () => { // Use a fresh database for this test suite localDbPath = getFreshDbPath(); // Index some files first await runQmd(["collection", "add", "."], { dbPath: localDbPath }); }); test("add global context with /", async () => { const { stdout, exitCode } = await runQmd([ "context", "add", "/", "Global system context", ], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("✓ Set global context"); expect(stdout).toContain("Global system context"); }); test("list contexts", async () => { // Add a global context first await runQmd([ "context", "add", "/", "Test context", ], { dbPath: localDbPath }); const { stdout, exitCode } = await runQmd([ "context", "list", ], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("Configured Contexts"); expect(stdout).toContain("Test context"); }); test("add context to virtual path", async () => { // Collection name should be "fixtures" (basename of the fixtures directory) const { stdout, exitCode } = await runQmd([ "context", "add", "qmd://fixtures/notes", "Context for notes subdirectory", ], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("✓ Added context for: qmd://fixtures/notes"); }); test("remove global context", async () => { // Add a global context first await runQmd([ "context", "add", "/", "Global context to remove", ], { dbPath: localDbPath }); const { stdout, exitCode } = await runQmd([ "context", "rm", "/", ], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("✓ Removed"); }); test("remove virtual path context", async () => { // Add a context first await runQmd([ "context", "add", "qmd://fixtures/notes", "Context to remove", ], { dbPath: localDbPath }); const { stdout, exitCode } = await runQmd([ "context", "rm", "qmd://fixtures/notes", ], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("✓ Removed context for: qmd://fixtures/notes"); }); test("fails to remove non-existent context", async () => { const { stdout, stderr, exitCode } = await runQmd([ "context", "rm", "qmd://nonexistent/path", ], { dbPath: localDbPath }); expect(exitCode).toBe(1); expect(stderr || stdout).toContain("not found"); }); }); describe("CLI ls Command", () => { let localDbPath: string; beforeEach(async () => { // Use a fresh database for this test suite localDbPath = getFreshDbPath(); // Index some files first await runQmd(["collection", "add", "."], { dbPath: localDbPath }); }); test("lists all collections", async () => { const { stdout, exitCode } = await runQmd(["ls"], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("Collections:"); expect(stdout).toContain("qmd://fixtures/"); }); test("lists files in a collection", async () => { const { stdout, exitCode } = await runQmd(["ls", "fixtures"], { dbPath: localDbPath }); expect(exitCode).toBe(0); // handelize preserves original case expect(stdout).toContain("qmd://fixtures/README.md"); expect(stdout).toContain("qmd://fixtures/notes/meeting.md"); }); test("lists files with path prefix", async () => { const { stdout, exitCode } = await runQmd(["ls", "fixtures/notes"], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("qmd://fixtures/notes/meeting.md"); expect(stdout).toContain("qmd://fixtures/notes/ideas.md"); // Should not include files outside the prefix (case preserved) expect(stdout).not.toContain("qmd://fixtures/README.md"); }); test("lists files with virtual path", async () => { const { stdout, exitCode } = await runQmd(["ls", "qmd://fixtures/docs"], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("qmd://fixtures/docs/api.md"); }); test("handles non-existent collection", async () => { const { stderr, exitCode } = await runQmd(["ls", "nonexistent"], { dbPath: localDbPath }); expect(exitCode).toBe(1); expect(stderr).toContain("Collection not found"); }); }); describe("CLI Collection Commands", () => { let localDbPath: string; beforeEach(async () => { // Use a fresh database for this test suite localDbPath = getFreshDbPath(); // Index some files first to create a collection await runQmd(["collection", "add", "."], { dbPath: localDbPath }); }); test("lists collections", async () => { const { stdout, exitCode } = await runQmd(["collection", "list"], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("Collections"); expect(stdout).toContain("fixtures"); expect(stdout).toContain("qmd://fixtures/"); expect(stdout).toContain("Pattern:"); expect(stdout).toContain("Files:"); }); test("removes a collection", async () => { // First verify the collection exists const { stdout: listBefore } = await runQmd(["collection", "list"], { dbPath: localDbPath }); expect(listBefore).toContain("fixtures"); // Remove it const { stdout, exitCode } = await runQmd(["collection", "remove", "fixtures"], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("✓ Removed collection 'fixtures'"); expect(stdout).toContain("Deleted"); // Verify it's gone const { stdout: listAfter } = await runQmd(["collection", "list"], { dbPath: localDbPath }); expect(listAfter).not.toContain("fixtures"); }); test("handles removing non-existent collection", async () => { const { stderr, exitCode } = await runQmd(["collection", "remove", "nonexistent"], { dbPath: localDbPath }); expect(exitCode).toBe(1); expect(stderr).toContain("Collection not found"); }); test("handles missing remove argument", async () => { const { stderr, exitCode } = await runQmd(["collection", "remove"], { dbPath: localDbPath }); expect(exitCode).toBe(1); expect(stderr).toContain("Usage:"); }); test("handles unknown subcommand", async () => { const { stderr, exitCode } = await runQmd(["collection", "invalid"], { dbPath: localDbPath }); expect(exitCode).toBe(1); expect(stderr).toContain("Unknown subcommand"); }); test("renames a collection", async () => { // First verify the collection exists const { stdout: listBefore } = await runQmd(["collection", "list"], { dbPath: localDbPath }); expect(listBefore).toContain("qmd://fixtures/"); // Rename it const { stdout, exitCode } = await runQmd(["collection", "rename", "fixtures", "my-fixtures"], { dbPath: localDbPath }); expect(exitCode).toBe(0); expect(stdout).toContain("✓ Renamed collection 'fixtures' to 'my-fixtures'"); expect(stdout).toContain("qmd://fixtures/"); expect(stdout).toContain("qmd://my-fixtures/"); // Verify the new name exists and old name is gone const { stdout: listAfter } = await runQmd(["collection", "list"], { dbPath: localDbPath }); expect(listAfter).toContain("qmd://my-fixtures/"); expect(listAfter).not.toContain("qmd://fixtures/"); // Old collection should not appear }); test("handles renaming non-existent collection", async () => { const { stderr, exitCode } = await runQmd(["collection", "rename", "nonexistent", "newname"], { dbPath: localDbPath }); expect(exitCode).toBe(1); expect(stderr).toContain("Collection not found"); }); test("handles renaming to existing collection name", async () => { // Create a second collection in a temp directory const tempDir = await mkdtemp(join(tmpdir(), "qmd-second-")); await writeFile(join(tempDir, "test.md"), "# Test"); const addResult = await runQmd(["collection", "add", tempDir, "--name", "second"], { dbPath: localDbPath }); if (addResult.exitCode !== 0) { console.error("Failed to add second collection:", addResult.stderr); } expect(addResult.exitCode).toBe(0); // Verify both collections exist const { stdout: listBoth } = await runQmd(["collection", "list"], { dbPath: localDbPath }); expect(listBoth).toContain("qmd://fixtures/"); expect(listBoth).toContain("qmd://second/"); // Try to rename fixtures to second (which already exists) const { stderr, exitCode } = await runQmd(["collection", "rename", "fixtures", "second"], { dbPath: localDbPath }); expect(exitCode).toBe(1); expect(stderr).toContain("Collection name already exists"); }); test("handles missing rename arguments", async () => { const { stderr: stderr1, exitCode: exitCode1 } = await runQmd(["collection", "rename"], { dbPath: localDbPath }); expect(exitCode1).toBe(1); expect(stderr1).toContain("Usage:"); const { stderr: stderr2, exitCode: exitCode2 } = await runQmd(["collection", "rename", "fixtures"], { dbPath: localDbPath }); expect(exitCode2).toBe(1); expect(stderr2).toContain("Usage:"); }); }); // ============================================================================= // Collection Ignore Patterns // ============================================================================= describe("collection ignore patterns", () => { let localDbPath: string; let localConfigDir: string; let ignoreTestDir: string; beforeAll(async () => { const env = await createIsolatedTestEnv("ignore-patterns"); localDbPath = env.dbPath; localConfigDir = env.configDir; // Create directory structure with subdirectories to ignore ignoreTestDir = join(testDir, "ignore-fixtures"); await mkdir(join(ignoreTestDir, "notes"), { recursive: true }); await mkdir(join(ignoreTestDir, "sessions"), { recursive: true }); await mkdir(join(ignoreTestDir, "sessions", "2026-03"), { recursive: true }); await mkdir(join(ignoreTestDir, "archive"), { recursive: true }); // Files that should be indexed await writeFile(join(ignoreTestDir, "readme.md"), "# Main readme\nThis should be indexed."); await writeFile(join(ignoreTestDir, "notes", "note1.md"), "# Note 1\nThis is a personal note."); // Files that should be ignored await writeFile(join(ignoreTestDir, "sessions", "session1.md"), "# Session 1\nThis session should be ignored."); await writeFile(join(ignoreTestDir, "sessions", "2026-03", "session2.md"), "# Session 2\nNested session should also be ignored."); await writeFile(join(ignoreTestDir, "archive", "old.md"), "# Old stuff\nThis archive file should be ignored."); }); test("ignore patterns exclude matching files from indexing", async () => { // Write YAML config with ignore patterns await writeFile( join(localConfigDir, "index.yml"), `collections: ignoretst: path: ${ignoreTestDir} pattern: "**/*.md" ignore: - "sessions/**" - "archive/**" ` ); const { stdout, exitCode } = await runQmd(["update"], { cwd: ignoreTestDir, dbPath: localDbPath, configDir: localConfigDir, }); expect(exitCode).toBe(0); // Should index 2 files (readme.md + notes/note1.md), not 5 expect(stdout).toContain("2 new"); }); test("ignored files are not searchable", async () => { const { stdout, exitCode } = await runQmd(["search", "session", "-n", "10"], { cwd: ignoreTestDir, dbPath: localDbPath, configDir: localConfigDir, }); // Should find no results since sessions/ was ignored if (exitCode === 0) { expect(stdout).not.toContain("session1"); expect(stdout).not.toContain("session2"); } }); test("non-ignored files are searchable", async () => { const { stdout, exitCode } = await runQmd(["search", "personal note", "-n", "10"], { cwd: ignoreTestDir, dbPath: localDbPath, configDir: localConfigDir, }); expect(exitCode).toBe(0); expect(stdout).toContain("note1"); }); test("status shows ignore patterns", async () => { const { stdout, exitCode } = await runQmd(["collection", "list"], { cwd: ignoreTestDir, dbPath: localDbPath, configDir: localConfigDir, }); expect(exitCode).toBe(0); expect(stdout).toContain("Ignore:"); expect(stdout).toContain("sessions/**"); expect(stdout).toContain("archive/**"); }); test("collection without ignore indexes all files", async () => { // Create a second collection without ignore const env2 = await createIsolatedTestEnv("no-ignore"); await writeFile( join(env2.configDir, "index.yml"), `collections: allfiles: path: ${ignoreTestDir} pattern: "**/*.md" ` ); const { stdout, exitCode } = await runQmd(["update"], { cwd: ignoreTestDir, dbPath: env2.dbPath, configDir: env2.configDir, }); expect(exitCode).toBe(0); // Should index all 5 files expect(stdout).toContain("5 new"); }); }); // ============================================================================= // Output Format Tests - qmd:// URIs, context, and docid // ============================================================================= describe("search output formats", () => { let localDbPath: string; let localConfigDir: string; const collName = "fixtures"; beforeAll(async () => { const env = await createIsolatedTestEnv("output-format"); localDbPath = env.dbPath; localConfigDir = env.configDir; // Add collection const { exitCode, stderr } = await runQmd( ["collection", "add", fixturesDir, "--name", collName], { dbPath: localDbPath, configDir: localConfigDir } ); if (exitCode !== 0) console.error("collection add failed:", stderr); expect(exitCode).toBe(0); // Add context await runQmd(["context", "add", `qmd://${collName}/`, "Test fixtures for QMD"], { dbPath: localDbPath, configDir: localConfigDir }); }); test("search --json includes qmd:// path, docid, and context", async () => { const { stdout, exitCode } = await runQmd(["search", "test", "--json", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); const results = JSON.parse(stdout); expect(results.length).toBeGreaterThan(0); const result = results[0]; expect(result.file).toMatch(new RegExp(`^qmd://${collName}/`)); expect(result.docid).toMatch(/^#[a-f0-9]{6}$/); expect(result.context).toBe("Test fixtures for QMD"); // Ensure no full filesystem paths expect(result.file).not.toMatch(/^\/Users\//); expect(result.file).not.toMatch(/^\/home\//); }); test("custom-index search links include ?index= and can be passed back to qmd get", async () => { const env = await createIsolatedTestEnv("custom-index-links"); const customColl = "fixtures-alt"; const customIndex = "release-notes"; const customCacheDir = join(testDir, `cache-${Date.now()}-${Math.random().toString(16).slice(2)}`); await mkdir(customCacheDir, { recursive: true }); const sharedEnv = { INDEX_PATH: "", XDG_CACHE_HOME: customCacheDir, }; const addResult = await runQmd( ["--index", customIndex, "collection", "add", fixturesDir, "--name", customColl], { dbPath: env.dbPath, configDir: env.configDir, env: sharedEnv } ); expect(addResult.exitCode).toBe(0); const searchResult = await runQmd( ["--index", customIndex, "search", "test", "--json", "-n", "1"], { dbPath: env.dbPath, configDir: env.configDir, env: sharedEnv } ); expect(searchResult.exitCode).toBe(0); const results = JSON.parse(searchResult.stdout); const file = results[0]?.file; expect(file).toMatch(new RegExp(`^qmd://${customColl}/.+\\?index=${customIndex}$`)); const getResult = await runQmd( ["get", file, "-l", "2"], { dbPath: env.dbPath, configDir: env.configDir, env: sharedEnv } ); expect(getResult.exitCode).toBe(0); expect(getResult.stdout.trim().length).toBeGreaterThan(0); }); test("search --files includes qmd:// path, docid, and context", async () => { const { stdout, exitCode } = await runQmd(["search", "test", "--files", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); // Format: #docid,score,qmd://collection/path,"context" expect(stdout).toMatch(new RegExp(`^#[a-f0-9]{6},[\\d.]+,qmd://${collName}/`, "m")); expect(stdout).toContain("Test fixtures for QMD"); // Ensure no full filesystem paths expect(stdout).not.toMatch(/\/Users\//); expect(stdout).not.toMatch(/\/home\//); }); test("search --csv includes qmd:// path, docid, and context", async () => { const { stdout, exitCode } = await runQmd(["search", "test", "--csv", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); // Header should include context expect(stdout).toMatch(/^docid,score,file,title,context,line,snippet$/m); // Data rows should have qmd:// paths and context expect(stdout).toMatch(new RegExp(`#[a-f0-9]{6},[\\d.]+,qmd://${collName}/`)); expect(stdout).toContain("Test fixtures for QMD"); // Ensure no full filesystem paths expect(stdout).not.toMatch(/\/Users\//); expect(stdout).not.toMatch(/\/home\//); }); test("search --md includes docid and context", async () => { const { stdout, exitCode } = await runQmd(["search", "test", "--md", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); expect(stdout).toMatch(/\*\*docid:\*\* `#[a-f0-9]{6}`/); expect(stdout).toContain("**context:** Test fixtures for QMD"); }); test("search --xml includes qmd:// path, docid, and context", async () => { const { stdout, exitCode } = await runQmd(["search", "test", "--xml", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); expect(stdout).toMatch(new RegExp(` { const { stdout, exitCode } = await runQmd(["search", "test", "-n", "1"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); // runQmd uses piped stdio, so stdout is non-TTY and should not contain OSC 8 links. expect(stdout).toMatch(new RegExp(`^qmd://${collName}/.*#[a-f0-9]{6}`, "m")); expect(stdout).toContain("Context: Test fixtures for QMD"); expect(stdout).not.toContain("\x1b]8;;"); // Ensure no full filesystem paths expect(stdout).not.toMatch(/\/Users\//); expect(stdout).not.toMatch(/\/home\//); }); }); describe("editor URI templates", () => { test("buildEditorUri expands path, line, and col placeholders", () => { const uri = buildEditorUri( "vscode://file/{path}:{line}:{col}", "/tmp/my notes/readme.md", 42, 1, ); expect(uri).toBe("vscode://file//tmp/my%20notes/readme.md:42:1"); }); test("buildEditorUri supports {column} alias", () => { const uri = buildEditorUri( "cursor://file/{path}:{line}:{column}", "/tmp/docs/api.md", 7, 3, ); expect(uri).toBe("cursor://file//tmp/docs/api.md:7:3"); }); test("termLink returns plain text when stdout is not a TTY", () => { const linked = termLink("docs/api.md:12", "vscode://file//tmp/docs/api.md:12:1", false); expect(linked).toBe("docs/api.md:12"); }); test("termLink emits OSC 8 hyperlinks when stdout is a TTY", () => { const linked = termLink("docs/api.md:12", "vscode://file//tmp/docs/api.md:12:1", true); expect(linked).toBe("\x1b]8;;vscode://file//tmp/docs/api.md:12:1\x07docs/api.md:12\x1b]8;;\x07"); }); }); // ============================================================================= // Get Command Path Normalization Tests // ============================================================================= describe("get command path normalization", () => { let localDbPath: string; let localConfigDir: string; const collName = "fixtures"; beforeAll(async () => { const env = await createIsolatedTestEnv("get-paths"); localDbPath = env.dbPath; localConfigDir = env.configDir; const { exitCode, stderr } = await runQmd( ["collection", "add", fixturesDir, "--name", collName], { dbPath: localDbPath, configDir: localConfigDir } ); if (exitCode !== 0) console.error("collection add failed:", stderr); expect(exitCode).toBe(0); }); test("get with qmd://collection/path format", async () => { const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); expect(stdout).toContain("Test Document 1"); }); test("get with collection/path format (no scheme)", async () => { const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); expect(stdout).toContain("Test Document 1"); }); test("get with //collection/path format", async () => { const { stdout, exitCode } = await runQmd(["get", `//${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); expect(stdout).toContain("Test Document 1"); }); test("get with qmd:////collection/path format (extra slashes)", async () => { const { stdout, exitCode } = await runQmd(["get", `qmd:////${collName}/test1.md`, "-l", "3"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); expect(stdout).toContain("Test Document 1"); }); test("get with path:line format", async () => { const { stdout, exitCode } = await runQmd(["get", `${collName}/test1.md:3`, "-l", "2"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); // Should start from line 3, not line 1 expect(stdout).not.toMatch(/^# Test Document 1$/m); }); test("get with qmd://path:line format", async () => { const { stdout, exitCode } = await runQmd(["get", `qmd://${collName}/test1.md:3`, "-l", "2"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); // Should start from line 3, not line 1 expect(stdout).not.toMatch(/^# Test Document 1$/m); }); }); // ============================================================================= // Status and Collection List - No Full Paths // ============================================================================= describe("status and collection list hide filesystem paths", () => { let localDbPath: string; let localConfigDir: string; const collName = "fixtures"; beforeAll(async () => { const env = await createIsolatedTestEnv("status-paths"); localDbPath = env.dbPath; localConfigDir = env.configDir; const { exitCode, stderr } = await runQmd( ["collection", "add", fixturesDir, "--name", collName], { dbPath: localDbPath, configDir: localConfigDir } ); if (exitCode !== 0) console.error("collection add failed:", stderr); expect(exitCode).toBe(0); }); test("status does not show full filesystem paths", async () => { const { stdout, exitCode } = await runQmd(["status"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); // Should show qmd:// URIs expect(stdout).toContain(`qmd://${collName}/`); // Should NOT show full filesystem paths (except for the index location which is ok) const lines = stdout.split('\n').filter(l => !l.includes('Index:')); const pathLines = lines.filter(l => l.includes('/Users/') || l.includes('/home/') || l.includes('/tmp/')); expect(pathLines.length).toBe(0); }); test("collection list does not show full filesystem paths", async () => { const { stdout, exitCode } = await runQmd(["collection", "list"], { dbPath: localDbPath, configDir: localConfigDir }); expect(exitCode).toBe(0); // Should show qmd:// URIs expect(stdout).toContain(`qmd://${collName}/`); // Should NOT show Path: lines with filesystem paths expect(stdout).not.toMatch(/Path:\s+\//); }); }); // ============================================================================= // MCP HTTP Daemon Lifecycle // ============================================================================= describe("mcp http daemon", () => { let daemonTestDir: string; let daemonCacheDir: string; // XDG_CACHE_HOME value (the qmd/ subdir is created automatically) let daemonDbPath: string; let daemonConfigDir: string; // Track spawned PIDs for cleanup const spawnedPids: number[] = []; /** Get path to PID file inside the test cache dir */ function pidPath(): string { return join(daemonCacheDir, "qmd", "mcp.pid"); } /** Run qmd with test-isolated env (cache, db, config) */ async function runDaemonQmd( args: string[], ): Promise<{ stdout: string; stderr: string; exitCode: number }> { return runQmd(args, { dbPath: daemonDbPath, configDir: daemonConfigDir, env: { XDG_CACHE_HOME: daemonCacheDir }, }); } /** Spawn a foreground HTTP server (non-blocking) and return the process */ function spawnHttpServer(port: number): import("child_process").ChildProcess { const proc = spawn(tsxBin, [qmdScript, "mcp", "--http", "--port", String(port)], { cwd: fixturesDir, env: { ...process.env, INDEX_PATH: daemonDbPath, QMD_CONFIG_DIR: daemonConfigDir, }, stdio: ["ignore", "pipe", "pipe"], }); if (proc.pid) spawnedPids.push(proc.pid); return proc; } /** Wait for HTTP server to become ready */ async function waitForServer(port: number, timeoutMs = 5000): Promise { const deadline = Date.now() + timeoutMs; while (Date.now() < deadline) { try { const res = await fetch(`http://localhost:${port}/health`); if (res.ok) return true; } catch { /* not ready yet */ } await sleep(200); } return false; } /** Pick a random high port unlikely to conflict */ function randomPort(): number { return 10000 + Math.floor(Math.random() * 50000); } beforeAll(async () => { daemonTestDir = await mkdtemp(join(tmpdir(), "qmd-daemon-test-")); daemonCacheDir = join(daemonTestDir, "cache"); daemonDbPath = join(daemonTestDir, "test.sqlite"); daemonConfigDir = join(daemonTestDir, "config"); await mkdir(join(daemonCacheDir, "qmd"), { recursive: true }); await mkdir(daemonConfigDir, { recursive: true }); await writeFile(join(daemonConfigDir, "index.yml"), "collections: {}\n"); }); afterAll(async () => { // Kill any leftover spawned processes for (const pid of spawnedPids) { try { process.kill(pid, "SIGTERM"); } catch { /* already dead */ } } // Also clean up via PID file if present try { const pf = pidPath(); if (existsSync(pf)) { const pid = parseInt(readFileSync(pf, "utf-8").trim()); try { process.kill(pid, "SIGTERM"); } catch {} unlinkSync(pf); } } catch {} await rm(daemonTestDir, { recursive: true, force: true }); }); // ------------------------------------------------------------------------- // Foreground HTTP // ------------------------------------------------------------------------- test("foreground HTTP server starts and responds to health check", async () => { const port = randomPort(); const proc = spawnHttpServer(port); try { const ready = await waitForServer(port); expect(ready).toBe(true); const res = await fetch(`http://localhost:${port}/health`); expect(res.status).toBe(200); const body = await res.json(); expect(body.status).toBe("ok"); } finally { proc.kill("SIGTERM"); await new Promise(r => proc.on("close", r)); } }); // ------------------------------------------------------------------------- // Daemon lifecycle // ------------------------------------------------------------------------- test("--daemon writes PID file and starts server", async () => { const port = randomPort(); const { stdout, exitCode } = await runDaemonQmd([ "mcp", "--http", "--daemon", "--port", String(port), ]); expect(exitCode).toBe(0); expect(stdout).toContain(`http://localhost:${port}/mcp`); // PID file should exist expect(existsSync(pidPath())).toBe(true); const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); spawnedPids.push(pid); // Server should be reachable const ready = await waitForServer(port); expect(ready).toBe(true); // Clean up process.kill(pid, "SIGTERM"); await sleep(500); try { unlinkSync(pidPath()); } catch {} }); test("stop kills daemon and removes PID file", async () => { const port = randomPort(); // Start daemon const { exitCode: startCode } = await runDaemonQmd([ "mcp", "--http", "--daemon", "--port", String(port), ]); expect(startCode).toBe(0); const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); spawnedPids.push(pid); await waitForServer(port); // Stop it const { stdout: stopOut, exitCode: stopCode } = await runDaemonQmd(["mcp", "stop"]); expect(stopCode).toBe(0); expect(stopOut).toContain("Stopped"); // PID file should be gone expect(existsSync(pidPath())).toBe(false); // Process should be dead await sleep(500); expect(() => process.kill(pid, 0)).toThrow(); }); test("stop handles dead PID gracefully (cleans stale file)", async () => { // Write a PID file pointing to a dead process writeFileSync(pidPath(), "999999999"); const { stdout, exitCode } = await runDaemonQmd(["mcp", "stop"]); expect(exitCode).toBe(0); expect(stdout).toContain("stale"); // PID file should be cleaned up expect(existsSync(pidPath())).toBe(false); }); test("--daemon rejects if already running", async () => { const port = randomPort(); // Start first daemon const { exitCode: firstCode } = await runDaemonQmd([ "mcp", "--http", "--daemon", "--port", String(port), ]); expect(firstCode).toBe(0); const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); spawnedPids.push(pid); await waitForServer(port); // Try to start second daemon — should fail const { stderr, exitCode } = await runDaemonQmd([ "mcp", "--http", "--daemon", "--port", String(port + 1), ]); expect(exitCode).toBe(1); expect(stderr).toContain("Already running"); // Clean up first daemon process.kill(pid, "SIGTERM"); await sleep(500); try { unlinkSync(pidPath()); } catch {} }); test("--daemon cleans stale PID file and starts fresh", async () => { // Write a stale PID file writeFileSync(pidPath(), "999999999"); const port = randomPort(); const { exitCode, stdout } = await runDaemonQmd([ "mcp", "--http", "--daemon", "--port", String(port), ]); expect(exitCode).toBe(0); expect(stdout).toContain(`http://localhost:${port}/mcp`); const pid = parseInt(readFileSync(pidPath(), "utf-8").trim()); spawnedPids.push(pid); expect(pid).not.toBe(999999999); // Clean up const ready = await waitForServer(port); expect(ready).toBe(true); process.kill(pid, "SIGTERM"); await sleep(500); try { unlinkSync(pidPath()); } catch {} }); });