indexer.test.ts 7.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262
  1. import Database from "better-sqlite3"
  2. import {
  3. afterAll,
  4. beforeAll,
  5. beforeEach,
  6. describe,
  7. expect,
  8. it,
  9. vi,
  10. } from "vitest"
  11. // Mock @enesis/editor — its dependency chain pulls in Vue files that vitest
  12. // can't parse without a Vue plugin.
  13. vi.mock("@enesis/editor", () => ({
  14. splitMarkdownIntoBlocks: (content: string) =>
  15. content
  16. .split("\n")
  17. .filter(Boolean)
  18. .map((line, i) => ({
  19. id: `test-block-${i}`,
  20. content: line,
  21. depth: 0,
  22. })),
  23. }))
  24. // Mock @tauri-apps/plugin-sql — wrap better-sqlite3 so the indexer runs
  25. // against a real in-memory SQLite database during tests.
  26. //
  27. // The real plugin uses $1, $2 positional placeholders, but better-sqlite3
  28. // uses ?. We translate $N → ? before preparing statements.
  29. vi.mock("@tauri-apps/plugin-sql", () => {
  30. function translate(sql: string): string {
  31. return sql.replace(/\$\d+/g, "?")
  32. }
  33. class MockDatabase {
  34. private db: Database.Database
  35. private constructor(db: Database.Database) {
  36. this.db = db
  37. }
  38. static async load(_connectionString: string): Promise<MockDatabase> {
  39. const { default: BS3 } = await import("better-sqlite3")
  40. return new MockDatabase(new BS3(":memory:"))
  41. }
  42. async execute(
  43. sql: string,
  44. params?: unknown[],
  45. ): Promise<{ rowsAffected: number }> {
  46. if (params) {
  47. const stmt = this.db.prepare(translate(sql))
  48. const result = stmt.run(...params)
  49. return { rowsAffected: result.changes }
  50. }
  51. const result = this.db.exec(sql)
  52. return { rowsAffected: result.changes ?? 0 }
  53. }
  54. async select<T>(sql: string, params?: unknown[]): Promise<T[]> {
  55. const stmt = this.db.prepare(translate(sql))
  56. return (params ? stmt.all(...params) : stmt.all()) as T[]
  57. }
  58. }
  59. return { default: MockDatabase }
  60. })
  61. // Now it's safe to import the real indexer
  62. import { fullReindex, initIndex, syncIndex } from "./indexer"
  63. let db: Awaited<ReturnType<typeof initIndex>>
  64. beforeAll(async () => {
  65. db = await initIndex(":memory:")
  66. })
  67. afterAll(() => {
  68. vi.restoreAllMocks()
  69. })
  70. function opts(files: Record<string, string>, workspacePath = "/ws") {
  71. return {
  72. listDirectory: vi.fn(async () =>
  73. Object.keys(files).map((f) => `${workspacePath}/${f}`),
  74. ),
  75. readFile: vi.fn(async (path: string) => {
  76. const relative = path.replace(`${workspacePath}/`, "")
  77. if (!(relative in files)) throw new Error(`ENOENT: ${path}`)
  78. return files[relative]
  79. }),
  80. workspacePath,
  81. }
  82. }
  83. describe("syncIndex", () => {
  84. // Each test starts with a clean database so state doesn't leak between them
  85. beforeEach(async () => {
  86. await db.execute("DELETE FROM pages")
  87. })
  88. it("indexes new files", async () => {
  89. const o = opts({ "pages/New.md": "# Hello World" })
  90. await syncIndex(o, db)
  91. const pages = await db.select<{ path: string; title: string }[]>(
  92. "SELECT path, title FROM pages",
  93. )
  94. expect(pages).toEqual([{ path: "pages/New.md", title: "Hello World" }])
  95. })
  96. it("skips unchanged files", async () => {
  97. const o = opts({ "pages/Skip.md": "same content" })
  98. await syncIndex(o, db)
  99. await syncIndex(o, db)
  100. const pageRows = await db.select<{ path: string }[]>(
  101. "SELECT path FROM pages",
  102. )
  103. expect(pageRows).toHaveLength(1)
  104. expect(pageRows[0].path).toBe("pages/Skip.md")
  105. })
  106. it("re-indexes changed files", async () => {
  107. const o = opts({ "pages/Changed.md": "version 1" })
  108. await syncIndex(o, db)
  109. o.readFile = vi.fn(async () => "version 2")
  110. await syncIndex(o, db)
  111. const blocks = await db.select<{ content: string }[]>(
  112. "SELECT content FROM blocks",
  113. )
  114. expect(blocks).toEqual([{ content: "version 2" }])
  115. })
  116. it("removes files deleted from disk", async () => {
  117. const o = opts({
  118. "pages/Keep.md": "keep me",
  119. "pages/Remove.md": "remove me",
  120. })
  121. await syncIndex(o, db)
  122. const updated = opts({ "pages/Keep.md": "keep me" })
  123. await syncIndex(updated, db)
  124. const pages = await db.select<{ path: string }[]>(
  125. "SELECT path FROM pages ORDER BY path",
  126. )
  127. expect(pages).toEqual([{ path: "pages/Keep.md" }])
  128. })
  129. it("ignores non-.md files", async () => {
  130. const o = opts({
  131. "page.txt": "not markdown",
  132. "readme.md": "# Actual doc",
  133. })
  134. await syncIndex(o, db)
  135. const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
  136. expect(pages).toEqual([{ path: "readme.md" }])
  137. })
  138. it("continues on read error", async () => {
  139. const o = opts({ "pages/Good.md": "fine" })
  140. vi.mocked(o.listDirectory).mockResolvedValue([
  141. "/ws/pages/Bad.md",
  142. "/ws/pages/Good.md",
  143. ])
  144. vi.mocked(o.readFile).mockImplementation(async (path: string) => {
  145. if (path.includes("Bad.md")) throw new Error("read error")
  146. return "fine"
  147. })
  148. await syncIndex(o, db)
  149. const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
  150. expect(pages).toEqual([{ path: "pages/Good.md" }])
  151. })
  152. })
  153. describe("fullReindex", () => {
  154. it("wipes and rebuilds all pages", async () => {
  155. await db.execute(
  156. "INSERT INTO pages (path, title, modified_at, word_count) VALUES ($1, $2, $3, $4)",
  157. ["pages/Stale.md", "Stale", 1000, 2],
  158. )
  159. const o = opts({ "pages/Fresh.md": "# Fresh Start" })
  160. await fullReindex(o, db)
  161. const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
  162. expect(pages).toEqual([{ path: "pages/Fresh.md" }])
  163. })
  164. it("continues on read error", async () => {
  165. const o = opts({ "pages/Good.md": "fine" })
  166. vi.mocked(o.listDirectory).mockResolvedValue([
  167. "/ws/pages/Bad.md",
  168. "/ws/pages/Good.md",
  169. ])
  170. vi.mocked(o.readFile).mockImplementation(async (path: string) => {
  171. if (path.includes("Bad.md")) throw new Error("read error")
  172. return "fine"
  173. })
  174. await fullReindex(o, db)
  175. const pages = await db.select<{ path: string }[]>("SELECT path FROM pages")
  176. expect(pages).toEqual([{ path: "pages/Good.md" }])
  177. })
  178. })
  179. describe("migrateContentHash via initIndex", () => {
  180. // This is the one integration-style test in the suite: it creates a real DB
  181. // file on disk with the old schema, opens it through initIndex (which runs
  182. // migrateContentHash), and verifies the column was added. Writing a temp
  183. // file is unavoidable because initIndex opens the DB via a path string.
  184. // If the test process is killed mid-run, the temp dir leaks — acceptable
  185. // for a dev-only test.
  186. it("adds content_hash column to existing old-schema database", async () => {
  187. // Build the old schema manually (without content_hash)
  188. const raw = new Database(":memory:")
  189. raw.exec(`
  190. CREATE TABLE pages (
  191. path TEXT PRIMARY KEY,
  192. title TEXT NOT NULL DEFAULT '',
  193. modified_at INTEGER NOT NULL,
  194. word_count INTEGER NOT NULL DEFAULT 0
  195. );
  196. `)
  197. // Verify old schema has no content_hash
  198. const before = raw
  199. .prepare(
  200. "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
  201. )
  202. .all()
  203. expect(before).toHaveLength(0)
  204. // Serialize to a temp file and open through initIndex
  205. const { writeFileSync, mkdtempSync } = await import("node:fs")
  206. const { join } = await import("node:path")
  207. const { tmpdir } = await import("node:os")
  208. const dir = mkdtempSync(join(tmpdir(), "indexer-test-"))
  209. const dbPath = join(dir, "index.db")
  210. writeFileSync(dbPath, raw.serialize())
  211. const migrated = await initIndex(dbPath)
  212. const after = await migrated.select<{ name: string }[]>(
  213. "SELECT name FROM pragma_table_info('pages') WHERE name = 'content_hash'",
  214. )
  215. expect(after).toHaveLength(1)
  216. expect(after[0].name).toBe("content_hash")
  217. // Cleanup
  218. const { rmSync } = await import("node:fs")
  219. rmSync(dir, { recursive: true })
  220. })
  221. })