/** * PR-3.5: FTS5 Search Adapter Test * * Run: node --test test/memory-core/fts5-adapter.test.mjs * * Tests: * 1. FTS5 adapter is importable * 2. FTS5 adapter opens DB read-only * 3. FTS5 query returns unified contract * 4. FTS5 results have stable fields * 5. Empty query returns INVALID_QUERY * 6. Fallback to file-search when FTS5 unavailable * 7. Does not modify SQLite * 8. Does not modify MEMORY.md * 9. Production check still passes */ import { describe, it, before } from "node:test"; import assert from "node:assert"; import { existsSync, readFileSync } from "node:fs"; import { join, resolve } from "node:path"; import { homedir } from "node:os"; import { spawnSync } from "node:child_process"; import { fileURLToPath } from "node:url"; const __dirname = join(fileURLToPath(import.meta.url), "..", "..", ".."); const ROOT = resolve(__dirname); const DB_PATH = join(homedir(), ".openclaw", "memory", "main.sqlite"); let createFTS5Adapter; let adapter; let fts5Available; before(async () => { const mod = await import( join(ROOT, "src", "memory-core", "memory-fts5-search-adapter.mjs") ); createFTS5Adapter = mod.createFTS5SearchAdapter; adapter = createFTS5Adapter(DB_PATH); fts5Available = await adapter.isAvailable(); }); describe("PR-3.5: FTS5 Search Adapter", () => { // ─── Test 1 ────────────────────────────────────────── it("adapter is importable", () => { assert.ok(typeof createFTS5Adapter === "function", "createFTS5SearchAdapter should be a function"); assert.ok(typeof adapter.search === "function", "adapter.search should be a function"); assert.ok(typeof adapter.isAvailable === "function", "adapter.isAvailable should be a function"); }); // ─── Test 2 ────────────────────────────────────────── it("adapter opens DB read-only", async () => { // FTS5 availability check const available = await adapter.isAvailable(); assert.equal(typeof available, "boolean"); if (available) { // Verify we can search (proves read-only connection works) const result = await adapter.search("test", { maxResults: 1 }); // Either ok:true (found results) or ok:true (no results) // or ok:false (FTS5 error on that term) — all acceptable assert.ok(typeof result.ok === "boolean", "should have ok field"); assert.equal(result.source, "fts5-search", `source should be fts5-search, got ${result.source}`); } else { console.log(" FTS5 not available on this system — skipping read test"); } }); // ─── Test 3 ────────────────────────────────────────── it("FTS5 query returns unified contract", async () => { const result = await adapter.search("memory", { maxResults: 5 }); assert.equal(result.ok, true, `search should succeed: ${JSON.stringify(result.error)}`); assert.ok(Array.isArray(result.results), "results should be array"); assert.ok(result.results.length > 0, "should find results for 'memory'"); assert.equal(result.source, "fts5-search"); assert.ok(typeof result.latencyMs === "number", "latencyMs should be number"); assert.ok(result.latencyMs >= 0, "latencyMs should be >= 0"); }); // ─── Test 4 ────────────────────────────────────────── it("FTS5 results have stable fields", async () => { const result = await adapter.search("OpenClaw", { maxResults: 5 }); assert.equal(result.ok, true); assert.ok(result.results.length > 0, "should find OpenClaw references"); for (const r of result.results) { assert.ok(typeof r.id === "string", `result.id should be string, got ${typeof r.id}`); assert.ok(typeof r.path === "string", `result.path should be string`); assert.ok(typeof r.snippet === "string", `result.snippet should be string`); assert.ok(typeof r.score === "number", `result.score should be number`); assert.ok(r.score >= 0 && r.score <= 1, `score should be 0-1, got ${r.score}`); assert.ok(typeof r.source === "string", `result.source should be string`); } }); // ─── Test 5 ────────────────────────────────────────── it("empty query returns INVALID_QUERY", async () => { const result = await adapter.search(""); assert.equal(result.ok, false, "empty query should fail"); assert.equal(result.error.code, "INVALID_QUERY", `Expected INVALID_QUERY, got ${result.error?.code}`); assert.ok(Array.isArray(result.results), "results should be array"); assert.equal(result.results.length, 0, "results should be empty"); assert.equal(result.source, "fts5-search"); }); // ─── Test 6 ────────────────────────────────────────── it("fallback to file-search when FTS5 path is invalid", async () => { // Create adapter pointing to non-existent DB path const badAdapter = createFTS5Adapter("/nonexistent/path/main.sqlite"); // Should return error without fallback const result = await badAdapter.search("test", { maxResults: 3 }); assert.equal(result.ok, false, "should fail without DB"); assert.equal(result.source, "file-search-fallback", "source should indicate fallback attempt"); assert.ok(result.error, "should have error"); }); // ─── Test 7 ────────────────────────────────────────── it("does not modify SQLite file", async () => { const statBefore = existsSync(DB_PATH) ? readFileSync(DB_PATH, null).length : -1; // Run multiple searches await adapter.search("memory", { maxResults: 10 }); await adapter.search("agent", { maxResults: 10 }); await adapter.search("config", { maxResults: 10 }); await adapter.search(""); const statAfter = existsSync(DB_PATH) ? readFileSync(DB_PATH, null).length : -1; assert.equal(statAfter, statBefore, `SQLite file size changed: ${statBefore} → ${statAfter}`); }); // ─── Test 8 ────────────────────────────────────────── it("does not modify MEMORY.md", async () => { const before = readFileSync(join(ROOT, "MEMORY.md"), "utf8"); // Run queries await adapter.search("MEMORY.md", { maxResults: 5 }); await adapter.search("memory system", { maxResults: 5 }); const after = readFileSync(join(ROOT, "MEMORY.md"), "utf8"); assert.equal(after, before, "MEMORY.md must not be modified"); }); // ─── Test 9 ────────────────────────────────────────── it("multiple queries are consistent", async () => { const r1 = await adapter.search("architecture", { maxResults: 5 }); const r2 = await adapter.search("architecture", { maxResults: 5 }); assert.equal(r1.ok, r2.ok); assert.equal(r1.source, r2.source); assert.equal(r1.results.length, r2.results.length, "same query should return same count"); if (r1.results.length > 0 && r2.results.length > 0) { assert.equal(r1.results[0].id, r2.results[0].id, "same query should return same top result"); } }); // ─── Test 10 ───────────────────────────────────────── it("production check still passes", { timeout: 180000 }, () => { const result = spawnSync("bash", [join(ROOT, "scripts", "production-check.sh")], { cwd: ROOT, encoding: "utf8", timeout: 170000, maxBuffer: 2 * 1024 * 1024, }); assert.equal(result.status, 0, `Production check failed (exit ${result.status})`); }); });