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>
457 lines
17 KiB
TypeScript
457 lines
17 KiB
TypeScript
/**
|
|
* Tests for `gitnexus embeddings sync` writer-safety contracts (#3065 review):
|
|
* index lock, missing-DB preflight, identity fail-closed, tri-state count,
|
|
* closeLbug masking, and hash-only cache load.
|
|
*/
|
|
import { mkdtemp, mkdir, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const {
|
|
acquireIndexLockMock,
|
|
releaseMock,
|
|
getStoragePathsMock,
|
|
loadMetaMock,
|
|
saveMetaMock,
|
|
initLbugMock,
|
|
closeLbugMock,
|
|
executeQueryMock,
|
|
executeWithReusedStatementMock,
|
|
fetchExistingEmbeddingHashesMock,
|
|
runEmbeddingPipelineMock,
|
|
resolveEmbeddingIdentityMock,
|
|
installEmbeddingRuntimeMock,
|
|
resolveEmbeddingRuntimeMock,
|
|
isPrefixRuntimeLoadableMock,
|
|
reapEmbeddingSidecarMock,
|
|
} = vi.hoisted(() => ({
|
|
acquireIndexLockMock: vi.fn(),
|
|
releaseMock: vi.fn(),
|
|
getStoragePathsMock: vi.fn(),
|
|
loadMetaMock: vi.fn(),
|
|
saveMetaMock: vi.fn(),
|
|
initLbugMock: vi.fn(),
|
|
closeLbugMock: vi.fn(),
|
|
executeQueryMock: vi.fn(),
|
|
executeWithReusedStatementMock: vi.fn(),
|
|
fetchExistingEmbeddingHashesMock: vi.fn(),
|
|
runEmbeddingPipelineMock: vi.fn(),
|
|
resolveEmbeddingIdentityMock: vi.fn(),
|
|
installEmbeddingRuntimeMock: vi.fn(),
|
|
resolveEmbeddingRuntimeMock: vi.fn(),
|
|
isPrefixRuntimeLoadableMock: vi.fn(),
|
|
reapEmbeddingSidecarMock: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/git.js', () => ({
|
|
getGitRoot: () => '/tmp/emb-sync-repo',
|
|
}));
|
|
|
|
vi.mock('../../src/storage/index-lock.js', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/storage/index-lock.js')>()),
|
|
acquireIndexLock: (...args: unknown[]) => acquireIndexLockMock(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/storage/repo-manager.js', () => ({
|
|
getStoragePaths: (...args: unknown[]) => getStoragePathsMock(...args),
|
|
loadMeta: (...args: unknown[]) => loadMetaMock(...args),
|
|
saveMeta: (...args: unknown[]) => saveMetaMock(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
initLbug: (...args: unknown[]) => initLbugMock(...args),
|
|
closeLbug: (...args: unknown[]) => closeLbugMock(...args),
|
|
executeQuery: (...args: unknown[]) => executeQueryMock(...args),
|
|
executeWithReusedStatement: (...args: unknown[]) => executeWithReusedStatementMock(...args),
|
|
fetchExistingEmbeddingHashes: (...args: unknown[]) => fetchExistingEmbeddingHashesMock(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/core/embeddings/embedding-pipeline.js', () => ({
|
|
runEmbeddingPipeline: (...args: unknown[]) => runEmbeddingPipelineMock(...args),
|
|
}));
|
|
|
|
vi.mock('../../src/core/embeddings/embedding-identity.js', () => ({
|
|
resolveEmbeddingIdentity: () => resolveEmbeddingIdentityMock(),
|
|
}));
|
|
|
|
vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/core/embeddings/runtime-install.js')>()),
|
|
installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args),
|
|
resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(),
|
|
isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(),
|
|
}));
|
|
|
|
vi.mock('../../src/core/embeddings/embedding-sidecar-client.js', () => ({
|
|
reapEmbeddingSidecar: () => reapEmbeddingSidecarMock(),
|
|
}));
|
|
|
|
const IDENTITY = { model: 'test-model', dimensions: 768, provider: 'local' } as const;
|
|
|
|
const BASE_META = {
|
|
repoPath: '/tmp/emb-sync-repo',
|
|
lastCommit: 'abc123',
|
|
indexedAt: '2026-01-01T00:00:00.000Z',
|
|
stats: { embeddings: 1 },
|
|
};
|
|
|
|
const lockHandle = (release: () => void = releaseMock) => ({
|
|
record: {
|
|
v: 1 as const,
|
|
pid: 1,
|
|
hostname: 'h',
|
|
startTime: null,
|
|
token: 't',
|
|
invocationId: 'i',
|
|
acquiredAt: '',
|
|
},
|
|
release,
|
|
});
|
|
|
|
async function run(inputPath = '/tmp/emb-sync-repo') {
|
|
const { embeddingsSyncCommand } = await import('../../src/cli/embeddings-sync.js');
|
|
await embeddingsSyncCommand(inputPath);
|
|
}
|
|
|
|
describe('embeddingsSyncCommand writer safety (#3065)', () => {
|
|
const tmpDirs: string[] = [];
|
|
const originalEmbeddingUrl = process.env.GITNEXUS_EMBEDDING_URL;
|
|
const originalEmbeddingModel = process.env.GITNEXUS_EMBEDDING_MODEL;
|
|
|
|
async function store(kind: 'file' | 'missing' | 'dir' = 'file') {
|
|
const dir = await mkdtemp(path.join(tmpdir(), 'emb-sync-'));
|
|
tmpDirs.push(dir);
|
|
const lbugPath = path.join(dir, 'lbug');
|
|
const metaPath = path.join(dir, 'gitnexus.json');
|
|
if (kind === 'file') await writeFile(lbugPath, 'db');
|
|
if (kind === 'dir') await mkdir(lbugPath);
|
|
getStoragePathsMock.mockReturnValue({ lbugPath, metaPath });
|
|
return { dir, lbugPath, metaPath };
|
|
}
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
acquireIndexLockMock.mockReset().mockResolvedValue(lockHandle());
|
|
releaseMock.mockReset();
|
|
getStoragePathsMock.mockReset();
|
|
loadMetaMock.mockReset().mockResolvedValue({ ...BASE_META });
|
|
saveMetaMock.mockReset().mockResolvedValue(undefined);
|
|
initLbugMock.mockReset().mockResolvedValue(undefined);
|
|
closeLbugMock.mockReset().mockResolvedValue(undefined);
|
|
executeQueryMock.mockReset().mockResolvedValue([{ cnt: 2 }]);
|
|
executeWithReusedStatementMock.mockReset();
|
|
fetchExistingEmbeddingHashesMock.mockReset().mockResolvedValue(new Map([['n1', 'hash-1']]));
|
|
runEmbeddingPipelineMock.mockReset().mockResolvedValue({
|
|
nodesProcessed: 2,
|
|
chunksProcessed: 2,
|
|
failedNodeIds: [],
|
|
});
|
|
resolveEmbeddingIdentityMock.mockReset().mockReturnValue({ ...IDENTITY });
|
|
installEmbeddingRuntimeMock.mockReset().mockResolvedValue(undefined);
|
|
resolveEmbeddingRuntimeMock.mockReset().mockReturnValue({ source: 'package' });
|
|
isPrefixRuntimeLoadableMock.mockReset().mockReturnValue(true);
|
|
reapEmbeddingSidecarMock.mockReset();
|
|
delete process.env.GITNEXUS_EMBEDDING_URL;
|
|
delete process.env.GITNEXUS_EMBEDDING_MODEL;
|
|
});
|
|
|
|
afterEach(async () => {
|
|
if (originalEmbeddingUrl === undefined) delete process.env.GITNEXUS_EMBEDDING_URL;
|
|
else process.env.GITNEXUS_EMBEDDING_URL = originalEmbeddingUrl;
|
|
if (originalEmbeddingModel === undefined) delete process.env.GITNEXUS_EMBEDDING_MODEL;
|
|
else process.env.GITNEXUS_EMBEDDING_MODEL = originalEmbeddingModel;
|
|
await Promise.all(tmpDirs.splice(0).map((dir) => rm(dir, { recursive: true, force: true })));
|
|
});
|
|
|
|
it('acquires the index lock before re-reading metadata and releases it in finally', async () => {
|
|
const { dir } = await store();
|
|
const order: string[] = [];
|
|
acquireIndexLockMock.mockImplementation(async () => {
|
|
order.push('lock');
|
|
return lockHandle(() => {
|
|
order.push('release');
|
|
releaseMock();
|
|
});
|
|
});
|
|
loadMetaMock.mockImplementation(async () => {
|
|
order.push('loadMeta');
|
|
return { ...BASE_META };
|
|
});
|
|
initLbugMock.mockImplementation(async () => {
|
|
order.push('init');
|
|
});
|
|
|
|
await run();
|
|
|
|
expect(acquireIndexLockMock).toHaveBeenCalledWith(dir);
|
|
expect(order[0]).toBe('lock');
|
|
expect(order.indexOf('loadMeta')).toBeGreaterThan(order.indexOf('lock'));
|
|
expect(order.indexOf('init')).toBeGreaterThan(order.indexOf('loadMeta'));
|
|
expect(order.at(-1)).toBe('release');
|
|
});
|
|
|
|
it('refuses an unlocked embeddings sync', async () => {
|
|
await store();
|
|
acquireIndexLockMock.mockResolvedValue({ ...lockHandle(), lockFree: true as const });
|
|
await expect(run()).rejects.toThrow('refusing an unlocked embeddings sync');
|
|
expect(loadMetaMock).not.toHaveBeenCalled();
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to create a new database when the LadybugDB file is missing', async () => {
|
|
const { lbugPath } = await store('missing');
|
|
await expect(run()).rejects.toThrow(
|
|
`The LadybugDB graph store at ${lbugPath} is missing. Run gitnexus analyze first.`,
|
|
);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to open a LadybugDB path that is not a regular file', async () => {
|
|
const { lbugPath } = await store('dir');
|
|
await expect(run()).rejects.toThrow(
|
|
`The LadybugDB graph store at ${lbugPath} is not a usable database file. Run gitnexus analyze first.`,
|
|
);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails closed on an identity-mismatched partial checkpoint', async () => {
|
|
await store();
|
|
loadMetaMock.mockResolvedValue({
|
|
...BASE_META,
|
|
embeddingCheckpoint: {
|
|
at: '2026-01-01T00:00:00.000Z',
|
|
nodesProcessed: 1,
|
|
totalNodes: 2,
|
|
chunksProcessed: 1,
|
|
model: 'old-model',
|
|
dimensions: 768,
|
|
provider: 'local',
|
|
kind: 'partial',
|
|
pendingNodeIds: ['n2'],
|
|
},
|
|
});
|
|
resolveEmbeddingIdentityMock.mockReturnValue({
|
|
model: 'new-model',
|
|
dimensions: 768,
|
|
provider: 'http:deadbeef',
|
|
});
|
|
|
|
await expect(run()).rejects.toThrow(/Cannot sync embeddings: the index checkpoint was written/);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('fails closed on an identity-mismatched unverified-count checkpoint', async () => {
|
|
// `decideEmbeddingResume` abandons this kind before comparing identity, so
|
|
// the command's own gate is the only thing that keeps a foreign model from
|
|
// filling the remaining holes beside the old model's vectors.
|
|
await store();
|
|
loadMetaMock.mockResolvedValue({
|
|
...BASE_META,
|
|
embeddingCheckpoint: {
|
|
at: '2026-01-01T00:00:00.000Z',
|
|
nodesProcessed: 2,
|
|
totalNodes: 2,
|
|
chunksProcessed: 2,
|
|
model: 'old-model',
|
|
dimensions: 768,
|
|
provider: 'local',
|
|
kind: 'unverified-count',
|
|
pendingNodeIds: [],
|
|
},
|
|
});
|
|
resolveEmbeddingIdentityMock.mockReturnValue({
|
|
model: 'new-model',
|
|
dimensions: 768,
|
|
provider: 'http:deadbeef',
|
|
});
|
|
|
|
await expect(run()).rejects.toThrow(/Cannot sync embeddings: the index checkpoint was written/);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
expect(runEmbeddingPipelineMock).not.toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to sync when the recorded vector width differs from this run', async () => {
|
|
await store();
|
|
loadMetaMock.mockResolvedValue({ ...BASE_META, embeddingDims: 1 });
|
|
|
|
await expect(run()).rejects.toThrow(/Cannot sync embeddings: this index stores FLOAT\[1\]/);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('refuses to sync while the structural index is incomplete', async () => {
|
|
await store();
|
|
loadMetaMock.mockResolvedValue({
|
|
...BASE_META,
|
|
incrementalInProgress: { startedAt: '2026-01-01T00:00:00.000Z', toWriteCount: 3 },
|
|
});
|
|
|
|
await expect(run()).rejects.toThrow(
|
|
'The structural index is incomplete. Run gitnexus analyze --force first.',
|
|
);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
expect(saveMetaMock).not.toHaveBeenCalled();
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('persists an interrupted checkpoint from the pipeline checkpoint callbacks', async () => {
|
|
// The resume contract lives in these callbacks; a mock that never invokes
|
|
// them leaves the whole save path unexecuted.
|
|
await store();
|
|
runEmbeddingPipelineMock.mockImplementation(
|
|
async (
|
|
_executeQuery: unknown,
|
|
_executeWithReusedStatement: unknown,
|
|
_onProgress: unknown,
|
|
_config: unknown,
|
|
_signal: unknown,
|
|
_existing: unknown,
|
|
options: {
|
|
onCheckpointWindowStart: (checkpoint: Record<string, unknown>) => Promise<void>;
|
|
onCheckpoint: (checkpoint: Record<string, unknown>) => Promise<void>;
|
|
},
|
|
) => {
|
|
await options.onCheckpointWindowStart({
|
|
nodeIds: ['n2', 'n3'],
|
|
nodesProcessed: 1,
|
|
totalNodes: 3,
|
|
chunksProcessed: 1,
|
|
});
|
|
await options.onCheckpoint({ nodesProcessed: 3, totalNodes: 3, chunksProcessed: 3 });
|
|
return { nodesProcessed: 3, chunksProcessed: 3, failedNodeIds: [] };
|
|
},
|
|
);
|
|
|
|
await run();
|
|
|
|
type SavedMeta = {
|
|
stats?: { embeddings?: number };
|
|
embeddingCheckpoint?: { pendingNodeIds?: string[] };
|
|
};
|
|
const windowStart = saveMetaMock.mock.calls[0]?.[1] as SavedMeta;
|
|
expect(windowStart.embeddingCheckpoint?.pendingNodeIds).toEqual(['n2', 'n3']);
|
|
// The window marker carries no count, so the last known one must survive.
|
|
expect(windowStart.stats?.embeddings).toBe(1);
|
|
|
|
const windowEnd = saveMetaMock.mock.calls[1]?.[1] as SavedMeta;
|
|
expect(windowEnd.embeddingCheckpoint?.pendingNodeIds).toEqual([]);
|
|
expect(windowEnd.stats?.embeddings).toBe(2);
|
|
});
|
|
|
|
it('keeps a partial checkpoint when some nodes failed to embed', async () => {
|
|
await store();
|
|
runEmbeddingPipelineMock.mockResolvedValue({
|
|
nodesProcessed: 2,
|
|
chunksProcessed: 2,
|
|
failedNodeIds: ['n9'],
|
|
});
|
|
|
|
await run();
|
|
|
|
const saved = saveMetaMock.mock.calls.at(-1)?.[1] as {
|
|
embeddingCheckpoint?: { pendingNodeIds?: string[] };
|
|
};
|
|
expect(saved.embeddingCheckpoint).toBeDefined();
|
|
expect(saved.embeddingCheckpoint?.pendingNodeIds).toEqual(['n9']);
|
|
});
|
|
|
|
it('does not publish a missing count cell as zero', async () => {
|
|
await store();
|
|
executeQueryMock.mockResolvedValue([{}]);
|
|
|
|
await expect(run()).rejects.toThrow('Could not verify persisted embedding count.');
|
|
expect(saveMetaMock).toHaveBeenCalledTimes(1);
|
|
const saved = saveMetaMock.mock.calls[0]?.[1] as {
|
|
stats?: { embeddings?: number };
|
|
embeddingCheckpoint?: { kind?: string; pendingNodeIds?: string[] };
|
|
};
|
|
expect(saved.stats?.embeddings).toBe(1);
|
|
expect(saved.embeddingCheckpoint?.kind).toBe('unverified-count');
|
|
expect(saved.embeddingCheckpoint?.pendingNodeIds).toEqual([]);
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the pipeline error when closeLbug also rejects', async () => {
|
|
await store();
|
|
runEmbeddingPipelineMock.mockRejectedValue(new Error('pipeline boom'));
|
|
closeLbugMock.mockRejectedValue(new Error('close boom'));
|
|
|
|
await expect(run()).rejects.toThrow('pipeline boom');
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('keeps the pipeline error when sidecar reap also throws', async () => {
|
|
await store();
|
|
runEmbeddingPipelineMock.mockRejectedValue(new Error('pipeline boom'));
|
|
reapEmbeddingSidecarMock.mockImplementation(() => {
|
|
throw new Error('reap boom');
|
|
});
|
|
|
|
await expect(run()).rejects.toThrow('pipeline boom');
|
|
expect(releaseMock).toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not spawn npm on darwin/x64', async () => {
|
|
await store();
|
|
resolveEmbeddingRuntimeMock.mockReturnValue(null);
|
|
const orig = { platform: process.platform, arch: process.arch };
|
|
Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true });
|
|
Object.defineProperty(process, 'arch', { value: 'x64', configurable: true });
|
|
try {
|
|
await expect(run()).rejects.toThrow(/macOS Intel/);
|
|
expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled();
|
|
} finally {
|
|
Object.defineProperty(process, 'platform', { value: orig.platform, configurable: true });
|
|
Object.defineProperty(process, 'arch', { value: orig.arch, configurable: true });
|
|
}
|
|
});
|
|
|
|
it('auto-heals a missing stack without requesting CUDA binaries', async () => {
|
|
await store();
|
|
resolveEmbeddingRuntimeMock.mockReturnValue(null);
|
|
|
|
await run();
|
|
|
|
expect(installEmbeddingRuntimeMock).toHaveBeenCalledTimes(1);
|
|
expect(installEmbeddingRuntimeMock.mock.calls[0]?.[0]).toEqual({});
|
|
expect(installEmbeddingRuntimeMock.mock.calls[0]?.[0]).not.toMatchObject({ cuda: true });
|
|
});
|
|
|
|
it('wraps a failed auto-install with the missing-stack recovery path', async () => {
|
|
await store();
|
|
resolveEmbeddingRuntimeMock.mockReturnValue(null);
|
|
installEmbeddingRuntimeMock.mockRejectedValue(new Error('npm install timed out'));
|
|
|
|
await expect(run()).rejects.toThrow(
|
|
/Could not install the embedding runtime[\s\S]*gitnexus embeddings install/,
|
|
);
|
|
expect(initLbugMock).not.toHaveBeenCalled();
|
|
});
|
|
|
|
it('does not statically import the embedding pipeline', async () => {
|
|
const { readFileSync } = await import('node:fs');
|
|
const src = readFileSync(new URL('../../src/cli/embeddings-sync.ts', import.meta.url), 'utf8');
|
|
expect(src).not.toMatch(/^import .*embedding-pipeline/m);
|
|
expect(src).toContain("await import('../core/embeddings/embedding-pipeline.js')");
|
|
});
|
|
|
|
it('loads existing hashes without materializing cached vectors', async () => {
|
|
await store();
|
|
const hashes = new Map([
|
|
['n1', 'h1'],
|
|
['n2', 'h2'],
|
|
]);
|
|
fetchExistingEmbeddingHashesMock.mockResolvedValue(hashes);
|
|
|
|
await run();
|
|
|
|
expect(fetchExistingEmbeddingHashesMock).toHaveBeenCalledTimes(1);
|
|
const existingArg = runEmbeddingPipelineMock.mock.calls[0]?.[5];
|
|
expect(existingArg).toBe(hashes);
|
|
});
|
|
});
|