Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesPull RequestsActionsSecurityInsightsSettings
✨ AI
More
Blame · Line-by-line history

semantic-index.test.ts

Each line is annotated with the commit that last touched it. Click any SHA to jump to that commit and see the surrounding change.

semantic-index.test.tsBlame530 lines · 1 contributor
a686079Claude1/**
2 * Tests for src/lib/semantic-index.ts and the /api/v2/.../semantic-search
3 * endpoint that wraps it.
4 *
5 * Layered:
6 * - Pure (no DB, no network): fallback embedder shape + determinism,
7 * `indexChangedFiles` short-circuits when there are no candidate
8 * paths, file-type filtering, and the test-only embedder seam.
9 * - DB + pgvector: full upsert → search ranking via the real
10 * `code_embeddings` table. Skipped unless DATABASE_URL is present
11 * AND the running Postgres has pgvector installed (probed lazily).
12 *
13 * The HAS_PGVECTOR probe inserts and then deletes a single throwaway
14 * row so a missing extension or table is detected at runtime.
15 */
16
17import {
18 describe,
19 it,
20 expect,
21 beforeAll,
22 afterAll,
23 beforeEach,
24} from "bun:test";
25import { join } from "path";
26import { rm, mkdir } from "fs/promises";
27import { randomBytes } from "crypto";
28import app from "../app";
29import { clearRateLimitStore } from "../middleware/rate-limit";
30import { initBareRepo, getRepoPath } from "../git/repository";
31import {
32 indexChangedFiles,
33 searchSemantic,
34 embedOne,
35 __setEmbedderForTests,
36 __test,
37 EMBEDDING_DIM,
38} from "../lib/semantic-index";
39
40const HAS_DB = Boolean(process.env.DATABASE_URL);
41
42const TEST_REPOS = join(
43 import.meta.dir,
44 "../../.test-repos-semantic-index-" + Date.now()
45);
46
47// Lazily probed — true only if DATABASE_URL is set, the table exists,
48// AND a basic UPSERT/SELECT round-trip through pgvector succeeds.
49let HAS_PGVECTOR = false;
50
51beforeAll(async () => {
52 process.env.GIT_REPOS_PATH = TEST_REPOS;
53 process.env.GLUECRON_SEMANTIC_CACHE_DIR = join(TEST_REPOS, "_cache");
54 clearRateLimitStore();
55 await rm(TEST_REPOS, { recursive: true, force: true });
56 await mkdir(TEST_REPOS, { recursive: true });
57
58 if (HAS_DB) {
59 HAS_PGVECTOR = await probePgvector();
60 }
61});
62
63afterAll(async () => {
64 __setEmbedderForTests(null);
65 await rm(TEST_REPOS, { recursive: true, force: true });
66});
67
68beforeEach(() => {
69 __setEmbedderForTests(null);
70});
71
72// ---------------------------------------------------------------------------
73// Helpers
74// ---------------------------------------------------------------------------
75
76async function run(cmd: string[], cwd: string) {
77 const proc = Bun.spawn(cmd, { cwd, stdout: "pipe", stderr: "pipe" });
78 await new Response(proc.stdout).text();
79 await proc.exited;
80}
81
82async function seedRepo(owner: string, name: string, files: Record<string, string>) {
83 await initBareRepo(owner, name);
84 const bare = getRepoPath(owner, name);
85 const work = join(TEST_REPOS, "_work_" + randomBytes(4).toString("hex"));
86 await mkdir(work, { recursive: true });
87 await run(["git", "clone", bare, work], TEST_REPOS);
88 await run(["git", "config", "user.email", "t@gluecron.com"], work);
89 await run(["git", "config", "user.name", "T"], work);
90 await run(["git", "checkout", "-B", "main"], work);
91 for (const [path, content] of Object.entries(files)) {
92 const full = join(work, path);
93 await mkdir(join(full, ".."), { recursive: true });
94 await Bun.write(full, content);
95 }
96 await run(["git", "add", "-A"], work);
97 await run(["git", "commit", "-m", "seed"], work);
98 await run(["git", "push", "-u", "origin", "main"], work);
99 // Capture the commit sha
100 const { stdout } = await Bun.spawn(["git", "rev-parse", "main"], {
101 cwd: work,
102 stdout: "pipe",
103 });
104 const sha = (await new Response(stdout).text()).trim();
105 await rm(work, { recursive: true, force: true });
106 return sha;
107}
108
109// Deterministic stub embedder used by ranking tests — encodes the
110// first 8 unique characters of the text into the first 8 dimensions
111// so semantically-similar text produces similar vectors. Pads to the
112// full embedding dim with zeros + L2-normalises.
113function makeStubEmbedder(): (
114 text: string,
115 inputType: "document" | "query"
116) => Promise<{ vector: number[]; model: string }> {
117 return async (text: string) => {
118 const v = new Array<number>(EMBEDDING_DIM).fill(0);
119 const lower = text.toLowerCase();
120 // Token-frequency over a small bag of high-signal keywords. The
121 // ranking test below relies on "fetch" / "database" being distinct.
122 const KEYS = [
123 "fetch",
124 "database",
125 "config",
126 "user",
127 "render",
128 "test",
129 "embed",
130 "route",
131 ];
132 for (let i = 0; i < KEYS.length; i++) {
133 const k = KEYS[i];
134 const matches = lower.split(k).length - 1;
135 v[i] = matches;
136 }
137 // Normalise so cosine == dot.
138 let sumsq = 0;
139 for (let i = 0; i < EMBEDDING_DIM; i++) sumsq += v[i] * v[i];
140 if (sumsq > 0) {
141 const inv = 1 / Math.sqrt(sumsq);
142 for (let i = 0; i < EMBEDDING_DIM; i++) v[i] *= inv;
143 }
144 return { vector: v, model: "stub-1024" };
145 };
146}
147
148async function probePgvector(): Promise<boolean> {
149 if (!HAS_DB) return false;
150 try {
151 const { db } = await import("../db");
152 const { codeEmbeddings } = await import("../db/schema");
153 const { eq } = await import("drizzle-orm");
154 const fakeRepoId = "00000000-0000-0000-0000-000000000000";
155 // Try a SELECT — fast probe that the table + vector column exist.
156 await db
157 .select({ id: codeEmbeddings.id })
158 .from(codeEmbeddings)
159 .where(eq(codeEmbeddings.repositoryId, fakeRepoId))
160 .limit(1);
161 return true;
162 } catch {
163 return false;
164 }
165}
166
167// ---------------------------------------------------------------------------
168// 1. Pure helpers — no DB required
169// ---------------------------------------------------------------------------
170
171describe("semantic-index — pure helpers", () => {
172 it("fallbackEmbed returns a 1024-dim vector", () => {
173 const v = __test.fallbackEmbed("hello world function getUser");
174 expect(v.length).toBe(EMBEDDING_DIM);
175 });
176
177 it("fallbackEmbed is deterministic", () => {
178 const a = __test.fallbackEmbed("function indexFiles()");
179 const b = __test.fallbackEmbed("function indexFiles()");
180 expect(a).toEqual(b);
181 });
182
183 it("fallbackEmbed produces different vectors for different inputs", () => {
184 const a = __test.fallbackEmbed("database connection pool");
185 const b = __test.fallbackEmbed("react component render hook");
186 let differ = false;
187 for (let i = 0; i < EMBEDDING_DIM; i++) {
188 if (Math.abs(a[i] - b[i]) > 1e-9) {
189 differ = true;
190 break;
191 }
192 }
193 expect(differ).toBe(true);
194 });
195
196 it("deriveBlobSha is deterministic + 64 hex chars", async () => {
197 const a = await __test.deriveBlobSha("hello world");
198 const b = await __test.deriveBlobSha("hello world");
199 expect(a).toBe(b);
200 expect(/^[0-9a-f]{64}$/.test(a)).toBe(true);
201 });
202
203 it("MAX_FILES_PER_PUSH is the documented cap (~50)", () => {
204 expect(__test.MAX_FILES_PER_PUSH).toBeGreaterThanOrEqual(20);
205 expect(__test.MAX_FILES_PER_PUSH).toBeLessThanOrEqual(200);
206 });
207});
208
209describe("embedOne — test seam", () => {
210 it("respects __setEmbedderForTests override", async () => {
211 __setEmbedderForTests(async () => ({
212 vector: new Array(EMBEDDING_DIM).fill(0.001),
213 model: "fake-model",
214 }));
215 const out = await embedOne("anything", "document");
216 expect(out.model).toBe("fake-model");
217 expect(out.vector.length).toBe(EMBEDDING_DIM);
218 expect(out.vector[0]).toBe(0.001);
219 __setEmbedderForTests(null);
220 });
221
222 it("falls back to the deterministic embedder when no key + no override", async () => {
223 delete process.env.VOYAGE_API_KEY;
224 const out = await embedOne("hello world", "document");
225 expect(out.vector.length).toBe(EMBEDDING_DIM);
226 // Fallback model name is stable.
227 expect(out.model).toBe(__test.FALLBACK_MODEL);
228 });
229});
230
231describe("indexChangedFiles — graceful no-ops", () => {
232 it("returns 0/0 for empty path list", async () => {
233 const out = await indexChangedFiles({
234 repositoryId: "00000000-0000-0000-0000-000000000000",
235 ownerName: "nobody",
236 repoName: "nothing",
237 commitSha: "0000000000000000000000000000000000000000",
238 changedPaths: [],
239 });
240 expect(out.indexed).toBe(0);
241 });
242
243 it("returns 0/0 for empty repositoryId", async () => {
244 const out = await indexChangedFiles({
245 repositoryId: "",
246 ownerName: "x",
247 repoName: "y",
248 commitSha: "deadbeef",
249 changedPaths: ["src/foo.ts"],
250 });
251 expect(out.indexed).toBe(0);
252 });
253
254 it("filters out non-code files before doing any work", async () => {
255 // We can't easily assert what was filtered without a stub git, but
256 // we can verify that *only* non-code paths produce indexed=0 even
257 // when a stub embedder would have succeeded.
258 __setEmbedderForTests(makeStubEmbedder());
259 const out = await indexChangedFiles({
260 repositoryId: "00000000-0000-0000-0000-000000000000",
261 ownerName: "nobody",
262 repoName: "nothing",
263 commitSha: "0000000000000000000000000000000000000000",
264 // None of these are code files we'd index.
265 changedPaths: ["LICENSE", "image.png", "binary.zip"],
266 });
267 expect(out.indexed).toBe(0);
268 __setEmbedderForTests(null);
269 });
270});
271
272describe("searchSemantic — graceful no-ops", () => {
273 it("returns [] for empty query", async () => {
274 const out = await searchSemantic({
275 repositoryId: "00000000-0000-0000-0000-000000000000",
276 query: "",
277 });
278 expect(out).toEqual([]);
279 });
280
281 it("returns [] for empty repositoryId", async () => {
282 const out = await searchSemantic({
283 repositoryId: "",
284 query: "hello",
285 });
286 expect(out).toEqual([]);
287 });
288
289 it("clamps limit to [1, 100]", async () => {
290 // We can't assert the bound directly without DB; but we can verify
291 // that absurd values don't throw — graceful behaviour is the
292 // contract.
293 const a = await searchSemantic({
294 repositoryId: "",
295 query: "hi",
296 limit: 0,
297 });
298 const b = await searchSemantic({
299 repositoryId: "",
300 query: "hi",
301 limit: 999999,
302 });
303 expect(a).toEqual([]);
304 expect(b).toEqual([]);
305 });
306});
307
308// ---------------------------------------------------------------------------
309// 2. API endpoint — auth/validation surface (no DB)
310// ---------------------------------------------------------------------------
311
312describe("GET /api/v2/repos/:o/:r/semantic-search — validation", () => {
313 it("returns 404 for a nonexistent repo", async () => {
314 const res = await app.request(
315 "/api/v2/repos/nobody/nothing/semantic-search?q=foo"
316 );
317 expect(res.status).toBe(404);
318 });
319
320 it("returns 404 (or 200) when called without ?q on a missing repo", async () => {
321 const res = await app.request(
322 "/api/v2/repos/nobody/nothing/semantic-search"
323 );
324 // Missing repo dominates over empty query.
325 expect(res.status).toBe(404);
326 });
327});
328
329// ---------------------------------------------------------------------------
330// 3. End-to-end with DB + pgvector
331// ---------------------------------------------------------------------------
332
333describe.skipIf(!HAS_DB)("semantic-index — DB-backed flows", () => {
334 it.skipIf(!HAS_DB)("upserts a row per file and ranks by similarity", async () => {
335 if (!HAS_PGVECTOR) {
336 // Re-check at runtime — the beforeAll probe may have raced before
337 // migrations ran. Skip cleanly so the suite stays green when
338 // pgvector is unavailable.
339 return;
340 }
341 const { db } = await import("../db");
342 const { users, repositories, codeEmbeddings } = await import(
343 "../db/schema"
344 );
345 const { eq, and } = await import("drizzle-orm");
346
347 const stamp = randomBytes(4).toString("hex");
348 const username = `semuser-${stamp}`;
349 const reponame = `semrepo-${stamp}`;
350
351 const [u] = await db
352 .insert(users)
353 .values({
354 username,
355 email: `${username}@test.local`,
356 passwordHash: "x",
357 })
358 .returning();
359 if (!u) return;
360
361 const sha = await seedRepo(username, reponame, {
362 "src/fetch.ts": "export function fetchData() { return fetch('/api'); }\n",
363 "src/db.ts": "export function connectDatabase() { /* database pool */ }\n",
364 });
365
366 const [r] = await db
367 .insert(repositories)
368 .values({
369 name: reponame,
370 ownerId: u.id,
371 diskPath: getRepoPath(username, reponame),
372 defaultBranch: "main",
373 })
374 .returning();
375 if (!r) return;
376
377 // Use the deterministic stub so the test doesn't rely on Voyage.
378 __setEmbedderForTests(makeStubEmbedder());
379
380 const out = await indexChangedFiles({
381 repositoryId: r.id,
382 ownerName: username,
383 repoName: reponame,
384 commitSha: sha,
385 changedPaths: ["src/fetch.ts", "src/db.ts"],
386 });
387
388 expect(out.indexed).toBe(2);
389
390 // Verify rows landed.
391 const rows = await db
392 .select({
393 path: codeEmbeddings.filePath,
394 snippet: codeEmbeddings.contentSnippet,
395 })
396 .from(codeEmbeddings)
397 .where(eq(codeEmbeddings.repositoryId, r.id));
398 expect(rows.length).toBe(2);
399 const paths = rows.map((r) => r.path).sort();
400 expect(paths).toEqual(["src/db.ts", "src/fetch.ts"]);
401 for (const row of rows) {
402 // Snippet is the first 500 chars of file content — should be non-empty.
403 expect(row.snippet.length).toBeGreaterThan(0);
404 expect(row.snippet.length).toBeLessThanOrEqual(500);
405 }
406
407 // Ranking: query "fetch" should rank src/fetch.ts higher than src/db.ts.
408 const hits = await searchSemantic({
409 repositoryId: r.id,
410 query: "fetch data",
411 limit: 5,
412 });
413 expect(hits.length).toBeGreaterThanOrEqual(1);
414 expect(hits[0].filePath).toBe("src/fetch.ts");
415
416 // Query "database" should rank src/db.ts higher.
417 const hits2 = await searchSemantic({
418 repositoryId: r.id,
419 query: "database connection",
420 limit: 5,
421 });
422 expect(hits2.length).toBeGreaterThanOrEqual(1);
423 expect(hits2[0].filePath).toBe("src/db.ts");
424
425 // Idempotency: re-indexing the same files should not duplicate rows
426 // (unique index on (repository_id, file_path) enforces this).
427 const out2 = await indexChangedFiles({
428 repositoryId: r.id,
429 ownerName: username,
430 repoName: reponame,
431 commitSha: sha,
432 changedPaths: ["src/fetch.ts", "src/db.ts"],
433 });
434 expect(out2.indexed).toBe(2);
435
436 const rowsAfter = await db
437 .select({ id: codeEmbeddings.id })
438 .from(codeEmbeddings)
439 .where(eq(codeEmbeddings.repositoryId, r.id));
440 expect(rowsAfter.length).toBe(2);
441
442 // Cleanup
443 await db.delete(codeEmbeddings).where(eq(codeEmbeddings.repositoryId, r.id));
444 await db
445 .delete(repositories)
446 .where(and(eq(repositories.ownerId, u.id), eq(repositories.name, reponame)));
447 await db.delete(users).where(eq(users.id, u.id));
448 __setEmbedderForTests(null);
449 });
450
451 it.skipIf(!HAS_DB)(
452 "GET /api/v2/repos/:o/:r/semantic-search returns the indexed hits",
453 async () => {
454 if (!HAS_PGVECTOR) return;
455 const { db } = await import("../db");
456 const { users, repositories, codeEmbeddings } = await import(
457 "../db/schema"
458 );
459 const { eq, and } = await import("drizzle-orm");
460
461 const stamp = randomBytes(4).toString("hex");
462 const username = `semapi-${stamp}`;
463 const reponame = `semapi-${stamp}`;
464
465 const [u] = await db
466 .insert(users)
467 .values({
468 username,
469 email: `${username}@test.local`,
470 passwordHash: "x",
471 })
472 .returning();
473 if (!u) return;
474
475 const sha = await seedRepo(username, reponame, {
476 "src/main.ts": "export function fetchUserData() { return fetch('/u'); }\n",
477 });
478
479 const [r] = await db
480 .insert(repositories)
481 .values({
482 name: reponame,
483 ownerId: u.id,
484 diskPath: getRepoPath(username, reponame),
485 defaultBranch: "main",
486 isPrivate: false,
487 })
488 .returning();
489 if (!r) return;
490
491 __setEmbedderForTests(makeStubEmbedder());
492
493 await indexChangedFiles({
494 repositoryId: r.id,
495 ownerName: username,
496 repoName: reponame,
497 commitSha: sha,
498 changedPaths: ["src/main.ts"],
499 });
500
501 const res = await app.request(
502 `/api/v2/repos/${username}/${reponame}/semantic-search?q=fetch`
503 );
504 expect(res.status).toBe(200);
505 const body = (await res.json()) as Array<{
506 file_path: string;
507 snippet: string;
508 score: number;
509 blob_sha: string;
510 }>;
511 expect(Array.isArray(body)).toBe(true);
512 expect(body.length).toBeGreaterThanOrEqual(1);
513 expect(body[0].file_path).toBe("src/main.ts");
514 expect(typeof body[0].score).toBe("number");
515 expect(typeof body[0].blob_sha).toBe("string");
516
517 // Cleanup
518 await db
519 .delete(codeEmbeddings)
520 .where(eq(codeEmbeddings.repositoryId, r.id));
521 await db
522 .delete(repositories)
523 .where(
524 and(eq(repositories.ownerId, u.id), eq(repositories.name, reponame))
525 );
526 await db.delete(users).where(eq(users.id, u.id));
527 __setEmbedderForTests(null);
528 }
529 );
530});