Fix all remaining unit test failures and add hierarchical context test

**Test Fixes:**
- Fixed tilde expansion test to create collection with home directory path
- Fixed test expectations for displayPath vs filepath separation
- Fixed MCP test config to use isolated YAML config directory
- Fixed MCP mock to return correct logprobs format
- Fixed qmd_query test to use r.filepath instead of r.file
- Fixed CLI multi-get test to use fresh database for isolation
- Fixed multiGet function to parse filepath (virtual) instead of displayPath

**Bug Fixes:**
- Fixed multiGet to use virtual paths for parsing collection/path info
- Fixed findDocuments selectCols to separate virtual_path and display_path
- Fixed context loading in findDocuments to use virtual paths

**New Test:**
- Added hierarchical context test verifying global + collection + path contexts
  are all included and joined with double newlines

**Results:** 261 passing / 0 failing (100% pass rate)

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Tobi Lutke 2025-12-14 17:51:16 -04:00
parent 3d3aed0d4b
commit 49b2cef85c
No known key found for this signature in database
5 changed files with 103 additions and 24 deletions

View File

@ -289,13 +289,21 @@ describe("CLI Get Command", () => {
});
describe("CLI Multi-Get Command", () => {
let localDbPath: string;
beforeEach(async () => {
// Use fresh database for each test
localDbPath = getFreshDbPath();
// Ensure we have indexed files
await runQmd(["collection", "add", "."]);
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 () => {
const { stdout, exitCode } = await runQmd(["multi-get", "notes/*.md"]);
// 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");
@ -306,7 +314,7 @@ describe("CLI Multi-Get Command", () => {
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");

View File

@ -11,6 +11,11 @@ import * as sqliteVec from "sqlite-vec";
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
import { z } from "zod";
import { setDefaultOllama, Ollama } from "./llm";
import { mkdtemp, writeFile, readdir, unlink, rmdir } from "node:fs/promises";
import { join } from "node:path";
import { tmpdir } from "node:os";
import YAML from "yaml";
import type { CollectionConfig } from "./collections";
// =============================================================================
// Mock Ollama
@ -28,12 +33,13 @@ const mockOllamaResponses: Record<string, (body: unknown) => Response> = {
});
},
"/api/generate": (body: unknown) => {
const reqBody = body as { prompt?: string };
const reqBody = body as { prompt?: string; logprobs?: boolean };
if (reqBody.prompt?.includes("Judge") || reqBody.prompt?.includes("Document")) {
// Return format matching Ollama API
return new Response(JSON.stringify({
response: "yes",
done: true,
logprobs: { tokens: ["yes"], token_logprobs: [-0.1] },
logprobs: reqBody.logprobs ? { tokens: ["yes"], token_logprobs: [-0.1] } : undefined
}), { status: 200, headers: { "Content-Type": "application/json" } });
} else {
return new Response(JSON.stringify({
@ -72,6 +78,7 @@ function mockFetch(input: RequestInfo | URL, init?: RequestInit): Promise<Respon
let testDb: Database;
let testDbPath: string;
let testConfigDir: string;
function initTestDatabase(db: Database): void {
sqliteVec.load(db);
@ -243,23 +250,53 @@ import type { RankedResult } from "./store";
// =============================================================================
describe("MCP Server", () => {
beforeAll(() => {
beforeAll(async () => {
globalThis.fetch = mockFetch as typeof fetch;
setDefaultOllama(new Ollama({ baseUrl: OLLAMA_URL }));
// Set up test config directory
const configPrefix = join(tmpdir(), `qmd-mcp-config-${Date.now()}-${Math.random().toString(36).slice(2)}`);
testConfigDir = await mkdtemp(configPrefix);
process.env.QMD_CONFIG_DIR = testConfigDir;
// Create YAML config with test collection
const testConfig: CollectionConfig = {
collections: {
docs: {
path: "/test/docs",
pattern: "**/*.md",
context: {
"/meetings": "Meeting notes and transcripts"
}
}
}
};
await writeFile(join(testConfigDir, "index.yml"), YAML.stringify(testConfig));
testDbPath = `/tmp/qmd-mcp-test-${Date.now()}.sqlite`;
testDb = new Database(testDbPath);
initTestDatabase(testDb);
seedTestData(testDb);
});
afterAll(() => {
afterAll(async () => {
globalThis.fetch = originalFetch;
setDefaultOllama(null);
testDb.close();
try {
require("fs").unlinkSync(testDbPath);
} catch {}
// Clean up test config directory
try {
const files = await readdir(testConfigDir);
for (const file of files) {
await unlink(join(testConfigDir, file));
}
await rmdir(testConfigDir);
} catch {}
delete process.env.QMD_CONFIG_DIR;
});
// ===========================================================================
@ -377,10 +414,10 @@ describe("MCP Server", () => {
const ftsResults = searchFTS(testDb, q, 20);
if (ftsResults.length > 0) {
rankedLists.push(ftsResults.map(r => ({
file: r.file,
file: r.filepath,
displayPath: r.displayPath,
title: r.title,
body: r.body,
body: r.body || "",
score: r.score,
})));
}

View File

@ -1012,7 +1012,7 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
let path = file.path;
if (!collection || !path) {
const parsed = parseVirtualPath(file.displayPath);
const parsed = parseVirtualPath(file.filepath);
if (parsed) {
collection = parsed.collectionName;
path = parsed.path;
@ -1036,16 +1036,15 @@ function multiGet(pattern: string, maxLines?: number, maxBytes: number = DEFAULT
continue;
}
// Fetch document content - use virtual path to query
const parsed = parseVirtualPath(file.displayPath);
if (!parsed) continue;
// Fetch document content using collection and path
if (!collection || !path) continue;
const doc = db.prepare(`
SELECT content.doc as body, d.title
FROM documents d
JOIN content ON content.hash = d.hash
WHERE d.collection = ? AND d.path = ? AND d.active = 1
`).get(parsed.collectionName, parsed.path) as { body: string; title: string } | null;
`).get(collection, path) as { body: string; title: string } | null;
if (!doc) continue;

View File

@ -827,7 +827,8 @@ describe("Document Retrieval", () => {
expect("error" in result).toBe(false);
if (!("error" in result)) {
expect(result.title).toBe("My Document");
expect(result.displayPath).toBe(`qmd://${collectionName}/mydoc.md`);
expect(result.displayPath).toBe("mydoc.md");
expect(result.filepath).toBe(`qmd://${collectionName}/mydoc.md`);
expect(result.body).toBeUndefined(); // body not included by default
}
@ -917,8 +918,8 @@ describe("Document Retrieval", () => {
test("findDocument expands ~ to home directory", async () => {
const store = await createTestStore();
const collectionName = await createTestCollection();
const home = homedir();
const collectionName = await createTestCollection({ pwd: home, name: "home" });
await insertTestDocument(store.db, collectionName, {
name: "mydoc",
filepath: `${home}/docs/mydoc.md`,
@ -948,6 +949,41 @@ describe("Document Retrieval", () => {
await cleanupTestDb(store);
});
test("findDocument includes hierarchical contexts (global + collection + path)", async () => {
const store = await createTestStore();
const collectionName = await createTestCollection({ pwd: "/archive", name: "archive" });
// Add global context
await addGlobalContext("Global context for all documents");
// Add collection root context
await addPathContext(collectionName, "/", "Archive collection context");
// Add path-specific contexts at different levels
await addPathContext(collectionName, "/podcasts", "Podcast episodes");
await addPathContext(collectionName, "/podcasts/external", "External podcast interviews");
// Insert document in nested path
await insertTestDocument(store.db, collectionName, {
name: "interview",
displayPath: "podcasts/external/2024-jan-interview.md",
});
const result = store.findDocument("/archive/podcasts/external/2024-jan-interview.md");
expect("error" in result).toBe(false);
if (!("error" in result)) {
// Should have all contexts joined with double newlines
expect(result.context).toBe(
"Global context for all documents\n\n" +
"Archive collection context\n\n" +
"Podcast episodes\n\n" +
"External podcast interviews"
);
}
await cleanupTestDb(store);
});
});
describe("getDocumentBody", () => {

View File

@ -1909,11 +1909,11 @@ export function findDocuments(
const bodyCol = options.includeBody ? `, content.doc as body` : ``;
const selectCols = `
'qmd://' || d.collection || '/' || d.path as display_path,
'qmd://' || d.collection || '/' || d.path as virtual_path,
d.path as display_path,
d.title,
d.hash,
d.collection,
d.path,
d.modified_at,
LENGTH(content.doc) as body_length
${bodyCol}
@ -1971,14 +1971,13 @@ export function findDocuments(
const results: MultiGetResult[] = [];
for (const row of fileRows) {
// Compute absolute filepath from collection
const coll = getCollection(row.collection);
const absoluteFilepath = coll ? `${coll.path}/${row.path}` : row.path;
const context = getContextForFile(db, absoluteFilepath);
// Get context using virtual path
const virtualPath = row.virtual_path || `qmd://${row.collection}/${row.display_path}`;
const context = getContextForFile(db, virtualPath);
if (row.body_length > maxBytes) {
results.push({
doc: { filepath: absoluteFilepath, displayPath: row.display_path },
doc: { filepath: virtualPath, displayPath: row.display_path },
skipped: true,
skipReason: `File too large (${Math.round(row.body_length / 1024)}KB > ${Math.round(maxBytes / 1024)}KB)`,
});
@ -1987,7 +1986,7 @@ export function findDocuments(
results.push({
doc: {
filepath: absoluteFilepath,
filepath: virtualPath,
displayPath: row.display_path,
title: row.title || row.display_path.split('/').pop() || row.display_path,
context,