| 1 | import { |
| 2 | FileFinder, |
| 3 | type FileItem, |
| 4 | type GrepCursor, |
| 5 | type GrepOptions, |
| 6 | type GrepResult, |
| 7 | } from "@ff-labs/fff-node"; |
| 8 | import { afterEach, expect, it } from "@effect/vitest"; |
| 9 | import * as Cause from "effect/Cause"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Exit from "effect/Exit"; |
| 12 | import { vi } from "vite-plus/test"; |
| 13 | |
| 14 | import * as WorkspaceSearchIndex from "./WorkspaceSearchIndex.ts"; |
| 15 | |
| 16 | afterEach(() => { |
| 17 | vi.restoreAllMocks(); |
| 18 | }); |
| 19 | |
| 20 | function fileItem(relativePath: string): FileItem { |
| 21 | return { |
| 22 | relativePath, |
| 23 | fileName: relativePath.slice(relativePath.lastIndexOf("/") + 1), |
| 24 | size: 1, |
| 25 | modified: 0, |
| 26 | accessFrecencyScore: 0, |
| 27 | modificationFrecencyScore: 0, |
| 28 | totalFrecencyScore: 0, |
| 29 | gitStatus: "clean", |
| 30 | }; |
| 31 | } |
| 32 | |
| 33 | it.effect("filters image searches before applying the result limit", () => |
| 34 | Effect.scoped( |
| 35 | Effect.gen(function* () { |
| 36 | const items = [ |
| 37 | ...Array.from({ length: 200 }, (_, index) => fileItem(`src/file-${index}.ts`)), |
| 38 | fileItem("public/icon.svg"), |
| 39 | ]; |
| 40 | const fileSearch = vi.fn(() => ({ |
| 41 | ok: true as const, |
| 42 | value: { |
| 43 | items, |
| 44 | scores: [], |
| 45 | totalMatched: items.length, |
| 46 | totalFiles: items.length, |
| 47 | }, |
| 48 | })); |
| 49 | const finder = { |
| 50 | destroy: vi.fn(), |
| 51 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 52 | fileSearch, |
| 53 | } as unknown as FileFinder; |
| 54 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 55 | |
| 56 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); |
| 57 | const resultWithoutKind = yield* searchIndex.search("", 200, undefined, true); |
| 58 | const resultWithDirectoryKind = yield* searchIndex.search("", 200, "directory", true); |
| 59 | |
| 60 | expect(resultWithoutKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); |
| 61 | expect(resultWithDirectoryKind.entries).toEqual([{ kind: "file", path: "public/icon.svg" }]); |
| 62 | expect(fileSearch).toHaveBeenCalledTimes(2); |
| 63 | expect(fileSearch).toHaveBeenCalledWith("", { pageSize: 25_002 }); |
| 64 | }), |
| 65 | ), |
| 66 | ); |
| 67 | |
| 68 | it.effect("preserves unexpected FileFinder creation failures", () => |
| 69 | Effect.gen(function* () { |
| 70 | const cause = new Error("native initialization failed"); |
| 71 | vi.spyOn(FileFinder, "create").mockImplementationOnce(() => { |
| 72 | throw cause; |
| 73 | }); |
| 74 | |
| 75 | const error = yield* Effect.flip( |
| 76 | Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), |
| 77 | ); |
| 78 | |
| 79 | expect(error).toMatchObject({ |
| 80 | _tag: "WorkspaceSearchIndexCreateFailed", |
| 81 | cwd: "/workspace/project", |
| 82 | reason: "FileFinder.create threw unexpectedly.", |
| 83 | cause, |
| 84 | }); |
| 85 | }), |
| 86 | ); |
| 87 | |
| 88 | it.effect("keeps returned FileFinder creation diagnostics out of the cause chain", () => |
| 89 | Effect.gen(function* () { |
| 90 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ |
| 91 | ok: false, |
| 92 | error: "native index rejected the directory", |
| 93 | }); |
| 94 | |
| 95 | const error = yield* Effect.flip( |
| 96 | Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")), |
| 97 | ); |
| 98 | |
| 99 | expect(error).toMatchObject({ |
| 100 | _tag: "WorkspaceSearchIndexCreateFailed", |
| 101 | cwd: "/workspace/project", |
| 102 | reason: "native index rejected the directory", |
| 103 | }); |
| 104 | expect(error.cause).toBeUndefined(); |
| 105 | }), |
| 106 | ); |
| 107 | |
| 108 | it.effect("waits for the full content index warmup before returning", () => |
| 109 | Effect.gen(function* () { |
| 110 | const waitForIndexReady = vi.fn(async () => ({ ok: true as const, value: true })); |
| 111 | const finder = { |
| 112 | destroy: vi.fn(), |
| 113 | waitForIndexReady, |
| 114 | } as unknown as FileFinder; |
| 115 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 116 | |
| 117 | yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")); |
| 118 | |
| 119 | expect(waitForIndexReady).toHaveBeenCalledWith(15_000); |
| 120 | }), |
| 121 | ); |
| 122 | |
| 123 | it.effect("preserves a full-index warmup timeout as a structured error", () => |
| 124 | Effect.gen(function* () { |
| 125 | const finder = { |
| 126 | destroy: vi.fn(), |
| 127 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), |
| 128 | } as unknown as FileFinder; |
| 129 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 130 | |
| 131 | const error = yield* Effect.flip( |
| 132 | Effect.scoped(WorkspaceSearchIndex.make("/workspace/project", "content")), |
| 133 | ); |
| 134 | |
| 135 | expect(error).toMatchObject({ |
| 136 | _tag: "WorkspaceSearchIndexScanTimedOut", |
| 137 | cwd: "/workspace/project", |
| 138 | timeout: "15 seconds", |
| 139 | }); |
| 140 | }), |
| 141 | ); |
| 142 | |
| 143 | it.effect("returns partial path results when the initial scan times out", () => |
| 144 | Effect.scoped( |
| 145 | Effect.gen(function* () { |
| 146 | const mixedSearch = vi.fn(() => ({ |
| 147 | ok: true as const, |
| 148 | value: { |
| 149 | items: [ |
| 150 | { |
| 151 | type: "directory" as const, |
| 152 | item: { relativePath: "src", fileName: "src", fileCount: 1 }, |
| 153 | }, |
| 154 | { type: "file" as const, item: fileItem("src/index.ts") }, |
| 155 | ], |
| 156 | scores: [], |
| 157 | totalMatched: 2, |
| 158 | totalFiles: 1, |
| 159 | }, |
| 160 | })); |
| 161 | let scanning = true; |
| 162 | const finder = { |
| 163 | destroy: vi.fn(), |
| 164 | isScanning: vi.fn(() => scanning), |
| 165 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: false })), |
| 166 | mixedSearch, |
| 167 | } as unknown as FileFinder; |
| 168 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 169 | |
| 170 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "paths"); |
| 171 | const list = yield* searchIndex.list(); |
| 172 | const search = yield* searchIndex.search("src", 10); |
| 173 | scanning = true; |
| 174 | mixedSearch.mockImplementationOnce(() => { |
| 175 | scanning = false; |
| 176 | return { |
| 177 | ok: true as const, |
| 178 | value: { |
| 179 | items: [ |
| 180 | { |
| 181 | type: "directory" as const, |
| 182 | item: { relativePath: "src", fileName: "src", fileCount: 1 }, |
| 183 | }, |
| 184 | { type: "file" as const, item: fileItem("src/index.ts") }, |
| 185 | ], |
| 186 | scores: [], |
| 187 | totalMatched: 2, |
| 188 | totalFiles: 1, |
| 189 | }, |
| 190 | }; |
| 191 | }); |
| 192 | const searchCompletingDuringQuery = yield* searchIndex.search("src", 10); |
| 193 | scanning = false; |
| 194 | const completedSearch = yield* searchIndex.search("src", 10); |
| 195 | |
| 196 | expect(list).toEqual({ |
| 197 | entries: [ |
| 198 | { kind: "directory", path: "src" }, |
| 199 | { kind: "file", path: "src/index.ts" }, |
| 200 | ], |
| 201 | truncated: true, |
| 202 | }); |
| 203 | expect(search.truncated).toBe(true); |
| 204 | expect(searchCompletingDuringQuery.truncated).toBe(true); |
| 205 | expect(completedSearch.truncated).toBe(false); |
| 206 | expect(mixedSearch).toHaveBeenCalledTimes(4); |
| 207 | }), |
| 208 | ), |
| 209 | ); |
| 210 | |
| 211 | it.effect("preserves FileFinder destroy failures as structured defects", () => |
| 212 | Effect.gen(function* () { |
| 213 | const cause = new Error("native destroy failed"); |
| 214 | const finder = { |
| 215 | destroy: vi.fn(() => { |
| 216 | throw cause; |
| 217 | }), |
| 218 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 219 | } as unknown as FileFinder; |
| 220 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 221 | |
| 222 | const exit = yield* Effect.scoped(WorkspaceSearchIndex.make("/workspace/project")).pipe( |
| 223 | Effect.exit, |
| 224 | ); |
| 225 | |
| 226 | expect(Exit.isFailure(exit)).toBe(true); |
| 227 | if (Exit.isFailure(exit)) { |
| 228 | expect(Cause.hasDies(exit.cause)).toBe(true); |
| 229 | const error = Cause.squash(exit.cause); |
| 230 | expect(error).toBeInstanceOf(WorkspaceSearchIndex.WorkspaceSearchIndexDestroyFailed); |
| 231 | expect(error).toMatchObject({ |
| 232 | _tag: "WorkspaceSearchIndexDestroyFailed", |
| 233 | cwd: "/workspace/project", |
| 234 | cause, |
| 235 | }); |
| 236 | } |
| 237 | }), |
| 238 | ); |
| 239 | |
| 240 | it.effect("preserves search and refresh failures with operation context", () => |
| 241 | Effect.scoped( |
| 242 | Effect.gen(function* () { |
| 243 | const searchCause = new Error("native search failed"); |
| 244 | const refreshCause = new Error("native scan failed"); |
| 245 | const contentSearchCause = new Error("native grep failed"); |
| 246 | const finder = { |
| 247 | destroy: vi.fn(), |
| 248 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 249 | mixedSearch: vi.fn(() => { |
| 250 | throw searchCause; |
| 251 | }), |
| 252 | grep: vi.fn(() => { |
| 253 | throw contentSearchCause; |
| 254 | }), |
| 255 | scanFiles: vi.fn(() => { |
| 256 | throw refreshCause; |
| 257 | }), |
| 258 | } as unknown as FileFinder; |
| 259 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 260 | |
| 261 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); |
| 262 | const query = "authorization: Bearer secret-token"; |
| 263 | const searchError = yield* Effect.flip(searchIndex.search(query, 3)); |
| 264 | const contentSearchError = yield* Effect.flip( |
| 265 | searchIndex.searchContents({ |
| 266 | query, |
| 267 | limit: 3, |
| 268 | caseSensitive: false, |
| 269 | wholeWord: false, |
| 270 | useRegex: false, |
| 271 | }), |
| 272 | ); |
| 273 | const refreshError = yield* Effect.flip(searchIndex.refresh()); |
| 274 | |
| 275 | expect(searchError).toMatchObject({ |
| 276 | _tag: "WorkspaceSearchIndexSearchFailed", |
| 277 | cwd: "/workspace/project", |
| 278 | queryLength: query.length, |
| 279 | pageSize: 4, |
| 280 | reason: "FileFinder.mixedSearch threw unexpectedly.", |
| 281 | cause: searchCause, |
| 282 | }); |
| 283 | expect(searchError).not.toHaveProperty("query"); |
| 284 | expect(searchError.message).not.toMatch(/Bearer|secret-token/); |
| 285 | expect(contentSearchError).toMatchObject({ |
| 286 | _tag: "WorkspaceSearchIndexSearchFailed", |
| 287 | cwd: "/workspace/project", |
| 288 | queryLength: query.length, |
| 289 | pageSize: 3, |
| 290 | reason: "FileFinder.grep threw unexpectedly.", |
| 291 | cause: contentSearchCause, |
| 292 | }); |
| 293 | expect(contentSearchError).not.toHaveProperty("query"); |
| 294 | expect(contentSearchError.message).not.toMatch(/Bearer|secret-token/); |
| 295 | expect(refreshError).toMatchObject({ |
| 296 | _tag: "WorkspaceSearchIndexRefreshFailed", |
| 297 | cwd: "/workspace/project", |
| 298 | reason: "FileFinder.scanFiles threw unexpectedly.", |
| 299 | cause: refreshCause, |
| 300 | }); |
| 301 | }), |
| 302 | ), |
| 303 | ); |
| 304 | |
| 305 | it.effect("keeps returned search diagnostics out of the cause chain", () => |
| 306 | Effect.scoped( |
| 307 | Effect.gen(function* () { |
| 308 | const finder = { |
| 309 | destroy: vi.fn(), |
| 310 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 311 | mixedSearch: vi.fn(() => ({ ok: false, error: "native query rejected" })), |
| 312 | scanFiles: vi.fn(() => ({ ok: false, error: "native refresh rejected" })), |
| 313 | } as unknown as FileFinder; |
| 314 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 315 | |
| 316 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project"); |
| 317 | const query = "authorization: Bearer secret-token"; |
| 318 | const searchError = yield* Effect.flip(searchIndex.search(query, 3)); |
| 319 | const refreshError = yield* Effect.flip(searchIndex.refresh()); |
| 320 | |
| 321 | expect(searchError).toMatchObject({ |
| 322 | _tag: "WorkspaceSearchIndexSearchFailed", |
| 323 | cwd: "/workspace/project", |
| 324 | queryLength: query.length, |
| 325 | pageSize: 4, |
| 326 | reason: "native query rejected", |
| 327 | }); |
| 328 | expect(searchError).not.toHaveProperty("query"); |
| 329 | expect(searchError.message).not.toMatch(/Bearer|secret-token/); |
| 330 | expect(searchError.cause).toBeUndefined(); |
| 331 | expect(refreshError).toMatchObject({ |
| 332 | _tag: "WorkspaceSearchIndexRefreshFailed", |
| 333 | cwd: "/workspace/project", |
| 334 | reason: "native refresh rejected", |
| 335 | }); |
| 336 | expect(refreshError.cause).toBeUndefined(); |
| 337 | }), |
| 338 | ), |
| 339 | ); |
| 340 | |
| 341 | it.effect("continues whole-word searches after a filtered grep page", () => |
| 342 | Effect.scoped( |
| 343 | Effect.gen(function* () { |
| 344 | const nextCursor = { |
| 345 | __brand: "GrepCursor", |
| 346 | _offset: 1, |
| 347 | } as GrepCursor; |
| 348 | const grepResult = ( |
| 349 | lineContent: string, |
| 350 | matchRanges: Array<[number, number]>, |
| 351 | cursor: GrepCursor | null, |
| 352 | ): GrepResult => ({ |
| 353 | items: [ |
| 354 | { |
| 355 | relativePath: "src/words.ts", |
| 356 | fileName: "words.ts", |
| 357 | gitStatus: "unmodified", |
| 358 | size: lineContent.length, |
| 359 | modified: 0, |
| 360 | isBinary: false, |
| 361 | totalFrecencyScore: 0, |
| 362 | accessFrecencyScore: 0, |
| 363 | modificationFrecencyScore: 0, |
| 364 | lineNumber: 1, |
| 365 | col: 0, |
| 366 | byteOffset: 0, |
| 367 | lineContent, |
| 368 | matchRanges, |
| 369 | }, |
| 370 | ], |
| 371 | totalMatched: 1, |
| 372 | totalFilesSearched: 1, |
| 373 | totalFiles: 1, |
| 374 | filteredFileCount: 1, |
| 375 | nextCursor: cursor, |
| 376 | }); |
| 377 | const grep = vi.fn((_query: string, options?: GrepOptions) => |
| 378 | options?.cursor |
| 379 | ? { ok: true as const, value: grepResult("needle", [[0, 6]], null) } |
| 380 | : { |
| 381 | ok: true as const, |
| 382 | value: grepResult("needleSuffix", [[0, 6]], nextCursor), |
| 383 | }, |
| 384 | ); |
| 385 | const finder = { |
| 386 | destroy: vi.fn(), |
| 387 | waitForIndexReady: vi.fn(async () => ({ ok: true as const, value: true })), |
| 388 | grep, |
| 389 | } as unknown as FileFinder; |
| 390 | vi.spyOn(FileFinder, "create").mockReturnValueOnce({ ok: true, value: finder }); |
| 391 | |
| 392 | const searchIndex = yield* WorkspaceSearchIndex.make("/workspace/project", "content"); |
| 393 | const result = yield* searchIndex.searchContents({ |
| 394 | query: "needle", |
| 395 | limit: 1, |
| 396 | caseSensitive: true, |
| 397 | wholeWord: true, |
| 398 | useRegex: false, |
| 399 | }); |
| 400 | |
| 401 | expect(result).toEqual({ |
| 402 | matches: [ |
| 403 | { |
| 404 | path: "src/words.ts", |
| 405 | lineNumber: 1, |
| 406 | lineContent: "needle", |
| 407 | matchRanges: [{ start: 0, end: 6 }], |
| 408 | }, |
| 409 | ], |
| 410 | truncated: false, |
| 411 | }); |
| 412 | expect(grep).toHaveBeenCalledTimes(2); |
| 413 | expect(grep.mock.calls[1]?.[1]?.cursor).toBe(nextCursor); |
| 414 | }), |
| 415 | ), |
| 416 | ); |