| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262 |
- import Database from "better-sqlite3"
- import {
- afterAll,
- beforeAll,
- beforeEach,
- describe,
- expect,
- it,
- vi,
- } from "vitest"
- // Mock @enesis/editor — its dependency chain pulls in Vue files that vitest
- // can't parse without a Vue plugin.
- vi.mock("@enesis/editor", () => ({
- splitMarkdownIntoBlocks: (content: string) =>
- content
- .split("\n")
- .filter(Boolean)
- .map((line, i) => ({
- id: `test-block-${i}`,
- content: line,
- depth: 0,
- })),
- }))
- // Mock @tauri-apps/plugin-sql — wrap better-sqlite3 so the indexer runs
- // against a real in-memory SQLite database during tests.
- //
- // The real plugin uses $1, $2 positional placeholders, but better-sqlite3
- // uses ?. We translate $N → ? before preparing statements.
- vi.mock("@tauri-apps/plugin-sql", () => {
- function translate(sql: string): string {
- return sql.replace(/\$\d+/g, "?")
- }
- class MockDatabase {
- private db: Database.Database
- private constructor(db: Database.Database) {
- this.db = db
- }
- static async load(_connectionString: string): Promise<MockDatabase> {
- const { default: BS3 } = await import("better-sqlite3")
- return new MockDatabase(new BS3(":memory:"))
- }
- async execute(
- sql: string,
- params?: unknown[],
- ): Promise<{ rowsAffected: number }> {
- if (params) {
- const stmt = this.db.prepare(translate(sql))
- const result = stmt.run(...params)
- return { rowsAffected: result.changes }
- }
- const result = this.db.exec(sql)
- return { rowsAffected: result.changes ?? 0 }
- }
- async select<T>(sql: string, params?: unknown[]): Promise<T[]> {
- const stmt = this.db.prepare(translate(sql))
- return (params ? stmt.all(...params) : stmt.all()) as T[]
- }
- }
- return { default: MockDatabase }
- })
- // Now it's safe to import the real indexer
- import { fullReindex, initIndex, syncIndex } from "./indexer"
- let db: Awaited<ReturnType<typeof initIndex>>
- beforeAll(async () => {
- db = await initIndex(":memory:")
- })
- afterAll(() => {
- vi.restoreAllMocks()
- })
- function opts(files: Record<string, string>, workspacePath = "/ws") {
- return {
- listDirectory: vi.fn(async () =>
- Object.keys(files).map((f) => `${workspacePath}/${f}`),
- ),
- readFile: vi.fn(async (path: string) => {
- const relative = path.replace(`${workspacePath}/`, "")
- if (!(relative in files)) throw new Error(`ENOENT: ${path}`)
- return files[relative]
- }),
- workspacePath,
- }
- }
- describe("syncIndex", () => {
- // Each test starts with a clean database so state doesn't leak between them
- beforeEach(async () => {
- await db.execute("DELETE FROM pages")
- })
- it("indexes new files", async () => {
- const o = opts({ "pages/New.md": "# Hello World" })
- await syncIndex(o, db)
- const pages = await db.select<{ path: string; title: string }[]>(
- "SELECT path, title FROM pages",
- )
- expect(pages).toEqual([{ path: "pages/New.md", title: "Hello World" }])
- })
- it("skips unchanged files", async () => {
- const o = opts({ "pages/Skip.md": "same content" })
- await syncIndex(o, db)
- await syncIndex(o, db)
- const pageRows = await db.select<{ path: string }[]>(
- "SELECT path FROM pages",
- )
- expect(pageRows).toHaveLength(1)
- expect(pageRows[0].path).toBe("pages/Skip.md")
- })
- it("re-indexes changed files", async () => {
- const o = opts({ "pages/Changed.md": "version 1" })
- await syncIndex(o, db)
- o.readFile = vi.fn(async () => "version 2")
- await syncIndex(o, db)
- const blocks = await db.select<{ content: string }[]>(
- "SELECT content FROM blocks",
- )
- expect(blocks).toEqual([{ content: "version 2" }])
- })
- it("removes files deleted from disk", async () => {
- const o = opts({
- "pages/Keep.md": "keep me",
- "pages/Remove.md": "remove me",
- })
- await syncIndex(o, db)
- const updated = opts({ "pages/Keep.md": "keep me" })
- await syncIndex(updated, db)
- const pages = await db.select<{ path: string }[]>(
- "SELECT path FROM pages ORDER BY path",
- )
- expect(pages).toEqual([{ path: "pages/Keep.md" }])
- })
- it("ignores non-.md files", async () => {
- const o = opts({
- "page.txt": "not markdown",
- "readme.md": "# Actual doc",
- })
- await syncIndex(o, db)
- const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
- expect(pages).toEqual([{ path: "readme.md" }])
- })
- it("continues on read error", async () => {
- const o = opts({ "pages/Good.md": "fine" })
- vi.mocked(o.listDirectory).mockResolvedValue([
- "/ws/pages/Bad.md",
- "/ws/pages/Good.md",
- ])
- vi.mocked(o.readFile).mockImplementation(async (path: string) => {
- if (path.includes("Bad.md")) throw new Error("read error")
- return "fine"
- })
- await syncIndex(o, db)
- const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
- expect(pages).toEqual([{ path: "pages/Good.md" }])
- })
- })
- describe("fullReindex", () => {
- it("wipes and rebuilds all pages", async () => {
- await db.execute(
- "INSERT INTO pages (path, title, modified_at, word_count) VALUES ($1, $2, $3, $4)",
- ["pages/Stale.md", "Stale", 1000, 2],
- )
- const o = opts({ "pages/Fresh.md": "# Fresh Start" })
- await fullReindex(o, db)
- const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
- expect(pages).toEqual([{ path: "pages/Fresh.md" }])
- })
- it("continues on read error", async () => {
- const o = opts({ "pages/Good.md": "fine" })
- vi.mocked(o.listDirectory).mockResolvedValue([
- "/ws/pages/Bad.md",
- "/ws/pages/Good.md",
- ])
- vi.mocked(o.readFile).mockImplementation(async (path: string) => {
- if (path.includes("Bad.md")) throw new Error("read error")
- return "fine"
- })
- await fullReindex(o, db)
- const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
- expect(pages).toEqual([{ path: "pages/Good.md" }])
- })
- })
- describe("migrateContentHash via initIndex", () => {
- // This is the one integration-style test in the suite: it creates a real DB
- // file on disk with the old schema, opens it through initIndex (which runs
- // migrateContentHash), and verifies the column was added. Writing a temp
- // file is unavoidable because initIndex opens the DB via a path string.
- // If the test process is killed mid-run, the temp dir leaks — acceptable
- // for a dev-only test.
- it("adds content_hash column to existing old-schema database", async () => {
- // Build the old schema manually (without content_hash)
- const raw = new Database(":memory:")
- raw.exec(`
- CREATE TABLE pages (
- path TEXT PRIMARY KEY,
- title TEXT NOT NULL DEFAULT '',
- modified_at INTEGER NOT NULL,
- word_count INTEGER NOT NULL DEFAULT 0
- );
- `)
- // Verify old schema has no content_hash
- const before = raw
- .prepare(
- "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
- )
- .all()
- expect(before).toHaveLength(0)
- // Serialize to a temp file and open through initIndex
- const { writeFileSync, mkdtempSync } = await import("node:fs")
- const { join } = await import("node:path")
- const { tmpdir } = await import("node:os")
- const dir = mkdtempSync(join(tmpdir(), "indexer-test-"))
- const dbPath = join(dir, "index.db")
- writeFileSync(dbPath, raw.serialize())
- const migrated = await initIndex(dbPath)
- const after = await migrated.select<{ name: string }[]>(
- "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
- )
- expect(after).toHaveLength(1)
- expect(after[0].name).toBe("content_hash")
- // Cleanup
- const { rmSync } = await import("node:fs")
- rmSync(dir, { recursive: true })
- })
- })
|