mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-05 08:06:02 +00:00
* feat: configure prettier with pre-commit hook integration Add prettier, lint-staged, and prettier-plugin-tailwindcss at the repo root with husky pre-commit hook integration. Moves husky from gitnexus/ to root package.json for reliable hook installation. - Root package.json with prepare/format/format:check scripts - .prettierrc with endOfLine:lf and tailwindStylesheet for TW v4 - .prettierignore excluding fixtures, vendor, generated, *.d.ts, *.md - .gitattributes enforcing LF line endings for Windows consistency - Pre-commit hook uses direct node_modules/.bin/ paths (no npx) * style: apply prettier formatting to entire codebase One-time bulk format. No logic changes. Use .git-blame-ignore-revs to skip this commit in git blame. * chore: add .git-blame-ignore-revs for prettier format commit * perf: pre-commit hook runs only tests related to staged files Use vitest --related to scope test execution to tests that import the changed files, instead of running the full suite on every commit. * perf: remove vitest from pre-commit hook, keep in CI only Pre-commit now runs lint-staged + tsc only. Tests run in CI (ci-tests.yml) where they belong — keeps commits fast. * ci: add prettier format check to quality workflow PRs will now fail if code isn't formatted with prettier.
343 lines
12 KiB
TypeScript
343 lines
12 KiB
TypeScript
import { describe, it, expect, vi, afterEach } from 'vitest';
|
|
import { getEmbeddingDims, isEmbedderReady } from '../../src/mcp/core/embedder.js';
|
|
|
|
const ENV_KEYS = [
|
|
'GITNEXUS_EMBEDDING_URL',
|
|
'GITNEXUS_EMBEDDING_MODEL',
|
|
'GITNEXUS_EMBEDDING_API_KEY',
|
|
'GITNEXUS_EMBEDDING_DIMS',
|
|
] as const;
|
|
|
|
/** 384d mock vector matching the default schema dimensions. */
|
|
const mockVec = Array.from({ length: 384 }, (_, i) => i / 384);
|
|
|
|
describe('HTTP embedding backend', () => {
|
|
// Save original env state before any test mutates it
|
|
const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
|
|
afterEach(() => {
|
|
vi.unstubAllGlobals();
|
|
vi.resetModules();
|
|
// Restore env vars to pre-test state so a mid-test throw can't leak
|
|
for (const key of ENV_KEYS) {
|
|
if (savedEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = savedEnv[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
describe('MCP embedder', () => {
|
|
it('returns 384 dimensions by default', () => {
|
|
expect(getEmbeddingDims()).toBe(384);
|
|
});
|
|
|
|
it('returns false before initialization', () => {
|
|
expect(isEmbedderReady()).toBe(false);
|
|
});
|
|
|
|
it('returns true when HTTP environment variables are set', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://localhost:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
expect(mod.isEmbedderReady()).toBe(true);
|
|
});
|
|
|
|
it('reads custom dimensions from environment', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://localhost:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
expect(mod.getEmbeddingDims()).toBe(1024);
|
|
});
|
|
|
|
it('retries query on transient server error', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce({ ok: false, status: 503 }).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const result = await mod.embedQuery('test query');
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(result).toEqual(mockVec);
|
|
});
|
|
});
|
|
|
|
describe('core embedder HTTP path', () => {
|
|
it('sends correct request payload', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = 'test-key';
|
|
|
|
const mockEmbedding = Array.from({ length: 384 }, (_, i) => i * 0.001);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockEmbedding }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
expect(fetch).toHaveBeenCalledOnce();
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.model).toBe('test-model');
|
|
expect(body.input).toEqual(['test text']);
|
|
expect(result).toBeInstanceOf(Float32Array);
|
|
expect(result.length).toBe(384);
|
|
});
|
|
|
|
it('retries on server error', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce({ ok: false, status: 503 }).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await embedText('test');
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('retries on rate limit', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce({ ok: false, status: 429 }).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await embedText('test');
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('throws when all retries are exhausted', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('500');
|
|
});
|
|
|
|
it('excludes API key from error messages', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = 'secret-key-12345';
|
|
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 500 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
try {
|
|
await embedText('test');
|
|
} catch (e: any) {
|
|
expect(e.message).not.toContain('secret-key-12345');
|
|
expect(e.message).not.toContain('Authorization');
|
|
}
|
|
});
|
|
|
|
it('includes abort signal for timeout', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await embedText('test');
|
|
|
|
const opts = (fetch as any).mock.calls[0][1];
|
|
expect(opts.signal).toBeDefined();
|
|
});
|
|
|
|
it('splits large inputs into batches', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const makeResp = (n: number) => ({
|
|
ok: true,
|
|
json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: mockVec })) }),
|
|
});
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValueOnce(makeResp(64)).mockResolvedValueOnce(makeResp(6)),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const results = await embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`));
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(results).toHaveLength(70);
|
|
});
|
|
|
|
it('rejects initEmbedder when using HTTP backend', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const { initEmbedder } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(initEmbedder()).rejects.toThrow('HTTP mode');
|
|
});
|
|
|
|
it('rejects getEmbedder when using HTTP backend', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const { getEmbedder } = await import('../../src/core/embeddings/embedder.js');
|
|
expect(() => getEmbedder()).toThrow('HTTP embedding mode');
|
|
});
|
|
|
|
it('throws on empty response from endpoint', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
await expect(mod.embedQuery('test')).rejects.toThrow('empty response');
|
|
});
|
|
|
|
it('throws when endpoint returns fewer embeddings than texts', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedBatch(['text1', 'text2', 'text3'])).rejects.toThrow(
|
|
'1 vectors for 3 texts',
|
|
);
|
|
});
|
|
|
|
it('throws on dimension mismatch when GITNEXUS_EMBEDDING_DIMS is set', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: [0.1, 0.2, 0.3] }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('Embedding dimension mismatch');
|
|
});
|
|
});
|
|
|
|
describe('schema dimensions', () => {
|
|
it('defaults to 384 dimensions', async () => {
|
|
const { EMBEDDING_DIMS } = await import('../../src/core/lbug/schema.js');
|
|
expect(EMBEDDING_DIMS).toBe(384);
|
|
});
|
|
|
|
it('reads dimensions from environment variable', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
const { EMBEDDING_DIMS } = await import('../../src/core/lbug/schema.js');
|
|
expect(EMBEDDING_DIMS).toBe(1024);
|
|
});
|
|
});
|
|
|
|
describe('timeout and network error handling', () => {
|
|
it('does not retry on timeout', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const timeoutErr = new DOMException(
|
|
'The operation was aborted due to timeout',
|
|
'TimeoutError',
|
|
);
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(timeoutErr));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('timed out');
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retries on network error then succeeds', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockRejectedValueOnce(new TypeError('fetch failed')).mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test');
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(result).toBeInstanceOf(Float32Array);
|
|
});
|
|
});
|
|
|
|
describe('dimension mismatch on query path', () => {
|
|
it('throws on explicit dim mismatch in embedQuery', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
await expect(mod.embedQuery('test')).rejects.toThrow('dimension mismatch');
|
|
});
|
|
|
|
it('throws with Set hint when GITNEXUS_EMBEDDING_DIMS is unset', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const vec768 = Array.from({ length: 768 }, (_, i) => i / 768);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec768 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('Set GITNEXUS_EMBEDDING_DIMS=768');
|
|
});
|
|
});
|
|
});
|