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