mirror of
https://github.com/giancarloerra/socraticode.git
synced 2026-07-03 14:05:21 +02:00
330 lines
13 KiB
TypeScript
330 lines
13 KiB
TypeScript
// 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<string, string>,
|
|
): Promise<string> {
|
|
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}$/);
|
|
});
|
|
});
|