// SPDX-License-Identifier: AGPL-3.0-only // Copyright (C) 2026 Giancarlo Erra - Altaire Limited import fsp from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { afterAll, beforeAll, describe, expect, it } from "vitest"; import { contextCollectionName } from "../../src/config.js"; import { chunkArtifactContent, loadConfig, readArtifactContent, } from "../../src/services/context-artifacts.js"; // ── Temp directory helpers ──────────────────────────────────────────────── let tempDir: string; beforeAll(async () => { tempDir = await fsp.mkdtemp(path.join(os.tmpdir(), "socraticode-context-test-")); }); afterAll(async () => { await fsp.rm(tempDir, { recursive: true, force: true }); }); async function createTempProject( files: Record, ): Promise { const projectDir = path.join(tempDir, `proj-${Date.now()}-${Math.random().toString(36).slice(2, 8)}`); await fsp.mkdir(projectDir, { recursive: true }); for (const [filePath, content] of Object.entries(files)) { const fullPath = path.join(projectDir, filePath); await fsp.mkdir(path.dirname(fullPath), { recursive: true }); await fsp.writeFile(fullPath, content); } return projectDir; } // ── loadConfig ───────────────────────────────────────────────────────── describe("loadConfig", () => { it("returns null when no .socraticodecontextartifacts.json exists", async () => { const projectDir = await createTempProject({ "README.md": "# Hello" }); const config = await loadConfig(projectDir); expect(config).toBeNull(); }); it("parses a valid .socraticodecontextartifacts.json", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: [ { name: "db-schema", path: "./schema.sql", description: "Database schema", }, ], }), }); const config = await loadConfig(projectDir); expect(config).not.toBeNull(); expect(config?.artifacts).toHaveLength(1); expect(config?.artifacts?.[0].name).toBe("db-schema"); expect(config?.artifacts?.[0].path).toBe("./schema.sql"); expect(config?.artifacts?.[0].description).toBe("Database schema"); }); it("accepts config without artifacts key", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({}), }); const config = await loadConfig(projectDir); expect(config).not.toBeNull(); expect(config?.artifacts).toBeUndefined(); }); it("accepts multiple artifacts", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: [ { name: "schema", path: "./schema.sql", description: "DB schema" }, { name: "api", path: "./api.yaml", description: "API spec" }, { name: "infra", path: "./terraform/", description: "Infra configs" }, ], }), }); const config = await loadConfig(projectDir); expect(config?.artifacts).toHaveLength(3); }); it("throws on invalid JSON", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": "{ not valid json }", }); await expect(loadConfig(projectDir)).rejects.toThrow("not valid JSON"); }); it("throws when root is not an object", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify([1, 2, 3]), }); await expect(loadConfig(projectDir)).rejects.toThrow("must be a JSON object"); }); it("throws when artifacts is not an array", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: "not-an-array" }), }); await expect(loadConfig(projectDir)).rejects.toThrow('"artifacts" must be an array'); }); it("throws when artifact is missing name", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: [{ path: "./schema.sql", description: "DB" }], }), }); await expect(loadConfig(projectDir)).rejects.toThrow("name must be a non-empty string"); }); it("throws when artifact has empty name", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: [{ name: " ", path: "./schema.sql", description: "DB" }], }), }); await expect(loadConfig(projectDir)).rejects.toThrow("name must be a non-empty string"); }); it("throws when artifact is missing path", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: [{ name: "schema", description: "DB" }], }), }); await expect(loadConfig(projectDir)).rejects.toThrow("path must be a non-empty string"); }); it("throws when artifact is missing description", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: [{ name: "schema", path: "./schema.sql" }], }), }); await expect(loadConfig(projectDir)).rejects.toThrow("description must be a non-empty string"); }); it("throws on duplicate artifact names (case-insensitive)", async () => { const projectDir = await createTempProject({ ".socraticodecontextartifacts.json": JSON.stringify({ artifacts: [ { name: "Schema", path: "./a.sql", description: "First" }, { name: "schema", path: "./b.sql", description: "Second" }, ], }), }); await expect(loadConfig(projectDir)).rejects.toThrow('duplicate artifact name "schema"'); }); }); // ── readArtifactContent ───────────────────────────────────────────────── describe("readArtifactContent", () => { it("reads a single file and returns content + hash", async () => { const projectDir = await createTempProject({ "schema.sql": "CREATE TABLE users (id INT PRIMARY KEY);", }); const { content, contentHash } = await readArtifactContent("./schema.sql", projectDir); expect(content).toBe("CREATE TABLE users (id INT PRIMARY KEY);"); expect(contentHash).toMatch(/^[0-9a-f]{16}$/); }); it("returns deterministic hash for same content", async () => { const projectDir = await createTempProject({ "a.sql": "SELECT 1;", "b.sql": "SELECT 1;", }); const r1 = await readArtifactContent("./a.sql", projectDir); const r2 = await readArtifactContent("./b.sql", projectDir); expect(r1.contentHash).toBe(r2.contentHash); }); it("returns different hashes for different content", async () => { const projectDir = await createTempProject({ "a.sql": "SELECT 1;", "b.sql": "SELECT 2;", }); const r1 = await readArtifactContent("./a.sql", projectDir); const r2 = await readArtifactContent("./b.sql", projectDir); expect(r1.contentHash).not.toBe(r2.contentHash); }); it("reads a directory and concatenates files with headers", async () => { const projectDir = await createTempProject({ "deploy/service.yaml": "apiVersion: v1\nkind: Service", "deploy/deployment.yaml": "apiVersion: apps/v1\nkind: Deployment", }); const { content, contentHash } = await readArtifactContent("./deploy", projectDir); expect(content).toContain("# ── deployment.yaml ──"); expect(content).toContain("# ── service.yaml ──"); expect(content).toContain("kind: Service"); expect(content).toContain("kind: Deployment"); expect(contentHash).toMatch(/^[0-9a-f]{16}$/); }); it("reads nested directory files recursively", async () => { const projectDir = await createTempProject({ "infra/main.tf": 'resource "aws_s3_bucket" "bucket" {}', "infra/modules/vpc/main.tf": 'resource "aws_vpc" "main" {}', }); const { content } = await readArtifactContent("./infra", projectDir); expect(content).toContain("aws_s3_bucket"); expect(content).toContain("aws_vpc"); }); it("throws for nonexistent path", async () => { const projectDir = await createTempProject({}); await expect(readArtifactContent("./missing.sql", projectDir)).rejects.toThrow(); }); it("throws for empty directory", async () => { const projectDir = await createTempProject({}); await fsp.mkdir(path.join(projectDir, "empty-dir"), { recursive: true }); await expect(readArtifactContent("./empty-dir", projectDir)).rejects.toThrow("empty or contains no readable files"); }); it("supports absolute paths", async () => { const projectDir = await createTempProject({ "data.json": '{"key": "value"}', }); const absPath = path.join(projectDir, "data.json"); const { content } = await readArtifactContent(absPath, projectDir); expect(content).toBe('{"key": "value"}'); }); }); // ── chunkArtifactContent ──────────────────────────────────────────────── describe("chunkArtifactContent", () => { it("returns a single chunk for small content", () => { const content = "CREATE TABLE users (\n id INT PRIMARY KEY,\n name TEXT\n);"; const chunks = chunkArtifactContent(content, "schema", "./schema.sql"); expect(chunks).toHaveLength(1); expect(chunks[0].content).toBe(content); expect(chunks[0].artifactName).toBe("schema"); expect(chunks[0].startLine).toBe(1); expect(chunks[0].endLine).toBe(4); }); it("produces valid UUID chunk IDs", () => { const content = "line1\nline2\nline3"; const chunks = chunkArtifactContent(content, "test", "./test.txt"); const uuidPattern = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/; for (const chunk of chunks) { expect(chunk.id).toMatch(uuidPattern); } }); it("produces deterministic IDs for same inputs", () => { const content = "CREATE TABLE t1;\nCREATE TABLE t2;"; const chunks1 = chunkArtifactContent(content, "schema", "./schema.sql"); const chunks2 = chunkArtifactContent(content, "schema", "./schema.sql"); expect(chunks1.map((c) => c.id)).toEqual(chunks2.map((c) => c.id)); }); it("splits large content into multiple overlapping chunks", () => { // Generate short lines so we don't hit MAX_CHUNK_CHARS truncation const lines = Array.from({ length: 250 }, (_, i) => `L${i}`); const content = lines.join("\n"); const chunks = chunkArtifactContent(content, "schema", "./schema.sql"); expect(chunks.length).toBeGreaterThan(1); // Verify overlap: last lines of chunk N should overlap with first lines of chunk N+1 // CHUNK_SIZE = 100, CHUNK_OVERLAP = 10 for (let i = 0; i < chunks.length - 1; i++) { const chunkLines = chunks[i].content.split("\n"); const nextChunkLines = chunks[i + 1].content.split("\n"); // The last OVERLAP lines of this chunk should appear at the start of the next const overlapFromCurrent = chunkLines.slice(-10); const overlapFromNext = nextChunkLines.slice(0, 10); expect(overlapFromCurrent).toEqual(overlapFromNext); } }); it("handles empty content", () => { const chunks = chunkArtifactContent("", "test", "./test.txt"); expect(chunks).toHaveLength(0); }); it("handles single-line content", () => { const chunks = chunkArtifactContent("SELECT 1;", "test", "./test.sql"); expect(chunks).toHaveLength(1); expect(chunks[0].content).toBe("SELECT 1;"); }); it("sets correct line numbers", () => { const lines = Array.from({ length: 50 }, (_, i) => `line ${i + 1}`); const content = lines.join("\n"); const chunks = chunkArtifactContent(content, "test", "./test.txt"); expect(chunks[0].startLine).toBe(1); expect(chunks[0].endLine).toBe(50); }); it("uses different artifactName for different artifacts", () => { const content = "SELECT 1;"; const c1 = chunkArtifactContent(content, "schema-a", "./a.sql"); const c2 = chunkArtifactContent(content, "schema-b", "./b.sql"); expect(c1[0].artifactName).toBe("schema-a"); expect(c2[0].artifactName).toBe("schema-b"); // IDs should differ because artifact name and path differ expect(c1[0].id).not.toBe(c2[0].id); }); }); // ── contextCollectionName ──────────────────────────────────────────────── describe("contextCollectionName", () => { it("prefixes with context_", () => { expect(contextCollectionName("abc123def456")).toBe("context_abc123def456"); }); it("produces valid Qdrant-friendly collection names", () => { const name = contextCollectionName("abc123def456"); expect(name).toMatch(/^[a-zA-Z0-9_-]+$/); expect(name).toMatch(/^context_[0-9a-f]{12}$/); }); });