Pre-launch — Gluecron is in final validation. Public signups and git hosting for non-owner users open after launch review.
CodeIssuesDiscussionsWikiPull RequestsProjectsCommitsActionsReleasesContributorsPulse● GatesSecuritySettingsDeploymentsPipelineInsightsAgents✨ Explain✨ Ask AI✨ Workspace✨ Spec✨ Tests▓ Debt Map✨ NL Search🏛 Archaeology
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.tsBlame531 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", () => {
b08d63bClaude313 it("returns 404 for a nonexistent repo (or 500 when no DB)", async () => {
a686079Claude314 const res = await app.request(
315 "/api/v2/repos/nobody/nothing/semantic-search?q=foo"
316 );
b08d63bClaude317 // 404 with a DB, 500 without (resolveRepo throws on missing DATABASE_URL).
318 expect([404, 500]).toContain(res.status);
a686079Claude319 });
320
b08d63bClaude321 it("returns 404 (or 500 without DB) when called without ?q on a missing repo", async () => {
a686079Claude322 const res = await app.request(
323 "/api/v2/repos/nobody/nothing/semantic-search"
324 );
325 // Missing repo dominates over empty query.
b08d63bClaude326 expect([404, 500]).toContain(res.status);
a686079Claude327 });
328});
329
330// ---------------------------------------------------------------------------
331// 3. End-to-end with DB + pgvector
332// ---------------------------------------------------------------------------
333
334describe.skipIf(!HAS_DB)("semantic-index — DB-backed flows", () => {
335 it.skipIf(!HAS_DB)("upserts a row per file and ranks by similarity", async () => {
336 if (!HAS_PGVECTOR) {
337 // Re-check at runtime — the beforeAll probe may have raced before
338 // migrations ran. Skip cleanly so the suite stays green when
339 // pgvector is unavailable.
340 return;
341 }
342 const { db } = await import("../db");
343 const { users, repositories, codeEmbeddings } = await import(
344 "../db/schema"
345 );
346 const { eq, and } = await import("drizzle-orm");
347
348 const stamp = randomBytes(4).toString("hex");
349 const username = `semuser-${stamp}`;
350 const reponame = `semrepo-${stamp}`;
351
352 const [u] = await db
353 .insert(users)
354 .values({
355 username,
356 email: `${username}@test.local`,
357 passwordHash: "x",
358 })
359 .returning();
360 if (!u) return;
361
362 const sha = await seedRepo(username, reponame, {
363 "src/fetch.ts": "export function fetchData() { return fetch('/api'); }\n",
364 "src/db.ts": "export function connectDatabase() { /* database pool */ }\n",
365 });
366
367 const [r] = await db
368 .insert(repositories)
369 .values({
370 name: reponame,
371 ownerId: u.id,
372 diskPath: getRepoPath(username, reponame),
373 defaultBranch: "main",
374 })
375 .returning();
376 if (!r) return;
377
378 // Use the deterministic stub so the test doesn't rely on Voyage.
379 __setEmbedderForTests(makeStubEmbedder());
380
381 const out = await indexChangedFiles({
382 repositoryId: r.id,
383 ownerName: username,
384 repoName: reponame,
385 commitSha: sha,
386 changedPaths: ["src/fetch.ts", "src/db.ts"],
387 });
388
389 expect(out.indexed).toBe(2);
390
391 // Verify rows landed.
392 const rows = await db
393 .select({
394 path: codeEmbeddings.filePath,
395 snippet: codeEmbeddings.contentSnippet,
396 })
397 .from(codeEmbeddings)
398 .where(eq(codeEmbeddings.repositoryId, r.id));
399 expect(rows.length).toBe(2);
400 const paths = rows.map((r) => r.path).sort();
401 expect(paths).toEqual(["src/db.ts", "src/fetch.ts"]);
402 for (const row of rows) {
403 // Snippet is the first 500 chars of file content — should be non-empty.
404 expect(row.snippet.length).toBeGreaterThan(0);
405 expect(row.snippet.length).toBeLessThanOrEqual(500);
406 }
407
408 // Ranking: query "fetch" should rank src/fetch.ts higher than src/db.ts.
409 const hits = await searchSemantic({
410 repositoryId: r.id,
411 query: "fetch data",
412 limit: 5,
413 });
414 expect(hits.length).toBeGreaterThanOrEqual(1);
415 expect(hits[0].filePath).toBe("src/fetch.ts");
416
417 // Query "database" should rank src/db.ts higher.
418 const hits2 = await searchSemantic({
419 repositoryId: r.id,
420 query: "database connection",
421 limit: 5,
422 });
423 expect(hits2.length).toBeGreaterThanOrEqual(1);
424 expect(hits2[0].filePath).toBe("src/db.ts");
425
426 // Idempotency: re-indexing the same files should not duplicate rows
427 // (unique index on (repository_id, file_path) enforces this).
428 const out2 = await indexChangedFiles({
429 repositoryId: r.id,
430 ownerName: username,
431 repoName: reponame,
432 commitSha: sha,
433 changedPaths: ["src/fetch.ts", "src/db.ts"],
434 });
435 expect(out2.indexed).toBe(2);
436
437 const rowsAfter = await db
438 .select({ id: codeEmbeddings.id })
439 .from(codeEmbeddings)
440 .where(eq(codeEmbeddings.repositoryId, r.id));
441 expect(rowsAfter.length).toBe(2);
442
443 // Cleanup
444 await db.delete(codeEmbeddings).where(eq(codeEmbeddings.repositoryId, r.id));
445 await db
446 .delete(repositories)
447 .where(and(eq(repositories.ownerId, u.id), eq(repositories.name, reponame)));
448 await db.delete(users).where(eq(users.id, u.id));
449 __setEmbedderForTests(null);
450 });
451
452 it.skipIf(!HAS_DB)(
453 "GET /api/v2/repos/:o/:r/semantic-search returns the indexed hits",
454 async () => {
455 if (!HAS_PGVECTOR) return;
456 const { db } = await import("../db");
457 const { users, repositories, codeEmbeddings } = await import(
458 "../db/schema"
459 );
460 const { eq, and } = await import("drizzle-orm");
461
462 const stamp = randomBytes(4).toString("hex");
463 const username = `semapi-${stamp}`;
464 const reponame = `semapi-${stamp}`;
465
466 const [u] = await db
467 .insert(users)
468 .values({
469 username,
470 email: `${username}@test.local`,
471 passwordHash: "x",
472 })
473 .returning();
474 if (!u) return;
475
476 const sha = await seedRepo(username, reponame, {
477 "src/main.ts": "export function fetchUserData() { return fetch('/u'); }\n",
478 });
479
480 const [r] = await db
481 .insert(repositories)
482 .values({
483 name: reponame,
484 ownerId: u.id,
485 diskPath: getRepoPath(username, reponame),
486 defaultBranch: "main",
487 isPrivate: false,
488 })
489 .returning();
490 if (!r) return;
491
492 __setEmbedderForTests(makeStubEmbedder());
493
494 await indexChangedFiles({
495 repositoryId: r.id,
496 ownerName: username,
497 repoName: reponame,
498 commitSha: sha,
499 changedPaths: ["src/main.ts"],
500 });
501
502 const res = await app.request(
503 `/api/v2/repos/${username}/${reponame}/semantic-search?q=fetch`
504 );
505 expect(res.status).toBe(200);
506 const body = (await res.json()) as Array<{
507 file_path: string;
508 snippet: string;
509 score: number;
510 blob_sha: string;
511 }>;
512 expect(Array.isArray(body)).toBe(true);
513 expect(body.length).toBeGreaterThanOrEqual(1);
514 expect(body[0].file_path).toBe("src/main.ts");
515 expect(typeof body[0].score).toBe("number");
516 expect(typeof body[0].blob_sha).toBe("string");
517
518 // Cleanup
519 await db
520 .delete(codeEmbeddings)
521 .where(eq(codeEmbeddings.repositoryId, r.id));
522 await db
523 .delete(repositories)
524 .where(
525 and(eq(repositories.ownerId, u.id), eq(repositories.name, reponame))
526 );
527 await db.delete(users).where(eq(users.id, u.id));
528 __setEmbedderForTests(null);
529 }
530 );
531});