Files
2026-06-06 10:40:48 +08:00

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})`);
});
});