diff --git a/package.json b/package.json index 2bfc696..a2970da 100644 --- a/package.json +++ b/package.json @@ -20,7 +20,7 @@ } }, "scripts": { - "build": "bunx tsc && mkdir -p dist/web && cp -r src/web/* dist/web/", + "build": "bunx tsc && bun scripts/copy-web-assets.mjs", "dev": "tsc --watch", "typecheck": "tsc --noEmit", "format": "prettier --write \"src/**/*.{ts,js,css,html}\"", diff --git a/scripts/copy-web-assets.mjs b/scripts/copy-web-assets.mjs new file mode 100644 index 0000000..3b7948b --- /dev/null +++ b/scripts/copy-web-assets.mjs @@ -0,0 +1,4 @@ +import { cpSync, mkdirSync } from "node:fs"; + +mkdirSync("dist/web", { recursive: true }); +cpSync("src/web", "dist/web", { recursive: true }); diff --git a/tests/config-resolution.test.ts b/tests/config-resolution.test.ts index 5a5f74c..b944685 100644 --- a/tests/config-resolution.test.ts +++ b/tests/config-resolution.test.ts @@ -6,6 +6,8 @@ describe("project-scoped config resolution", () => { let readSpy: ReturnType; let existsSpy: ReturnType; + const normalizePath = (p: unknown) => String(p).replace(/\\/g, "/"); + afterEach(() => { readSpy?.mockRestore(); existsSpy?.mockRestore(); @@ -15,7 +17,7 @@ describe("project-scoped config resolution", () => { it("uses global config when no project config exists", () => { existsSpy = spyOn(fs, "existsSync").mockImplementation((p) => { - const path = String(p); + const path = normalizePath(p); return path.includes(".config/opencode/opencode-mem"); }); readSpy = spyOn(fs, "readFileSync").mockReturnValue( @@ -28,7 +30,7 @@ describe("project-scoped config resolution", () => { it("project config overrides global config", () => { existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { - const path = String(p); + const path = normalizePath(p); if (path.includes(".opencode/opencode-mem")) { return JSON.stringify({ opencodeProvider: "openai", @@ -48,7 +50,7 @@ describe("project-scoped config resolution", () => { it("shallow merge: project adds fields, global fields preserved when not overridden", () => { existsSpy = spyOn(fs, "existsSync").mockReturnValue(true); readSpy = spyOn(fs, "readFileSync").mockImplementation((p) => { - const path = String(p); + const path = normalizePath(p); if (path.includes(".opencode/opencode-mem")) { return JSON.stringify({ opencodeProvider: "anthropic" }) as any; } diff --git a/tests/helpers/temp-dir.mjs b/tests/helpers/temp-dir.mjs new file mode 100644 index 0000000..f56446f --- /dev/null +++ b/tests/helpers/temp-dir.mjs @@ -0,0 +1,37 @@ +import { rmSync } from "node:fs"; + +const RETRYABLE_REMOVE_CODES = new Set(["EBUSY", "ENOTEMPTY", "EPERM"]); + +function sleep(ms) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} + +function isRetryableRemoveError(error) { + return RETRYABLE_REMOVE_CODES.has(error?.code); +} + +export async function removeDirWithRetries(dir, attempts = 20) { + let lastError; + + for (let attempt = 0; attempt < attempts; attempt += 1) { + try { + rmSync(dir, { recursive: true, force: true }); + return; + } catch (error) { + lastError = error; + if (!isRetryableRemoveError(error) || attempt === attempts - 1) { + throw error; + } + await sleep(50 * (attempt + 1)); + } + } + + throw lastError; +} + +export async function removeTempDirs(tempDirs) { + while (tempDirs.length > 0) { + const dir = tempDirs.pop(); + if (dir) await removeDirWithRetries(dir); + } +} diff --git a/tests/profile-write.test.ts b/tests/profile-write.test.ts index 5acf134..1016e1b 100644 --- a/tests/profile-write.test.ts +++ b/tests/profile-write.test.ts @@ -4,10 +4,11 @@ * by testing the underlying manager directly (no live plugin context needed). */ import { describe, it, expect, beforeEach, afterEach } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { connectionManager } from "../src/services/sqlite/connection-manager.js"; +import { removeDirWithRetries } from "./helpers/temp-dir.mjs"; // We patch CONFIG.storagePath before importing the manager so the DB lands in tmp. let tmpDir: string; @@ -28,9 +29,9 @@ describe("UserProfileManager – explicit preference writes", () => { tmpDir = mkdtempSync(join(tmpdir(), "opencode-mem-test-")); }); - afterEach(() => { + afterEach(async () => { connectionManager.closeAll(); - rmSync(tmpDir, { recursive: true, force: true }); + await removeDirWithRetries(tmpDir); }); it("creates a profile with an explicit preference when none exists", async () => { diff --git a/tests/vector-backends/exact-scan-backend.test.ts b/tests/vector-backends/exact-scan-backend.test.ts index b5bf0fc..1797ec4 100644 --- a/tests/vector-backends/exact-scan-backend.test.ts +++ b/tests/vector-backends/exact-scan-backend.test.ts @@ -1,20 +1,22 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getDatabase } from "../../src/services/sqlite/sqlite-bootstrap.js"; import { ExactScanBackend } from "../../src/services/vector-backends/exact-scan-backend.js"; +import { removeTempDirs } from "../helpers/temp-dir.mjs"; const Database = getDatabase(); describe("ExactScanBackend", () => { const tempDirs: string[] = []; + const databases: Array<{ close: () => void }> = []; - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) rmSync(dir, { recursive: true, force: true }); + afterEach(async () => { + while (databases.length > 0) { + databases.pop()?.close(); } + await removeTempDirs(tempDirs); }); it("returns nearest vectors in similarity order", () => { @@ -41,6 +43,7 @@ describe("ExactScanBackend", () => { const tempDir = mkdtempSync(join(tmpdir(), "exact-scan-backend-")); tempDirs.push(tempDir); const db = new Database(join(tempDir, "test.db")); + databases.push(db); db.run(`CREATE TABLE memories (id TEXT PRIMARY KEY, vector BLOB, tags_vector BLOB)`); @@ -76,6 +79,7 @@ describe("ExactScanBackend", () => { const tempDir = mkdtempSync(join(tmpdir(), "exact-scan-backend-empty-")); tempDirs.push(tempDir); const db = new Database(join(tempDir, "test.db")); + databases.push(db); db.run(`CREATE TABLE memories (id TEXT PRIMARY KEY, vector BLOB, tags_vector BLOB)`); const backend = new ExactScanBackend(); diff --git a/tests/vector-backends/migration-fallback.test.ts b/tests/vector-backends/migration-fallback.test.ts index 8f25d40..d3c56cc 100644 --- a/tests/vector-backends/migration-fallback.test.ts +++ b/tests/vector-backends/migration-fallback.test.ts @@ -1,21 +1,25 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getDatabase } from "../../src/services/sqlite/sqlite-bootstrap.js"; import { ExactScanBackend } from "../../src/services/vector-backends/exact-scan-backend.js"; import { VectorSearch } from "../../src/services/sqlite/vector-search.js"; +import { removeTempDirs } from "../helpers/temp-dir.mjs"; +import { connectionManager } from "../../src/services/sqlite/connection-manager.js"; const Database = getDatabase(); describe("migration with backend abstraction", () => { const tempDirs: string[] = []; + const databases: Array<{ close: () => void }> = []; - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) rmSync(dir, { recursive: true, force: true }); + afterEach(async () => { + connectionManager.closeAll(); + while (databases.length > 0) { + databases.pop()?.close(); } + await removeTempDirs(tempDirs); }); it("rebuilds and searches memories without direct hnsw manager calls", async () => { @@ -23,6 +27,7 @@ describe("migration with backend abstraction", () => { tempDirs.push(tempDir); const dbPath = join(tempDir, "test.db"); const db = new Database(dbPath); + databases.push(db); db.run(` CREATE TABLE memories ( id TEXT PRIMARY KEY, diff --git a/tests/vector-backends/usearch-backend.test.ts b/tests/vector-backends/usearch-backend.test.ts index 0c65e36..a39105a 100644 --- a/tests/vector-backends/usearch-backend.test.ts +++ b/tests/vector-backends/usearch-backend.test.ts @@ -1,23 +1,27 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { getDatabase } from "../../src/services/sqlite/sqlite-bootstrap.js"; import { USearchBackend } from "../../src/services/vector-backends/usearch-backend.js"; +import { removeTempDirs } from "../helpers/temp-dir.mjs"; const Database = getDatabase(); +const canLoadUSearch = await import("usearch").then(() => true).catch(() => false); +const itIfUSearchAvailable = canLoadUSearch ? it : it.skip; describe("USearchBackend", () => { const tempDirs: string[] = []; + const databases: Array<{ close: () => void }> = []; - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) rmSync(dir, { recursive: true, force: true }); + afterEach(async () => { + while (databases.length > 0) { + databases.pop()?.close(); } + await removeTempDirs(tempDirs); }); - it("creates and searches an in-memory index", async () => { + itIfUSearchAvailable("creates and searches an in-memory index", async () => { const baseDir = mkdtempSync(join(tmpdir(), "usearch-backend-")); tempDirs.push(baseDir); @@ -38,7 +42,7 @@ describe("USearchBackend", () => { expect(result.map((x) => x.id)).toEqual(["a", "c"]); }); - it("supports public insert and search path", async () => { + itIfUSearchAvailable("supports public insert and search path", async () => { const baseDir = mkdtempSync(join(tmpdir(), "usearch-backend-public-")); tempDirs.push(baseDir); @@ -72,50 +76,54 @@ describe("USearchBackend", () => { expect(result.map((x) => x.id)).toEqual(["alpha"]); }); - it("updates an existing id instead of failing on duplicate insert", async () => { - const baseDir = mkdtempSync(join(tmpdir(), "usearch-backend-upsert-")); - tempDirs.push(baseDir); - - const shard = { - id: 1, - scope: "project" as const, - scopeHash: "hash", - shardIndex: 0, - dbPath: join(baseDir, "test.db"), - vectorCount: 1, - isActive: true, - createdAt: Date.now(), - }; - - const backend = new USearchBackend({ baseDir, dimensions: 4 }); - await backend.insert({ - id: "alpha", - vector: new Float32Array([0, 1, 0, 0]), - shard, - kind: "content", - }); - await backend.insert({ - id: "alpha", - vector: new Float32Array([1, 0, 0, 0]), - shard, - kind: "content", - }); - - const result = await backend.search({ - db: null, - shard, - kind: "content", - queryVector: new Float32Array([1, 0, 0, 0]), - limit: 1, - }); - - expect(result.map((x) => x.id)).toEqual(["alpha"]); - }); + itIfUSearchAvailable( + "updates an existing id instead of failing on duplicate insert", + async () => { + const baseDir = mkdtempSync(join(tmpdir(), "usearch-backend-upsert-")); + tempDirs.push(baseDir); + + const shard = { + id: 1, + scope: "project" as const, + scopeHash: "hash", + shardIndex: 0, + dbPath: join(baseDir, "test.db"), + vectorCount: 1, + isActive: true, + createdAt: Date.now(), + }; + + const backend = new USearchBackend({ baseDir, dimensions: 4 }); + await backend.insert({ + id: "alpha", + vector: new Float32Array([0, 1, 0, 0]), + shard, + kind: "content", + }); + await backend.insert({ + id: "alpha", + vector: new Float32Array([1, 0, 0, 0]), + shard, + kind: "content", + }); + + const result = await backend.search({ + db: null, + shard, + kind: "content", + queryVector: new Float32Array([1, 0, 0, 0]), + limit: 1, + }); + + expect(result.map((x) => x.id)).toEqual(["alpha"]); + } + ); - it("rebuilds an index from sqlite rows", async () => { + itIfUSearchAvailable("rebuilds an index from sqlite rows", async () => { const baseDir = mkdtempSync(join(tmpdir(), "usearch-backend-rebuild-")); tempDirs.push(baseDir); const db = new Database(join(baseDir, "test.db")); + databases.push(db); db.run(`CREATE TABLE memories (id TEXT PRIMARY KEY, vector BLOB, tags_vector BLOB)`); db.prepare(`INSERT INTO memories (id, vector, tags_vector) VALUES (?, ?, ?)`).run( "alpha", diff --git a/tests/vector-search-backend-integration.test.ts b/tests/vector-search-backend-integration.test.ts index f388348..9557568 100644 --- a/tests/vector-search-backend-integration.test.ts +++ b/tests/vector-search-backend-integration.test.ts @@ -1,11 +1,13 @@ import { afterEach, describe, expect, it } from "bun:test"; -import { mkdtempSync, rmSync } from "node:fs"; +import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { ExactScanBackend } from "../src/services/vector-backends/exact-scan-backend.js"; import { getDatabase } from "../src/services/sqlite/sqlite-bootstrap.js"; import { VectorSearch } from "../src/services/sqlite/vector-search.js"; import type { VectorBackend } from "../src/services/vector-backends/types.js"; +import { removeTempDirs } from "./helpers/temp-dir.mjs"; +import { connectionManager } from "../src/services/sqlite/connection-manager.js"; const Database = getDatabase(); @@ -27,12 +29,14 @@ function createFailingBackend(): VectorBackend { describe("vector search backend integration", () => { const tempDirs: string[] = []; + const databases: Array<{ close: () => void }> = []; - afterEach(() => { - while (tempDirs.length > 0) { - const dir = tempDirs.pop(); - if (dir) rmSync(dir, { recursive: true, force: true }); + afterEach(async () => { + connectionManager.closeAll(); + while (databases.length > 0) { + databases.pop()?.close(); } + await removeTempDirs(tempDirs); }); it("searches inserted memories and preserves ranking semantics", async () => { @@ -40,6 +44,7 @@ describe("vector search backend integration", () => { tempDirs.push(tempDir); const dbPath = join(tempDir, "test.db"); const db = new Database(dbPath); + databases.push(db); db.run(` CREATE TABLE memories ( @@ -124,6 +129,7 @@ describe("vector search backend integration", () => { tempDirs.push(tempDir); const dbPath = join(tempDir, "test.db"); const db = new Database(dbPath); + databases.push(db); db.run(` CREATE TABLE memories (