/** * Tests for the local-embedding-runtime blocker error path in the * `analyzeCommand` CLI (#1515 / #1987 review follow-up). * * On macOS Intel (darwin/x64) `initEmbedder` throws a GitNexus-authored blocker * before importing transformers.js. The analyze error handler must route that * message to a clean `local-embedding-unsupported` message (exit 1) — not the * generic MODULE_NOT_FOUND "installation may be corrupt" hint, and not the * network-heuristic HF-download branch — so the explicit platform message wins. * * Mirrors the shape of analyze-wal-error.test.ts: * - vi.mock the heavy dependencies so no real DB / git is touched * - drive `analyzeCommand` with a mocked `runFullAnalysis` that rejects * - assert on process.exitCode and the captured logger records */ import { beforeEach, describe, expect, it, vi } from 'vitest'; import { getLocalEmbeddingRuntimeBlocker } from '../../src/core/embeddings/runtime-support.js'; import type { LoggerCapture } from '../../src/core/logger.js'; async function withCapturedLogger(fn: (cap: LoggerCapture) => Promise): Promise { const { _captureLogger } = await import('../../src/core/logger.js'); const cap = _captureLogger(); try { return await fn(cap); } finally { cap.restore(); } } const runFullAnalysisMock = vi.fn(); // Controllable so the dual-match scenario can force the network heuristic to // also match the blocker error and prove the blocker branch still wins. const isHfDownloadFailureMock = vi.fn(() => false); // Drive analyze's auto-install / capability gate (#2372). Defaults keep the // existing tests on the happy path (package-sourced, loadable → gate skipped). const resolveEmbeddingRuntimeMock = vi.fn<() => { source: string } | null>(() => ({ source: 'package', })); const isPrefixRuntimeLoadableMock = vi.fn(() => true); const installEmbeddingRuntimeMock = vi.fn(async () => undefined); vi.mock('../../src/core/embeddings/runtime-install.js', async (importOriginal) => ({ ...(await importOriginal()), resolveEmbeddingRuntime: () => resolveEmbeddingRuntimeMock(), isPrefixRuntimeLoadable: () => isPrefixRuntimeLoadableMock(), installEmbeddingRuntime: (...args: unknown[]) => installEmbeddingRuntimeMock(...args), getEmbeddingRuntimeDir: () => '/fake/embedding-runtime', })); vi.mock('../../src/core/run-analyze.js', () => ({ runFullAnalysis: runFullAnalysisMock, })); vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({ closeLbug: vi.fn(async () => undefined), closeLbugBeforeExit: vi.fn(async () => undefined), isLbugReady: vi.fn(() => false), // Stub class for the CLI's `err instanceof LbugWipeError` branch (#2409, // tri-review 4669518496 P2-4): instanceof must evaluate (not TypeError on // an undefined binding) and correctly NOT claim this suite's errors. Same // pattern as the RegistryNameCollisionError stub below. LbugWipeError: class LbugWipeError extends Error {}, })); vi.mock('../../src/storage/repo-manager.js', () => ({ getStoragePaths: vi.fn(() => ({ storagePath: '.gitnexus', lbugPath: '.gitnexus/lbug' })), getGlobalRegistryPath: vi.fn(() => 'registry.json'), RegistryNameCollisionError: class RegistryNameCollisionError extends Error {}, AnalysisNotFinalizedError: class AnalysisNotFinalizedError extends Error {}, assertAnalysisFinalized: vi.fn(async () => undefined), })); vi.mock('../../src/storage/git.js', () => ({ getGitRoot: vi.fn(() => '/repo'), hasGitDir: vi.fn(() => true), })); vi.mock('../../src/core/ingestion/utils/max-file-size.js', () => ({ getMaxFileSizeBannerMessage: vi.fn(() => null), })); // analyze.ts imports isHfDownloadFailure from hf-env.js, which transitively // pulls gitnexus-shared. Mock it to break the chain and to drive the // blocker-vs-HF ordering test below. isLocalEmbeddingRuntimeBlockerMessage // (runtime-support.js) is intentionally NOT mocked — the real branch must fire. vi.mock('../../src/core/embeddings/hf-env.js', () => ({ isHfDownloadFailure: isHfDownloadFailureMock, })); const blockerMessage = getLocalEmbeddingRuntimeBlocker({ platform: 'darwin', arch: 'x64', }) as string; describe('analyzeCommand local-embedding-runtime error handling', () => { beforeEach(() => { vi.resetModules(); runFullAnalysisMock.mockReset(); isHfDownloadFailureMock.mockReset(); isHfDownloadFailureMock.mockReturnValue(false); resolveEmbeddingRuntimeMock.mockReset(); resolveEmbeddingRuntimeMock.mockReturnValue({ source: 'package' }); isPrefixRuntimeLoadableMock.mockReset(); isPrefixRuntimeLoadableMock.mockReturnValue(true); installEmbeddingRuntimeMock.mockReset(); installEmbeddingRuntimeMock.mockResolvedValue(undefined); process.exitCode = undefined; // Ensure ensureHeap() short-circuits (heap already at target size) process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); }); it('routes the blocker to a clean local-embedding-unsupported message (exit 1)', async () => { runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage)); await withCapturedLogger(async (cap) => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); expect(process.exitCode).toBe(1); const records = cap.records(); const blockerRecord = records.find((r) => r.recoveryHint === 'local-embedding-unsupported'); expect(blockerRecord).toBeDefined(); expect(typeof blockerRecord?.msg === 'string' && blockerRecord.msg).toMatch(/macOS Intel/); }); }); it('does NOT fall through to the module-not-found "installation may be corrupt" hint', async () => { runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage)); await withCapturedLogger(async (cap) => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); const records = cap.records(); const corruptRecord = records.find( (r) => typeof r.msg === 'string' && r.msg.includes('installation may be corrupt'), ); expect(corruptRecord).toBeUndefined(); }); }); it('wins over the HF-download branch even when isHfDownloadFailure also matches (R4 ordering)', async () => { // Force the network heuristic to claim the blocker error too. Because the // blocker check is ordered before isHfDownloadFailure, the blocker branch // must still win — this is the only scenario that falsifies a wrong order. isHfDownloadFailureMock.mockReturnValue(true); runFullAnalysisMock.mockRejectedValue(new Error(blockerMessage)); await withCapturedLogger(async (cap) => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); expect(process.exitCode).toBe(1); const records = cap.records(); expect(records.some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe(true); // The HF-download branch must NOT have fired. expect(records.some((r) => r.recoveryHint === 'hf-endpoint-unreachable')).toBe(false); }); }); it('does NOT route unrelated errors through the local-embedding branch', async () => { runFullAnalysisMock.mockRejectedValue( new Error('Some unexpected failure unrelated to embeddings'), ); await withCapturedLogger(async (cap) => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); expect(process.exitCode).toBe(1); const records = cap.records(); expect(records.some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe(false); }); }); }); describe('analyzeCommand — prefix-runtime capability gate (#2372)', () => { beforeEach(() => { vi.resetModules(); runFullAnalysisMock.mockReset(); isHfDownloadFailureMock.mockReset().mockReturnValue(false); resolveEmbeddingRuntimeMock.mockReset(); isPrefixRuntimeLoadableMock.mockReset(); installEmbeddingRuntimeMock.mockReset().mockResolvedValue(undefined); process.exitCode = undefined; process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim(); }); it('does not spawn npm on darwin/x64 even when the stack is missing', async () => { const orig = { platform: process.platform, arch: process.arch }; Object.defineProperty(process, 'platform', { value: 'darwin', configurable: true }); Object.defineProperty(process, 'arch', { value: 'x64', configurable: true }); resolveEmbeddingRuntimeMock.mockReturnValue(null); isPrefixRuntimeLoadableMock.mockReturnValue(true); try { await withCapturedLogger(async (cap) => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); expect(process.exitCode).toBe(1); expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled(); expect(cap.records().some((r) => r.recoveryHint === 'local-embedding-unsupported')).toBe( true, ); }); } finally { Object.defineProperty(process, 'platform', { value: orig.platform, configurable: true }); Object.defineProperty(process, 'arch', { value: orig.arch, configurable: true }); } }); it('fails fast without installing when nothing is installed and the prefix is unloadable', async () => { resolveEmbeddingRuntimeMock.mockReturnValue(null); isPrefixRuntimeLoadableMock.mockReturnValue(false); await withCapturedLogger(async (cap) => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); expect(process.exitCode).toBe(1); expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled(); const record = cap.records().find((r) => r.recoveryHint === 'local-embedding-stack-missing'); expect(typeof record?.msg === 'string' && record.msg).toMatch(/module\.registerHooks/); }); }); it('fails fast on a resolved-but-unloadable prefix (the previously-uncaught state)', async () => { resolveEmbeddingRuntimeMock.mockReturnValue({ source: 'runtime-prefix' }); isPrefixRuntimeLoadableMock.mockReturnValue(false); await withCapturedLogger(async (cap) => { const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); expect(process.exitCode).toBe(1); expect(installEmbeddingRuntimeMock).not.toHaveBeenCalled(); expect(cap.records().some((r) => r.recoveryHint === 'local-embedding-stack-missing')).toBe( true, ); }); }); it('installs with a shorter-than-default timeout when nothing is installed and the prefix is loadable', async () => { resolveEmbeddingRuntimeMock.mockReturnValue(null); isPrefixRuntimeLoadableMock.mockReturnValue(true); // Reject afterwards so analyze bails right after the install, isolating the gate. runFullAnalysisMock.mockRejectedValue(new Error('stop after install')); await withCapturedLogger(async () => { const { ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS } = await import('../../src/core/embeddings/runtime-install.js'); const { analyzeCommand } = await import('../../src/cli/analyze.js'); await analyzeCommand(undefined, { embeddings: true }); expect(installEmbeddingRuntimeMock).toHaveBeenCalledTimes(1); // analyze must pass the shorter deadline so a blackholed proxy can't stall // the run for the 10-minute default. const timeoutArg = installEmbeddingRuntimeMock.mock.calls[0][1] as number; expect(timeoutArg).toBe(ANALYZE_EMBEDDING_INSTALL_TIMEOUT_MS); }); }); });