mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-01 02:01:24 +00:00
Some checks are pending
Gitleaks / gitleaks (push) Waiting to run
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
* fix(embeddings): isolate local ONNX inference in a child_process sidecar The analyze parent must not load onnxruntime-node. Fork a sidecar for vectors only and reap it on worker exit; keep Ladybug writes in-process. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(embeddings): share the sidecar client across MCP, serve, and sync Query hosts now use the core façade instead of a second in-process ONNX embedder. Search skips an empty table, sync reaps beside closeLbug, and ready means the stack is resolvable rather than a warm singleton. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(embeddings): refuse Intel Mac and unloadable prefix before npm heal Analyze, sync, install, and the sidecar client now consult the platform blocker before forking or downloading the optional stack. HTTP stays the escape hatch; wasm is not treated as a rescue. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(embeddings): take the ONNX stack off default npm install Pins live in gitnexusEmbeddingStack. embeddings install writes prefix overrides before npm spawn. Leftover 1.6.12 package-first trees are residual. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(publish): drop grammar source from the published tarball Every vendored grammar has 6/6 prebuilds, so files ships those plus Leiden and FTS instead of parser.c. First ship stays above 80 MiB. Co-authored-by: Cursor <cursoragent@cursor.com> * test(embeddings): match MCP missing-stack warn to the R20 copy Default install no longer calls the stack optional, so the once-per-backend stderr assertion must look for the new lead line. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(review): bound sidecar death, cancel writes, and publish-file guards Init-time native crashes no longer respawn a child on every query. Local embedBatch honors AbortSignal after sidecar return, MCP query() surfaces vector-lane degradation, disconnect always reaps, and the grammar prepack guard checks files globs instead of on-disk prebuilds. Co-authored-by: Cursor <cursoragent@cursor.com> * refactor(embeddings): share runtime preflight and sidecar reap helpers Analyze and embeddings-sync used the same blocker/prefix/install gate with different error routing. One assessment keeps those paths aligned without changing CLI vs thrown-error behavior. Co-authored-by: Cursor <cursoragent@cursor.com> * Address PR review feedback (#3287) Keep a reaped sidecar from resetting its replacement, wait for dispose, tighten the publish-files guard, and stop assuming a leftover ONNX tree in CI. Co-authored-by: Cursor <cursoragent@cursor.com> * Address remaining PR review feedback (#3287) Clear the sidecar reap timeout, add init IPC slack, and isolate embeddings-sync tests from HTTP-mode env. Co-authored-by: Cursor <cursoragent@cursor.com> * test(embeddings): unstub globals after sidecar HTTP-mode tests Keep a leaked fetch stub from failing assertions out of later tests in the same file. Co-authored-by: Cursor <cursoragent@cursor.com> * test(embeddings): pin sidecar success cases off darwin/x64 The runtime blocker reads the real process platform before the fork mock, so local-success tests must not inherit an Intel Mac host. Co-authored-by: Cursor <cursoragent@cursor.com> * Address remaining PR review feedback (#3287) Keep vector degradation per query, treat leftover Intel-Mac stacks as not ready, and document that the CLI image no longer ships ONNX. Co-authored-by: Cursor <cursoragent@cursor.com> * Simplify embedding sidecar shutdown and search hot paths Drop redundant sidecar reaps and unused child helpers, and run FTS alongside semantic search. Co-authored-by: Cursor <cursoragent@cursor.com> * Address remaining PR review feedback (#3287) Share HF attempt parsing with the sidecar init deadline, abort embed waits without killing the child, and restore last init options on recreate. Co-authored-by: Cursor <cursoragent@cursor.com> * Address remaining PR review feedback (#3287) Treat sub-1 HF attempt env values as invalid, and drop leaked sidecar waiters when IPC send throws. Co-authored-by: Cursor <cursoragent@cursor.com> * Address remaining PR review feedback (#3287) Keep sidecar init on a shared chain; each waiter can abort only its own wait. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(ci): declare embedding-table existence probe as unordered LIMIT The empty-table skip in semanticSearch is existence-only; declare it so the #2787 determinism guard stops failing coverage shard 3/3. Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Gergo Magyar <gergomagyar0@gmail.com> Co-authored-by: Cursor <cursoragent@cursor.com>
1029 lines
43 KiB
TypeScript
1029 lines
43 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',
|
|
'GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS',
|
|
'GITNEXUS_EMBEDDING_MAX_ATTEMPTS',
|
|
'GITNEXUS_EMBEDDING_RETRY_CAP_MS',
|
|
'GITNEXUS_EMBEDDING_MIN_INTERVAL_MS',
|
|
'GITNEXUS_EMBEDDING_REQUEST_DIMS',
|
|
'GITNEXUS_EMBEDDING_RETRY_TIMEOUTS',
|
|
] 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.useRealTimers();
|
|
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];
|
|
}
|
|
}
|
|
});
|
|
|
|
it('fingerprints HTTP provider identity without confusing a model-only env with HTTP mode', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://user:secret@first.example/v1?token=hidden';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'shared-model-name';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '384';
|
|
const { resolveEmbeddingIdentity } =
|
|
await import('../../src/core/embeddings/embedding-identity.js');
|
|
|
|
const first = resolveEmbeddingIdentity();
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://second.example/v1';
|
|
const second = resolveEmbeddingIdentity();
|
|
delete process.env.GITNEXUS_EMBEDDING_URL;
|
|
const local = resolveEmbeddingIdentity();
|
|
|
|
expect(first.provider).toMatch(/^http:[0-9a-f]{64}$/u);
|
|
expect(first.provider).not.toContain('secret');
|
|
expect(first.provider).not.toContain('hidden');
|
|
expect(second.provider).not.toBe(first.provider);
|
|
expect(local.provider).toBe('local');
|
|
expect(local.model).not.toBe('shared-model-name');
|
|
});
|
|
|
|
describe('MCP embedder', () => {
|
|
it('returns 384 dimensions by default', () => {
|
|
expect(getEmbeddingDims()).toBe(384);
|
|
});
|
|
|
|
it('is ready from HTTP mode or a ready local runtime, not resolution alone', async () => {
|
|
const { isHttpMode } = await import('../../src/core/embeddings/http-client.js');
|
|
const { assessLocalEmbeddingRuntime } =
|
|
await import('../../src/core/embeddings/runtime-support.js');
|
|
expect(isEmbedderReady()).toBe(
|
|
isHttpMode() || assessLocalEmbeddingRuntime().status === 'ready',
|
|
);
|
|
});
|
|
|
|
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('omits dimensions from request body when GITNEXUS_EMBEDDING_DIMS is unset', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// GITNEXUS_EMBEDDING_DIMS intentionally unset
|
|
|
|
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 text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
// Backends that reject unknown fields must see the pre-existing
|
|
// request shape. The field must be absent, not `undefined`.
|
|
expect('dimensions' in body).toBe(false);
|
|
});
|
|
|
|
it('forwards GITNEXUS_EMBEDDING_DIMS as dimensions in request body', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.dimensions).toBe(1024);
|
|
expect(body.model).toBe('text-embedding-3-large');
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it('can validate custom dims without forwarding dimensions to strict backends', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect('dimensions' in body).toBe(false);
|
|
expect(body.model).toBe('bge-m3');
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it('forwards dimensions on the single-query path', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec512 }] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const result = await mod.embedQuery('query text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.dimensions).toBe(512);
|
|
expect(result.length).toBe(512);
|
|
});
|
|
|
|
it('can omit dimensions on the single-query path while validating custom dims', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'omit';
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const result = await mod.embedQuery('query text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect('dimensions' in body).toBe(false);
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it.each(['none', 'off', 'false', '0'])(
|
|
'treats GITNEXUS_EMBEDDING_REQUEST_DIMS=%s as omit and drops the request dimensions field',
|
|
async (alias) => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'bge-m3';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = alias;
|
|
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect('dimensions' in body).toBe(false);
|
|
expect(result.length).toBe(1024);
|
|
},
|
|
);
|
|
|
|
it('sends REQUEST_DIMS as the request dimensions while DIMS validates the response', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'text-embedding-3-large';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = '512';
|
|
|
|
// Response keeps the DIMS-validated length; only the outgoing request differs.
|
|
const vec1024 = Array.from({ length: 1024 }, (_, i) => i / 1024);
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: vec1024 }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test text');
|
|
|
|
const body = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
expect(body.dimensions).toBe(512);
|
|
expect(result.length).toBe(1024);
|
|
});
|
|
|
|
it('rejects a malformed GITNEXUS_EMBEDDING_REQUEST_DIMS with an error naming that var', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_REQUEST_DIMS = 'garbage';
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingDimsError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
// Recognizable as a config error so the CLI prints a clean message...
|
|
expect(isHttpEmbeddingDimsError(String(err))).toBe(true);
|
|
// ...and it points the operator at the var they set, not GITNEXUS_EMBEDDING_DIMS.
|
|
expect(String(err)).toContain('GITNEXUS_EMBEDDING_REQUEST_DIMS must be a positive integer');
|
|
});
|
|
|
|
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');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('500');
|
|
// Type-completeness fence: a non-OK-status failure must stay classifiable
|
|
// so the CLI routes it to the endpoint branch, not the HF branch (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
});
|
|
|
|
it('classifies a terminal 4xx (404) as a typed endpoint error without retrying (#2385)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// The most common --embedding-base-url misconfiguration: wrong path -> 404,
|
|
// bad key -> 401/403. resilientFetch returns a terminal 4xx (other than 429)
|
|
// without retrying, so httpEmbedBatch's !resp.ok branch is the sole
|
|
// classifier — distinct from 500 (ResilientFetchExhaustedError) and 429/503.
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 404 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('404');
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('classifies a reachable endpoint that returns a non-JSON 200 body', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// An unusable 2xx body is retried like a 5xx (#2790); collapse the
|
|
// backoff so this case still asserts classification, not wall clock.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
// A captive portal / wrong service answers 200 with HTML — resp.json() throws.
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => {
|
|
throw new SyntaxError('Unexpected token < in JSON at position 0');
|
|
},
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(String(err)).toContain('unparseable response');
|
|
});
|
|
|
|
it('surfaces a connection failure as a typed HttpEmbeddingError (the #2385 case)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://127.0.0.1:1/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// Node's undici throws `TypeError: fetch failed` on a terminal connect error.
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
// The endpoint failure carries the type — no message-text matching needed.
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
// The masked URL is preserved for the CLI message; no HuggingFace text.
|
|
expect(String(err)).toContain('127.0.0.1:1');
|
|
expect(String(err)).not.toMatch(/huggingface/i);
|
|
});
|
|
|
|
// A reachable-but-wrong endpoint can answer 200 with a well-formed outer array
|
|
// whose items are malformed. The outer Array.isArray(data.data) guard passes;
|
|
// without per-item validation these crash at new Float32Array(item.embedding)
|
|
// (batch) / items[0].embedding (query) with a raw TypeError that escapes the
|
|
// typed boundary — the exact #2385 stack-dump class. (#2385)
|
|
it.each([
|
|
{ label: 'a null item', body: { data: [null] } },
|
|
{ label: 'an item with no embedding', body: { data: [{}] } },
|
|
{ label: 'an item whose embedding is not an array', body: { data: [{ embedding: 'nope' }] } },
|
|
])('types a malformed response item ($label) on the batch path', async ({ body }) => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// Wrong-shaped 2xx bodies are retried too (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => body }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(String(err)).toContain('unexpected response shape');
|
|
});
|
|
|
|
it('types a null item on the query path (httpEmbedQuery, #2385)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// Wrong-shaped 2xx bodies are retried too (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({ ok: true, json: async () => ({ data: [null] }) }),
|
|
);
|
|
|
|
const { httpEmbedQuery, isHttpEmbeddingError } =
|
|
await import('../../src/core/embeddings/http-client.js');
|
|
const err = await httpEmbedQuery('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(String(err)).toContain('unexpected response shape');
|
|
});
|
|
|
|
it('excludes API key from error messages', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
const redactionProbeKey = 'test-api-key-redaction-check';
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = redactionProbeKey;
|
|
|
|
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(redactionProbeKey);
|
|
expect(e.message).not.toContain('Authorization');
|
|
}
|
|
});
|
|
|
|
it('scrubs credentials embedded in the endpoint URL from the error message (#2385)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://user:secret@host.example/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
// undici rejects a credential-bearing URL at Request construction, echoing
|
|
// the full URL (incl. user:secret) verbatim in err.message.
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockRejectedValue(
|
|
new TypeError(
|
|
'Request cannot be constructed from a URL that includes credentials: ' +
|
|
'https://user:secret@host.example/v1/embeddings',
|
|
),
|
|
),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
// The secret is gone; the masked host is retained so the message stays useful.
|
|
expect(String(err)).not.toContain('secret');
|
|
expect(String((err as Error & { cause?: unknown }).cause)).not.toContain('secret');
|
|
expect(String(err)).toContain('host.example');
|
|
});
|
|
|
|
it('redacts the API key from both the message and diagnostic cause', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'https://host.example/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_API_KEY = 'super-secret-key';
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockRejectedValue(new TypeError('transport rejected super-secret-key')),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const err = await embedText('test').catch((error: unknown) => error);
|
|
expect(String(err)).not.toContain('super-secret-key');
|
|
expect(String((err as Error & { cause?: unknown }).cause)).not.toContain('super-secret-key');
|
|
});
|
|
|
|
it('leaves a non-credential reason unchanged (no over-scrubbing)', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValue(new TypeError('fetch failed')));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('fetch failed');
|
|
});
|
|
|
|
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('forwards dimensions in every batch when splitting large inputs', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '512';
|
|
|
|
const vec512 = Array.from({ length: 512 }, (_, i) => i / 512);
|
|
const makeResp = (n: number) => ({
|
|
ok: true,
|
|
json: async () => ({ data: Array.from({ length: n }, () => ({ embedding: vec512 })) }),
|
|
});
|
|
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);
|
|
|
|
// Verify dimensions is sent in BOTH batch requests
|
|
const body0 = JSON.parse((fetch as any).mock.calls[0][1].body);
|
|
const body1 = JSON.parse((fetch as any).mock.calls[1][1].body);
|
|
expect(body0.dimensions).toBe(512);
|
|
expect(body1.dimensions).toBe(512);
|
|
});
|
|
|
|
it('rejects non-numeric GITNEXUS_EMBEDDING_DIMS values', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('must be a positive integer');
|
|
});
|
|
|
|
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';
|
|
// A short body is retried like a 5xx now (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [] }),
|
|
}),
|
|
);
|
|
|
|
const mod = await import('../../src/mcp/core/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await mod.embedQuery('test').catch((e: unknown) => e);
|
|
// `{"data": []}` is a cardinality mismatch caught inside the retry loop,
|
|
// so it reports both counts rather than reaching httpEmbedQuery's
|
|
// (now defensive) "empty response" backstop (#2790).
|
|
expect(String(err)).toContain('0 vectors for 1 texts');
|
|
// Type-completeness fence: this conversion must stay typed so the CLI
|
|
// routes it to the endpoint branch, not the HF branch (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
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';
|
|
// A short body is retried like a 5xx now (#2790) — collapse the backoff.
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi.fn().mockResolvedValue({
|
|
ok: true,
|
|
json: async () => ({ data: [{ embedding: mockVec }] }),
|
|
}),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedBatch(['text1', 'text2', 'text3']).catch((e: unknown) => e);
|
|
expect(String(err)).toContain('1 vectors for 3 texts');
|
|
// Type-completeness fence (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
// The short body now gets the full retry budget instead of failing after
|
|
// one attempt with a `recordSuccess()` already booked (#2790).
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
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');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('Embedding dimension mismatch');
|
|
// Type-completeness fence (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
});
|
|
});
|
|
|
|
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('uses a 180-second default timeout and accepts a bounded override', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
|
|
const { getHttpTimeoutMs } = await import('../../src/core/embeddings/http-client.js');
|
|
expect(getHttpTimeoutMs()).toBe(180_000);
|
|
|
|
process.env.GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS = '120000';
|
|
expect(getHttpTimeoutMs()).toBe(120_000);
|
|
process.env.GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS = '300001';
|
|
expect(() => getHttpTimeoutMs()).toThrow('GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS');
|
|
});
|
|
|
|
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');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('timed out');
|
|
// Type-completeness fence: a timeout must stay classifiable (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('retries a timeout when explicitly configured', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_TIMEOUTS = '1';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '2';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
|
|
const timeoutErr = new DOMException(
|
|
'The operation was aborted due to timeout',
|
|
'TimeoutError',
|
|
);
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal('fetch', vi.fn().mockRejectedValueOnce(timeoutErr).mockResolvedValueOnce(ok));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test');
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(result).toBeInstanceOf(Float32Array);
|
|
});
|
|
|
|
it('retries a body-read timeout when explicitly configured', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_TIMEOUTS = '1';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '2';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
|
|
// The motivating fault stalls mid-body rather than rejecting the initial
|
|
// fetch: the per-attempt signal is wired to the body stream, so the
|
|
// timeout surfaces out of `.json()`. That is a second re-wrap site, and
|
|
// the fetch-rejects test above does not reach it.
|
|
const stalledBody = {
|
|
ok: true,
|
|
json: async () => {
|
|
throw new DOMException('The operation was aborted due to timeout', 'TimeoutError');
|
|
},
|
|
};
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValueOnce(stalledBody).mockResolvedValueOnce(ok));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const result = await embedText('test');
|
|
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
expect(result).toBeInstanceOf(Float32Array);
|
|
});
|
|
|
|
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);
|
|
});
|
|
|
|
it('honors the configured total attempt bound', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '1';
|
|
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: false, status: 503 }));
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow('503');
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it('caps Retry-After with the configured retry cap', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '2';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '2500';
|
|
const ok = { ok: true, json: async () => ({ data: [{ embedding: mockVec }] }) };
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce(
|
|
new Response('{}', { status: 429, headers: { 'Retry-After': '60' } }),
|
|
)
|
|
.mockResolvedValueOnce(ok),
|
|
);
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const promise = embedText('test');
|
|
await vi.advanceTimersByTimeAsync(2499);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(promise).resolves.toBeInstanceOf(Float32Array);
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
});
|
|
|
|
it('paces retries and successful batches through one minimum-interval queue', async () => {
|
|
vi.useFakeTimers();
|
|
vi.setSystemTime(0);
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '2';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '1';
|
|
process.env.GITNEXUS_EMBEDDING_MIN_INTERVAL_MS = '1000';
|
|
const makeResp = (count: number) => ({
|
|
ok: true,
|
|
json: async () => ({ data: Array.from({ length: count }, () => ({ embedding: mockVec })) }),
|
|
});
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValueOnce({ ok: false, status: 503 })
|
|
.mockResolvedValueOnce(makeResp(64))
|
|
.mockResolvedValueOnce(makeResp(6)),
|
|
);
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const promise = embedBatch(Array.from({ length: 70 }, (_, i) => `text ${i}`));
|
|
await vi.advanceTimersByTimeAsync(0);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(999);
|
|
expect(fetch).toHaveBeenCalledTimes(2);
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
await expect(promise).resolves.toHaveLength(70);
|
|
expect(fetch).toHaveBeenCalledTimes(3);
|
|
});
|
|
|
|
it('cancels promptly while waiting for retry backoff', async () => {
|
|
vi.useFakeTimers();
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_MAX_ATTEMPTS = '3';
|
|
process.env.GITNEXUS_EMBEDDING_RETRY_CAP_MS = '60000';
|
|
vi.stubGlobal(
|
|
'fetch',
|
|
vi
|
|
.fn()
|
|
.mockResolvedValue(new Response('{}', { status: 429, headers: { 'Retry-After': '60' } })),
|
|
);
|
|
const controller = new AbortController();
|
|
|
|
const { embedBatch } = await import('../../src/core/embeddings/embedder.js');
|
|
const promise = embedBatch(['test'], { signal: controller.signal });
|
|
await vi.advanceTimersByTimeAsync(1);
|
|
controller.abort();
|
|
await expect(promise).rejects.toThrow(/cancelled/i);
|
|
expect(fetch).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
it.each([
|
|
['GITNEXUS_EMBEDDING_HTTP_TIMEOUT_MS', '0'],
|
|
['GITNEXUS_EMBEDDING_MAX_ATTEMPTS', '0'],
|
|
['GITNEXUS_EMBEDDING_RETRY_CAP_MS', '-1'],
|
|
['GITNEXUS_EMBEDDING_MIN_INTERVAL_MS', 'nope'],
|
|
])('rejects malformed resilience config %s=%s', async (key, value) => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env[key] = value;
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
await expect(embedText('test')).rejects.toThrow(key);
|
|
});
|
|
});
|
|
|
|
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');
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
const err = await mod.embedQuery('test').catch((e: unknown) => e);
|
|
expect(String(err)).toContain('dimension mismatch');
|
|
// Type-completeness fence: the query-path conversion must stay typed (#2385).
|
|
expect(isHttpEmbeddingError(err)).toBe(true);
|
|
});
|
|
|
|
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');
|
|
});
|
|
});
|
|
});
|
|
|
|
describe('HttpEmbeddingError classification', () => {
|
|
it('recognises an HttpEmbeddingError instance', async () => {
|
|
const { HttpEmbeddingError, isHttpEmbeddingError } =
|
|
await import('../../src/core/embeddings/http-client.js');
|
|
expect(isHttpEmbeddingError(new HttpEmbeddingError('anything at all'))).toBe(true);
|
|
});
|
|
|
|
it('recognises a cross-realm error by name even when instanceof fails', async () => {
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
// Simulates an error that crossed a module boundary and lost its prototype
|
|
// chain: instanceof would be false, but the stable `name` still identifies it.
|
|
const crossRealm = new Error('endpoint down');
|
|
crossRealm.name = 'HttpEmbeddingError';
|
|
expect(isHttpEmbeddingError(crossRealm)).toBe(true);
|
|
});
|
|
|
|
it.each([
|
|
new Error('TypeError: fetch failed'),
|
|
new Error('Failed to download embedding model'),
|
|
new Error('connect ECONNREFUSED 127.0.0.1:443'),
|
|
'not even an error',
|
|
undefined,
|
|
])('does not claim non-endpoint value: %s', async (value) => {
|
|
const { isHttpEmbeddingError } = await import('../../src/core/embeddings/http-client.js');
|
|
expect(isHttpEmbeddingError(value)).toBe(false);
|
|
});
|
|
});
|
|
|
|
describe('HTTP mode config probe (#2385)', () => {
|
|
const ENV_KEYS = [
|
|
'GITNEXUS_EMBEDDING_URL',
|
|
'GITNEXUS_EMBEDDING_MODEL',
|
|
'GITNEXUS_EMBEDDING_DIMS',
|
|
] as const;
|
|
const savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]]));
|
|
|
|
afterEach(() => {
|
|
vi.resetModules();
|
|
for (const key of ENV_KEYS) {
|
|
if (savedEnv[key] === undefined) {
|
|
delete process.env[key];
|
|
} else {
|
|
process.env[key] = savedEnv[key];
|
|
}
|
|
}
|
|
});
|
|
|
|
it('isHttpMode() is a presence probe that does NOT throw on a malformed DIMS', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
|
|
|
|
const { isHttpMode } = await import('../../src/core/embeddings/http-client.js');
|
|
// Root-cause fix: the mode probe must not validate DIMS, so ~13 unguarded
|
|
// call sites (analyze:1109, doctor, run-analyze, embedder, mcp) don't crash.
|
|
expect(isHttpMode()).toBe(true);
|
|
});
|
|
|
|
it('surfaces a malformed DIMS as a recognizable plain config error, not an endpoint error', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model';
|
|
process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc';
|
|
|
|
const { embedText } = await import('../../src/core/embeddings/embedder.js');
|
|
const { isHttpEmbeddingDimsError, isHttpEmbeddingError } =
|
|
await import('../../src/core/embeddings/http-client.js');
|
|
const err = await embedText('test').catch((e: unknown) => e);
|
|
// Validated where it's used (readConfig in httpEmbed) and recognizable...
|
|
expect(isHttpEmbeddingDimsError(String(err))).toBe(true);
|
|
// ...as a plain config Error, NOT an HttpEmbeddingError endpoint failure.
|
|
expect(isHttpEmbeddingError(err)).toBe(false);
|
|
});
|
|
});
|