187 lines
8.1 KiB
JavaScript
187 lines
8.1 KiB
JavaScript
/**
|
|
* 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})`);
|
|
});
|
|
});
|