Parallel test files each cold-load their own LLM model, competing for CPU and causing timeouts even at 120s. Sequential execution eliminates contention — tests that timed out at 30s now complete in 1-15s. Made-with: Cursor
1361 lines
42 KiB
TypeScript
1361 lines
42 KiB
TypeScript
/**
|
|
* sdk.test.ts - Unit tests for the QMD SDK (library mode)
|
|
*
|
|
* Tests the public API exposed via `@tobilu/qmd` (src/index.ts).
|
|
* Uses inline config (no YAML files) to verify the SDK works self-contained.
|
|
*/
|
|
|
|
import { describe, test, expect, beforeAll, afterAll, beforeEach, afterEach } from "vitest";
|
|
import { mkdtemp, writeFile, mkdir, rm } from "node:fs/promises";
|
|
import { tmpdir } from "node:os";
|
|
import { join } from "node:path";
|
|
import { existsSync, writeFileSync, mkdirSync, readFileSync } from "node:fs";
|
|
import YAML from "yaml";
|
|
import {
|
|
createStore,
|
|
type QMDStore,
|
|
type CollectionConfig,
|
|
type StoreOptions,
|
|
type UpdateProgress,
|
|
type SearchOptions,
|
|
type LexSearchOptions,
|
|
type VectorSearchOptions,
|
|
type ExpandQueryOptions,
|
|
} from "../src/index.js";
|
|
import { setDefaultLlamaCpp } from "../src/llm.js";
|
|
|
|
// =============================================================================
|
|
// Test Helpers
|
|
// =============================================================================
|
|
|
|
let testDir: string;
|
|
let docsDir: string;
|
|
let notesDir: string;
|
|
|
|
beforeAll(async () => {
|
|
testDir = await mkdtemp(join(tmpdir(), "qmd-sdk-test-"));
|
|
docsDir = join(testDir, "docs");
|
|
notesDir = join(testDir, "notes");
|
|
|
|
// Create test directories with sample markdown files
|
|
await mkdir(docsDir, { recursive: true });
|
|
await mkdir(notesDir, { recursive: true });
|
|
|
|
await writeFile(join(docsDir, "readme.md"), "# Getting Started\n\nThis is the getting started guide for the project.\n");
|
|
await writeFile(join(docsDir, "auth.md"), "# Authentication\n\nAuthentication uses JWT tokens for session management.\nUsers log in with email and password.\n");
|
|
await writeFile(join(docsDir, "api.md"), "# API Reference\n\n## Endpoints\n\n### POST /login\nAuthenticate a user.\n\n### GET /users\nList all users.\n");
|
|
await writeFile(join(notesDir, "meeting-2025-01.md"), "# January Planning Meeting\n\nDiscussed Q1 roadmap and resource allocation.\n");
|
|
await writeFile(join(notesDir, "meeting-2025-02.md"), "# February Standup\n\nReviewed sprint progress. Authentication feature is on track.\n");
|
|
await writeFile(join(notesDir, "ideas.md"), "# Project Ideas\n\n- Build a search engine\n- Create a knowledge base\n- Implement vector search\n");
|
|
});
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await rm(testDir, { recursive: true, force: true });
|
|
} catch {
|
|
// Ignore cleanup errors
|
|
}
|
|
});
|
|
|
|
function freshDbPath(): string {
|
|
return join(testDir, `test-${Date.now()}-${Math.random().toString(36).slice(2)}.sqlite`);
|
|
}
|
|
|
|
// =============================================================================
|
|
// Constructor Tests
|
|
// =============================================================================
|
|
|
|
describe("createStore", () => {
|
|
test("creates store with inline config", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
expect(store).toBeDefined();
|
|
expect(store.dbPath).toBeTruthy();
|
|
expect(store.internal).toBeDefined();
|
|
await store.close();
|
|
});
|
|
|
|
test("creates store with YAML config file", async () => {
|
|
const configPath = join(testDir, "test-config.yml");
|
|
const config: CollectionConfig = {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
};
|
|
writeFileSync(configPath, YAML.stringify(config));
|
|
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
configPath,
|
|
});
|
|
|
|
expect(store).toBeDefined();
|
|
await store.close();
|
|
});
|
|
|
|
test("throws if dbPath is missing", async () => {
|
|
await expect(
|
|
createStore({ dbPath: "", config: { collections: {} } })
|
|
).rejects.toThrow("dbPath is required");
|
|
});
|
|
|
|
test("opens with just dbPath (DB-only mode)", async () => {
|
|
const store = await createStore({ dbPath: freshDbPath() } as StoreOptions);
|
|
expect(store).toBeDefined();
|
|
// No collections yet — fresh DB
|
|
const collections = await store.listCollections();
|
|
expect(collections).toEqual([]);
|
|
await store.close();
|
|
});
|
|
|
|
test("throws if both configPath and config are provided", async () => {
|
|
await expect(
|
|
createStore({
|
|
dbPath: freshDbPath(),
|
|
configPath: "/some/path.yml",
|
|
config: { collections: {} },
|
|
})
|
|
).rejects.toThrow("Provide either configPath or config, not both");
|
|
});
|
|
|
|
test("creates database file on disk", async () => {
|
|
const dbPath = freshDbPath();
|
|
const store = await createStore({
|
|
dbPath,
|
|
config: { collections: {} },
|
|
});
|
|
|
|
expect(existsSync(dbPath)).toBe(true);
|
|
await store.close();
|
|
});
|
|
|
|
test("store.dbPath matches the provided path", async () => {
|
|
const dbPath = freshDbPath();
|
|
const store = await createStore({
|
|
dbPath,
|
|
config: { collections: {} },
|
|
});
|
|
|
|
expect(store.dbPath).toBe(dbPath);
|
|
await store.close();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Collection Management Tests
|
|
// =============================================================================
|
|
|
|
describe("collection management", () => {
|
|
let store: QMDStore;
|
|
|
|
beforeEach(async () => {
|
|
store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: { collections: {} },
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await store.close();
|
|
});
|
|
|
|
test("addCollection adds a collection to inline config", async () => {
|
|
await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
|
|
|
|
const collections = await store.listCollections();
|
|
const names = collections.map(c => c.name);
|
|
expect(names).toContain("docs");
|
|
});
|
|
|
|
test("addCollection with default pattern", async () => {
|
|
await store.addCollection("notes", { path: notesDir });
|
|
|
|
const collections = await store.listCollections();
|
|
expect(collections.find(c => c.name === "notes")).toBeDefined();
|
|
});
|
|
|
|
test("removeCollection removes existing collection", async () => {
|
|
await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
|
|
const removed = await store.removeCollection("docs");
|
|
|
|
expect(removed).toBe(true);
|
|
const collections = await store.listCollections();
|
|
expect(collections.map(c => c.name)).not.toContain("docs");
|
|
});
|
|
|
|
test("removeCollection returns false for non-existent collection", async () => {
|
|
const removed = await store.removeCollection("nonexistent");
|
|
expect(removed).toBe(false);
|
|
});
|
|
|
|
test("renameCollection renames a collection", async () => {
|
|
await store.addCollection("old-name", { path: docsDir, pattern: "**/*.md" });
|
|
const renamed = await store.renameCollection("old-name", "new-name");
|
|
|
|
expect(renamed).toBe(true);
|
|
const names = (await store.listCollections()).map(c => c.name);
|
|
expect(names).toContain("new-name");
|
|
expect(names).not.toContain("old-name");
|
|
});
|
|
|
|
test("renameCollection returns false for non-existent source", async () => {
|
|
const renamed = await store.renameCollection("nonexistent", "new-name");
|
|
expect(renamed).toBe(false);
|
|
});
|
|
|
|
test("renameCollection throws if target exists", async () => {
|
|
await store.addCollection("a", { path: docsDir, pattern: "**/*.md" });
|
|
await store.addCollection("b", { path: notesDir, pattern: "**/*.md" });
|
|
|
|
await expect(store.renameCollection("a", "b")).rejects.toThrow("already exists");
|
|
});
|
|
|
|
test("listCollections returns empty array for empty config", async () => {
|
|
const collections = await store.listCollections();
|
|
expect(collections).toEqual([]);
|
|
});
|
|
|
|
test("multiple collections can be added", async () => {
|
|
await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
|
|
await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
|
|
|
|
const names = (await store.listCollections()).map(c => c.name);
|
|
expect(names).toContain("docs");
|
|
expect(names).toContain("notes");
|
|
expect(names).toHaveLength(2);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Context Management Tests
|
|
// =============================================================================
|
|
|
|
describe("context management", () => {
|
|
let store: QMDStore;
|
|
|
|
beforeEach(async () => {
|
|
store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await store.close();
|
|
});
|
|
|
|
test("addContext adds context to a collection path", async () => {
|
|
const added = await store.addContext("docs", "/auth", "Authentication docs");
|
|
expect(added).toBe(true);
|
|
|
|
const contexts = await store.listContexts();
|
|
expect(contexts).toContainEqual({
|
|
collection: "docs",
|
|
path: "/auth",
|
|
context: "Authentication docs",
|
|
});
|
|
});
|
|
|
|
test("addContext returns false for non-existent collection", async () => {
|
|
const added = await store.addContext("nonexistent", "/path", "Some context");
|
|
expect(added).toBe(false);
|
|
});
|
|
|
|
test("removeContext removes existing context", async () => {
|
|
await store.addContext("docs", "/auth", "Authentication docs");
|
|
const removed = await store.removeContext("docs", "/auth");
|
|
|
|
expect(removed).toBe(true);
|
|
const contexts = await store.listContexts();
|
|
expect(contexts.find(c => c.path === "/auth")).toBeUndefined();
|
|
});
|
|
|
|
test("removeContext returns false for non-existent context", async () => {
|
|
const removed = await store.removeContext("docs", "/nonexistent");
|
|
expect(removed).toBe(false);
|
|
});
|
|
|
|
test("setGlobalContext sets and retrieves global context", async () => {
|
|
await store.setGlobalContext("Global knowledge base");
|
|
const global = await store.getGlobalContext();
|
|
|
|
expect(global).toBe("Global knowledge base");
|
|
});
|
|
|
|
test("setGlobalContext with undefined clears it", async () => {
|
|
await store.setGlobalContext("Some context");
|
|
await store.setGlobalContext(undefined);
|
|
const global = await store.getGlobalContext();
|
|
|
|
expect(global).toBeUndefined();
|
|
});
|
|
|
|
test("listContexts includes global context", async () => {
|
|
await store.setGlobalContext("Global context");
|
|
const contexts = await store.listContexts();
|
|
|
|
expect(contexts).toContainEqual({
|
|
collection: "*",
|
|
path: "/",
|
|
context: "Global context",
|
|
});
|
|
});
|
|
|
|
test("listContexts returns contexts across multiple collections", async () => {
|
|
await store.addContext("docs", "/", "Documentation");
|
|
await store.addContext("notes", "/", "Personal notes");
|
|
|
|
const contexts = await store.listContexts();
|
|
expect(contexts.filter(c => c.path === "/")).toHaveLength(2);
|
|
});
|
|
|
|
test("multiple contexts on same collection", async () => {
|
|
await store.addContext("docs", "/auth", "Auth docs");
|
|
await store.addContext("docs", "/api", "API docs");
|
|
|
|
const contexts = (await store.listContexts()).filter(c => c.collection === "docs");
|
|
expect(contexts).toHaveLength(2);
|
|
expect(contexts.map(c => c.path).sort()).toEqual(["/api", "/auth"]);
|
|
});
|
|
|
|
test("addContext overwrites existing context for same path", async () => {
|
|
await store.addContext("docs", "/auth", "Old context");
|
|
await store.addContext("docs", "/auth", "New context");
|
|
|
|
const contexts = (await store.listContexts()).filter(c => c.path === "/auth");
|
|
expect(contexts).toHaveLength(1);
|
|
expect(contexts[0]!.context).toBe("New context");
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Inline Config Isolation Tests
|
|
// =============================================================================
|
|
|
|
describe("inline config isolation", () => {
|
|
test("inline config does not write any files to disk", async () => {
|
|
const configDir = join(testDir, "should-not-exist");
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await store.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
|
|
await store.addContext("docs", "/", "Documentation");
|
|
|
|
expect(existsSync(configDir)).toBe(false);
|
|
await store.close();
|
|
});
|
|
|
|
test("inline config mutations persist within session", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: { collections: {} },
|
|
});
|
|
|
|
await store.addCollection("docs", { path: docsDir, pattern: "**/*.md" });
|
|
await store.addContext("docs", "/", "My docs");
|
|
|
|
// Verify the mutations are visible
|
|
const collections = await store.listCollections();
|
|
expect(collections.map(c => c.name)).toContain("docs");
|
|
|
|
const contexts = await store.listContexts();
|
|
expect(contexts).toContainEqual({
|
|
collection: "docs",
|
|
path: "/",
|
|
context: "My docs",
|
|
});
|
|
|
|
await store.close();
|
|
});
|
|
|
|
test("two stores with different inline configs are independent", async () => {
|
|
const store1 = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Close first store (resets config source)
|
|
await store1.close();
|
|
|
|
const store2 = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const names = (await store2.listCollections()).map(c => c.name);
|
|
expect(names).toContain("notes");
|
|
expect(names).not.toContain("docs");
|
|
|
|
await store2.close();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// YAML Config File Tests
|
|
// =============================================================================
|
|
|
|
describe("YAML config file mode", () => {
|
|
test("loads collections from YAML file", async () => {
|
|
const configPath = join(testDir, `config-${Date.now()}.yml`);
|
|
const config: CollectionConfig = {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
};
|
|
writeFileSync(configPath, YAML.stringify(config));
|
|
|
|
const store = await createStore({ dbPath: freshDbPath(), configPath });
|
|
const names = (await store.listCollections()).map(c => c.name);
|
|
|
|
expect(names).toContain("docs");
|
|
expect(names).toContain("notes");
|
|
await store.close();
|
|
});
|
|
|
|
test("addCollection persists to YAML file", async () => {
|
|
const configPath = join(testDir, `config-persist-${Date.now()}.yml`);
|
|
writeFileSync(configPath, YAML.stringify({ collections: {} }));
|
|
|
|
const store = await createStore({ dbPath: freshDbPath(), configPath });
|
|
await store.addCollection("newcol", { path: docsDir, pattern: "**/*.md" });
|
|
await store.close();
|
|
|
|
// Read the YAML file directly and verify
|
|
const raw = readFileSync(configPath, "utf-8");
|
|
const parsed = YAML.parse(raw) as CollectionConfig;
|
|
expect(parsed.collections).toHaveProperty("newcol");
|
|
expect(parsed.collections.newcol!.path).toBe(docsDir);
|
|
});
|
|
|
|
test("context persists to YAML file", async () => {
|
|
const configPath = join(testDir, `config-ctx-${Date.now()}.yml`);
|
|
writeFileSync(configPath, YAML.stringify({
|
|
collections: { docs: { path: docsDir, pattern: "**/*.md" } },
|
|
}));
|
|
|
|
const store = await createStore({ dbPath: freshDbPath(), configPath });
|
|
await store.addContext("docs", "/api", "API documentation");
|
|
await store.close();
|
|
|
|
const raw = readFileSync(configPath, "utf-8");
|
|
const parsed = YAML.parse(raw) as CollectionConfig;
|
|
expect(parsed.collections.docs!.context).toEqual({ "/api": "API documentation" });
|
|
});
|
|
|
|
test("non-existent config file returns empty collections", async () => {
|
|
const configPath = join(testDir, "nonexistent-config.yml");
|
|
const store = await createStore({ dbPath: freshDbPath(), configPath });
|
|
const collections = await store.listCollections();
|
|
|
|
expect(collections).toEqual([]);
|
|
await store.close();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Search Tests (BM25 - no LLM needed)
|
|
// =============================================================================
|
|
|
|
describe("searchLex (BM25)", () => {
|
|
let store: QMDStore;
|
|
let dbPath: string;
|
|
|
|
beforeAll(async () => {
|
|
dbPath = join(testDir, "search-test.sqlite");
|
|
store = await createStore({
|
|
dbPath,
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Index documents manually using internal store
|
|
const now = new Date().toISOString();
|
|
const { internal } = store;
|
|
const fs = require("fs");
|
|
|
|
// Index docs collection
|
|
for (const file of ["readme.md", "auth.md", "api.md"]) {
|
|
const fullPath = join(docsDir, file);
|
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
const hash = require("crypto").createHash("sha256").update(content).digest("hex");
|
|
const title = content.match(/^#\s+(.+)/m)?.[1] || file;
|
|
|
|
internal.insertContent(hash, content, now);
|
|
internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
|
|
}
|
|
|
|
// Index notes collection
|
|
for (const file of ["meeting-2025-01.md", "meeting-2025-02.md", "ideas.md"]) {
|
|
const fullPath = join(notesDir, file);
|
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
const hash = require("crypto").createHash("sha256").update(content).digest("hex");
|
|
const title = content.match(/^#\s+(.+)/m)?.[1] || file;
|
|
|
|
internal.insertContent(hash, content, now);
|
|
internal.insertDocument("notes", `qmd://notes/${file}`, title, hash, now, now);
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await store.close();
|
|
});
|
|
|
|
test("searchLex returns results for matching query", async () => {
|
|
const results = await store.searchLex("authentication");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("searchLex results have expected shape", async () => {
|
|
const results = await store.searchLex("authentication");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
|
|
const result = results[0]!;
|
|
expect(result).toHaveProperty("filepath");
|
|
expect(result).toHaveProperty("score");
|
|
expect(result).toHaveProperty("title");
|
|
expect(result).toHaveProperty("docid");
|
|
expect(result).toHaveProperty("collectionName");
|
|
expect(typeof result.score).toBe("number");
|
|
expect(result.score).toBeGreaterThan(0);
|
|
});
|
|
|
|
test("searchLex respects limit option", async () => {
|
|
const results = await store.searchLex("meeting", { limit: 1 });
|
|
expect(results.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("searchLex with collection filter", async () => {
|
|
const results = await store.searchLex("authentication", { collection: "notes" });
|
|
for (const r of results) {
|
|
expect(r.collectionName).toBe("notes");
|
|
}
|
|
});
|
|
|
|
test("searchLex returns empty for non-matching query", async () => {
|
|
const results = await store.searchLex("xyznonexistentterm123");
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
|
|
test("searchLex finds documents across collections", async () => {
|
|
const results = await store.searchLex("authentication", { limit: 10 });
|
|
const collections = new Set(results.map(r => r.collectionName));
|
|
// Auth appears in both docs/auth.md and notes/meeting-2025-02.md
|
|
expect(collections.size).toBeGreaterThanOrEqual(1);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Unified search() API Tests
|
|
// =============================================================================
|
|
|
|
describe("search (unified API)", () => {
|
|
let store: QMDStore;
|
|
|
|
beforeAll(async () => {
|
|
store = await createStore({
|
|
dbPath: join(testDir, "unified-search-test.sqlite"),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
await store.update();
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await store.close();
|
|
});
|
|
|
|
test("search() requires query or queries", async () => {
|
|
await expect(store.search({} as SearchOptions)).rejects.toThrow("requires either 'query' or 'queries'");
|
|
});
|
|
|
|
test("search() with pre-expanded queries and rerank:false", async () => {
|
|
const results = await store.search({
|
|
queries: [
|
|
{ type: "lex", query: "authentication JWT" },
|
|
{ type: "lex", query: "login session" },
|
|
],
|
|
rerank: false,
|
|
});
|
|
expect(results.length).toBeGreaterThan(0);
|
|
});
|
|
|
|
// Tests below use search({ query: ... }) which triggers LLM query expansion
|
|
describe.skipIf(!!process.env.CI)("with LLM query expansion", () => {
|
|
test("search() with query and rerank:false returns results", async () => {
|
|
const results = await store.search({ query: "authentication", rerank: false });
|
|
expect(results.length).toBeGreaterThan(0);
|
|
expect(results[0]).toHaveProperty("file");
|
|
expect(results[0]).toHaveProperty("score");
|
|
expect(results[0]).toHaveProperty("title");
|
|
expect(results[0]).toHaveProperty("bestChunk");
|
|
expect(results[0]).toHaveProperty("docid");
|
|
}, 90000);
|
|
|
|
test("search() with intent and rerank:false returns results", async () => {
|
|
const results = await store.search({
|
|
query: "meeting",
|
|
intent: "quarterly planning and roadmap",
|
|
rerank: false,
|
|
});
|
|
expect(results.length).toBeGreaterThan(0);
|
|
}, 60000);
|
|
|
|
test("search() with collection filter", async () => {
|
|
const results = await store.search({
|
|
query: "authentication",
|
|
collection: "docs",
|
|
rerank: false,
|
|
});
|
|
for (const r of results) {
|
|
expect(r.file).toMatch(/^qmd:\/\/docs\//);
|
|
}
|
|
});
|
|
|
|
test("search() with collections filter", async () => {
|
|
const results = await store.search({
|
|
query: "authentication",
|
|
collections: ["docs"],
|
|
rerank: false,
|
|
});
|
|
for (const r of results) {
|
|
expect(r.file).toMatch(/^qmd:\/\/docs\//);
|
|
}
|
|
});
|
|
|
|
test("search() with limit", async () => {
|
|
const results = await store.search({ query: "meeting", limit: 1, rerank: false });
|
|
expect(results.length).toBeLessThanOrEqual(1);
|
|
});
|
|
|
|
test("search() returns empty for non-matching query", async () => {
|
|
const results = await store.search({ query: "xyznonexistentterm123", rerank: false });
|
|
expect(results).toHaveLength(0);
|
|
});
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Document Retrieval Tests
|
|
// =============================================================================
|
|
|
|
describe("get and multiGet", () => {
|
|
let store: QMDStore;
|
|
|
|
beforeAll(async () => {
|
|
store = await createStore({
|
|
dbPath: join(testDir, "get-test.sqlite"),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Index documents
|
|
const now = new Date().toISOString();
|
|
const { internal } = store;
|
|
const fs = require("fs");
|
|
|
|
for (const file of ["readme.md", "auth.md", "api.md"]) {
|
|
const fullPath = join(docsDir, file);
|
|
const content = fs.readFileSync(fullPath, "utf-8");
|
|
const hash = require("crypto").createHash("sha256").update(content).digest("hex");
|
|
const title = content.match(/^#\s+(.+)/m)?.[1] || file;
|
|
|
|
internal.insertContent(hash, content, now);
|
|
internal.insertDocument("docs", `qmd://docs/${file}`, title, hash, now, now);
|
|
}
|
|
});
|
|
|
|
afterAll(async () => {
|
|
await store.close();
|
|
});
|
|
|
|
test("get retrieves a document by path", async () => {
|
|
const result = await store.get("qmd://docs/auth.md");
|
|
|
|
expect("error" in result).toBe(false);
|
|
if (!("error" in result)) {
|
|
expect(result.title).toBe("Authentication");
|
|
expect(result.collectionName).toBe("docs");
|
|
}
|
|
});
|
|
|
|
test("get with includeBody returns body content", async () => {
|
|
const result = await store.get("qmd://docs/auth.md", { includeBody: true });
|
|
|
|
if (!("error" in result)) {
|
|
expect(result.body).toBeDefined();
|
|
expect(result.body).toContain("JWT tokens");
|
|
}
|
|
});
|
|
|
|
test("get returns not_found for missing document", async () => {
|
|
const result = await store.get("qmd://docs/nonexistent.md");
|
|
|
|
expect("error" in result).toBe(true);
|
|
if ("error" in result) {
|
|
expect(result.error).toBe("not_found");
|
|
}
|
|
});
|
|
|
|
test("get by docid", async () => {
|
|
// First get a document to find its docid
|
|
const doc = await store.get("qmd://docs/readme.md");
|
|
if (!("error" in doc)) {
|
|
const byDocid = await store.get(`#${doc.docid}`);
|
|
expect("error" in byDocid).toBe(false);
|
|
if (!("error" in byDocid)) {
|
|
expect(byDocid.docid).toBe(doc.docid);
|
|
}
|
|
}
|
|
});
|
|
|
|
test("multiGet retrieves multiple documents", async () => {
|
|
const { docs, errors } = await store.multiGet("qmd://docs/*.md");
|
|
expect(docs.length).toBeGreaterThan(0);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Index Health Tests
|
|
// =============================================================================
|
|
|
|
describe("index health", () => {
|
|
let store: QMDStore;
|
|
|
|
beforeEach(async () => {
|
|
store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await store.close();
|
|
});
|
|
|
|
test("getStatus returns valid structure", async () => {
|
|
const status = await store.getStatus();
|
|
|
|
expect(status).toHaveProperty("totalDocuments");
|
|
expect(status).toHaveProperty("needsEmbedding");
|
|
expect(status).toHaveProperty("hasVectorIndex");
|
|
expect(status).toHaveProperty("collections");
|
|
expect(typeof status.totalDocuments).toBe("number");
|
|
});
|
|
|
|
test("getIndexHealth returns valid structure", async () => {
|
|
const health = await store.getIndexHealth();
|
|
|
|
expect(health).toHaveProperty("needsEmbedding");
|
|
expect(health).toHaveProperty("totalDocs");
|
|
expect(typeof health.needsEmbedding).toBe("number");
|
|
expect(typeof health.totalDocs).toBe("number");
|
|
});
|
|
|
|
test("fresh store has zero documents", async () => {
|
|
const status = await store.getStatus();
|
|
expect(status.totalDocuments).toBe(0);
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Update Tests
|
|
// =============================================================================
|
|
|
|
describe("update", () => {
|
|
test("indexes files and returns correct stats", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await store.update();
|
|
|
|
expect(result.collections).toBe(1);
|
|
expect(result.indexed).toBe(3); // readme.md, auth.md, api.md
|
|
expect(result.updated).toBe(0);
|
|
expect(result.unchanged).toBe(0);
|
|
expect(result.removed).toBe(0);
|
|
expect(typeof result.needsEmbedding).toBe("number");
|
|
|
|
await store.close();
|
|
});
|
|
|
|
test("second update shows unchanged files", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await store.update();
|
|
const result = await store.update();
|
|
|
|
expect(result.indexed).toBe(0);
|
|
expect(result.unchanged).toBe(3);
|
|
|
|
await store.close();
|
|
});
|
|
|
|
test("update with onProgress callback fires", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const progress: UpdateProgress[] = [];
|
|
await store.update({
|
|
onProgress: (info) => progress.push(info),
|
|
});
|
|
|
|
expect(progress.length).toBeGreaterThan(0);
|
|
expect(progress[0]!.collection).toBe("docs");
|
|
expect(progress[0]!.current).toBeGreaterThanOrEqual(1);
|
|
expect(progress[0]!.total).toBe(3);
|
|
|
|
await store.close();
|
|
});
|
|
|
|
test("update with collection filter", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await store.update({ collections: ["docs"] });
|
|
|
|
expect(result.collections).toBe(1);
|
|
expect(result.indexed).toBe(3); // Only docs
|
|
|
|
await store.close();
|
|
});
|
|
|
|
test("update multiple collections", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const result = await store.update();
|
|
|
|
expect(result.collections).toBe(2);
|
|
expect(result.indexed).toBe(6); // 3 docs + 3 notes
|
|
|
|
await store.close();
|
|
});
|
|
|
|
test("documents are searchable after update", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
await store.update();
|
|
|
|
const results = await store.searchLex("authentication");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
|
|
await store.close();
|
|
});
|
|
});
|
|
|
|
describe("embed", () => {
|
|
function createFakeTokenizer() {
|
|
return {
|
|
async tokenize(text: string) {
|
|
return new Array(Math.max(1, Math.ceil(text.length / 16))).fill(1);
|
|
},
|
|
};
|
|
}
|
|
|
|
function createFakeEmbedLlm() {
|
|
const embedBatchCalls: string[][] = [];
|
|
return {
|
|
embedBatchCalls,
|
|
async embed(_text: string) {
|
|
return { embedding: [0.1, 0.2, 0.3], model: "fake-embed" };
|
|
},
|
|
async embedBatch(texts: string[]) {
|
|
embedBatchCalls.push([...texts]);
|
|
return texts.map((_text, index) => ({
|
|
embedding: [index + 1, index + 2, index + 3],
|
|
model: "fake-embed",
|
|
}));
|
|
},
|
|
};
|
|
}
|
|
|
|
test("store.embed forwards batch limit options", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const fakeLlm = createFakeEmbedLlm();
|
|
setDefaultLlamaCpp(createFakeTokenizer() as any);
|
|
store.internal.llm = fakeLlm as any;
|
|
|
|
try {
|
|
await store.update();
|
|
const result = await store.embed({
|
|
maxDocsPerBatch: 1,
|
|
maxBatchBytes: 1024 * 1024,
|
|
});
|
|
|
|
expect(fakeLlm.embedBatchCalls).toHaveLength(3);
|
|
expect(fakeLlm.embedBatchCalls.map(call => call.length)).toEqual([1, 1, 1]);
|
|
expect(result.docsProcessed).toBe(3);
|
|
expect(result.chunksEmbedded).toBe(3);
|
|
} finally {
|
|
setDefaultLlamaCpp(null);
|
|
await store.close();
|
|
}
|
|
});
|
|
|
|
test("store.embed rejects invalid batch limits", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: { collections: {} },
|
|
});
|
|
|
|
try {
|
|
await expect(store.embed({ maxDocsPerBatch: 0 })).rejects.toThrow("maxDocsPerBatch");
|
|
await expect(store.embed({ maxBatchBytes: 0 })).rejects.toThrow("maxBatchBytes");
|
|
} finally {
|
|
setDefaultLlamaCpp(null);
|
|
await store.close();
|
|
}
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Lifecycle Tests
|
|
// =============================================================================
|
|
|
|
describe("lifecycle", () => {
|
|
test("close() is async and does not throw", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: { collections: {} },
|
|
});
|
|
|
|
// close() should return a promise
|
|
const result = store.close();
|
|
expect(result).toBeInstanceOf(Promise);
|
|
await result;
|
|
});
|
|
|
|
test("close() makes subsequent operations throw", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: { collections: {} },
|
|
});
|
|
|
|
await store.close();
|
|
|
|
// Database operations should fail after close
|
|
await expect(store.getStatus()).rejects.toThrow();
|
|
});
|
|
|
|
test("multiple stores can coexist with different databases", async () => {
|
|
const store1 = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
// Note: since config source is module-level, we close store1 first
|
|
await store1.close();
|
|
|
|
const store2 = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const names = (await store2.listCollections()).map(c => c.name);
|
|
expect(names).toContain("notes");
|
|
expect(names).not.toContain("docs");
|
|
|
|
await store2.close();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Config Initialization Tests
|
|
// =============================================================================
|
|
|
|
describe("config initialization", () => {
|
|
test("inline config with global_context is preserved", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
global_context: "System knowledge base",
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
|
|
const global = await store.getGlobalContext();
|
|
expect(global).toBe("System knowledge base");
|
|
await store.close();
|
|
});
|
|
|
|
test("inline config with pre-existing contexts is preserved", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: {
|
|
path: docsDir,
|
|
pattern: "**/*.md",
|
|
context: { "/auth": "Authentication docs" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const contexts = await store.listContexts();
|
|
expect(contexts).toContainEqual({
|
|
collection: "docs",
|
|
path: "/auth",
|
|
context: "Authentication docs",
|
|
});
|
|
await store.close();
|
|
});
|
|
|
|
test("inline config with empty collections object works", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: { collections: {} },
|
|
});
|
|
|
|
expect(await store.listCollections()).toEqual([]);
|
|
expect(await store.listContexts()).toEqual([]);
|
|
await store.close();
|
|
});
|
|
|
|
test("inline config with multiple collection options", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: {
|
|
collections: {
|
|
docs: {
|
|
path: docsDir,
|
|
pattern: "**/*.md",
|
|
ignore: ["drafts/**"],
|
|
includeByDefault: true,
|
|
},
|
|
notes: {
|
|
path: notesDir,
|
|
pattern: "**/*.md",
|
|
includeByDefault: false,
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
const collections = await store.listCollections();
|
|
expect(collections).toHaveLength(2);
|
|
await store.close();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// Type Export Tests (compile-time checks, runtime verification)
|
|
// =============================================================================
|
|
|
|
describe("type exports", () => {
|
|
test("StoreOptions type is usable", () => {
|
|
const opts: StoreOptions = {
|
|
dbPath: "/tmp/test.sqlite",
|
|
config: { collections: {} },
|
|
};
|
|
expect(opts.dbPath).toBe("/tmp/test.sqlite");
|
|
});
|
|
|
|
test("CollectionConfig type is usable", () => {
|
|
const config: CollectionConfig = {
|
|
global_context: "test",
|
|
collections: {
|
|
test: { path: "/tmp", pattern: "**/*.md" },
|
|
},
|
|
};
|
|
expect(config.collections).toHaveProperty("test");
|
|
});
|
|
|
|
test("QMDStore type exposes expected methods", async () => {
|
|
const store = await createStore({
|
|
dbPath: freshDbPath(),
|
|
config: { collections: {} },
|
|
});
|
|
|
|
// Verify all methods exist
|
|
expect(typeof store.search).toBe("function");
|
|
expect(typeof store.searchLex).toBe("function");
|
|
expect(typeof store.searchVector).toBe("function");
|
|
expect(typeof store.expandQuery).toBe("function");
|
|
expect(typeof store.get).toBe("function");
|
|
expect(typeof store.multiGet).toBe("function");
|
|
expect(typeof store.addCollection).toBe("function");
|
|
expect(typeof store.removeCollection).toBe("function");
|
|
expect(typeof store.renameCollection).toBe("function");
|
|
expect(typeof store.listCollections).toBe("function");
|
|
expect(typeof store.addContext).toBe("function");
|
|
expect(typeof store.removeContext).toBe("function");
|
|
expect(typeof store.setGlobalContext).toBe("function");
|
|
expect(typeof store.getGlobalContext).toBe("function");
|
|
expect(typeof store.listContexts).toBe("function");
|
|
expect(typeof store.getStatus).toBe("function");
|
|
expect(typeof store.getIndexHealth).toBe("function");
|
|
expect(typeof store.update).toBe("function");
|
|
expect(typeof store.embed).toBe("function");
|
|
expect(typeof store.close).toBe("function");
|
|
|
|
await store.close();
|
|
});
|
|
});
|
|
|
|
// =============================================================================
|
|
// DB-Only Mode Tests (self-contained store)
|
|
// =============================================================================
|
|
|
|
describe("DB-only mode", () => {
|
|
test("reopen store with just dbPath after config+update session", async () => {
|
|
const dbPath = freshDbPath();
|
|
|
|
// Session 1: create store with config, update, close
|
|
const store1 = await createStore({
|
|
dbPath,
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
notes: { path: notesDir, pattern: "**/*.md" },
|
|
},
|
|
global_context: "Test knowledge base",
|
|
},
|
|
});
|
|
|
|
await store1.update();
|
|
|
|
// Verify documents indexed
|
|
const status1 = await store1.getStatus();
|
|
expect(status1.totalDocuments).toBe(6);
|
|
await store1.close();
|
|
|
|
// Session 2: reopen with just dbPath — no config
|
|
const store2 = await createStore({ dbPath } as StoreOptions);
|
|
|
|
// Collections should still be available
|
|
const collections = await store2.listCollections();
|
|
expect(collections.map(c => c.name).sort()).toEqual(["docs", "notes"]);
|
|
|
|
// Search should still work
|
|
const results = await store2.searchLex("authentication");
|
|
expect(results.length).toBeGreaterThan(0);
|
|
|
|
// Global context should still be available
|
|
const globalCtx = await store2.getGlobalContext();
|
|
expect(globalCtx).toBe("Test knowledge base");
|
|
|
|
// Contexts from collections should persist
|
|
const status2 = await store2.getStatus();
|
|
expect(status2.totalDocuments).toBe(6);
|
|
|
|
await store2.close();
|
|
});
|
|
|
|
test("config sync populates store_collections table", async () => {
|
|
const dbPath = freshDbPath();
|
|
const store = await createStore({
|
|
dbPath,
|
|
config: {
|
|
collections: {
|
|
docs: {
|
|
path: docsDir,
|
|
pattern: "**/*.md",
|
|
context: { "/auth": "Auth documentation" },
|
|
},
|
|
},
|
|
},
|
|
});
|
|
|
|
// Verify collections are in the DB via listCollections
|
|
const collections = await store.listCollections();
|
|
expect(collections).toHaveLength(1);
|
|
expect(collections[0]!.name).toBe("docs");
|
|
expect(collections[0]!.pwd).toBe(docsDir);
|
|
|
|
// Verify contexts are accessible
|
|
const contexts = await store.listContexts();
|
|
expect(contexts).toContainEqual({
|
|
collection: "docs",
|
|
path: "/auth",
|
|
context: "Auth documentation",
|
|
});
|
|
|
|
await store.close();
|
|
});
|
|
|
|
test("config hash skip: second init with same config skips sync", async () => {
|
|
const dbPath = freshDbPath();
|
|
const config = {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
};
|
|
|
|
// First init — syncs config
|
|
const store1 = await createStore({ dbPath, config });
|
|
await store1.close();
|
|
|
|
// Second init with same config — should skip sync (no-op, but should not error)
|
|
const store2 = await createStore({ dbPath, config });
|
|
const collections = await store2.listCollections();
|
|
expect(collections).toHaveLength(1);
|
|
expect(collections[0]!.name).toBe("docs");
|
|
await store2.close();
|
|
});
|
|
|
|
test("DB-only mode supports collection mutations", async () => {
|
|
const dbPath = freshDbPath();
|
|
|
|
// Session 1: create with config
|
|
const store1 = await createStore({
|
|
dbPath,
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
await store1.close();
|
|
|
|
// Session 2: reopen DB-only, add a collection
|
|
const store2 = await createStore({ dbPath } as StoreOptions);
|
|
await store2.addCollection("notes", { path: notesDir, pattern: "**/*.md" });
|
|
|
|
const names = (await store2.listCollections()).map(c => c.name).sort();
|
|
expect(names).toEqual(["docs", "notes"]);
|
|
|
|
await store2.close();
|
|
|
|
// Session 3: reopen DB-only again, verify both collections persist
|
|
const store3 = await createStore({ dbPath } as StoreOptions);
|
|
const names3 = (await store3.listCollections()).map(c => c.name).sort();
|
|
expect(names3).toEqual(["docs", "notes"]);
|
|
await store3.close();
|
|
});
|
|
|
|
test("DB-only mode supports context mutations", async () => {
|
|
const dbPath = freshDbPath();
|
|
|
|
// Session 1: create with config
|
|
const store1 = await createStore({
|
|
dbPath,
|
|
config: {
|
|
collections: {
|
|
docs: { path: docsDir, pattern: "**/*.md" },
|
|
},
|
|
},
|
|
});
|
|
await store1.addContext("docs", "/api", "API docs");
|
|
await store1.setGlobalContext("Global context");
|
|
await store1.close();
|
|
|
|
// Session 2: reopen DB-only
|
|
const store2 = await createStore({ dbPath } as StoreOptions);
|
|
|
|
const contexts = await store2.listContexts();
|
|
expect(contexts).toContainEqual({
|
|
collection: "docs",
|
|
path: "/api",
|
|
context: "API docs",
|
|
});
|
|
expect(contexts).toContainEqual({
|
|
collection: "*",
|
|
path: "/",
|
|
context: "Global context",
|
|
});
|
|
|
|
await store2.close();
|
|
});
|
|
});
|