import { afterEach, describe, expect, it, vi } from 'vitest'; import { displayWidth, doctorCommand, localEmbeddingDoctorStatus, leftoverBranchSlotDoctorLines, padDisplayEnd, nativeStatusLine, pageSizeDoctorLines, poolSizeDoctorLine, } from '../../src/cli/doctor.js'; import type { StaleBranchSlot } from '../../src/storage/stale-branch-slots.js'; import { setCliLanguage, t, type SupportedCliLanguage } from '../../src/cli/i18n/index.js'; import type { NativeCheckResult } from '../../src/core/lbug/native-check.js'; import { createTempDir } from '../helpers/test-db.js'; const nativeProbeState = vi.hoisted(() => ({ vectorLoaded: true, fts: { loaded: true } as { loaded: boolean; suppressed?: boolean; reason?: string; }, })); vi.mock('../../src/core/lbug/native-check.js', () => ({ checkLbugNative: () => ({ ok: true, binaryPath: '/synthetic/lbugjs.node' }), ftsAvailabilityLabel: (probe: { loaded: boolean; suppressed?: boolean }) => probe.loaded ? 'available' : probe.suppressed ? 'suppressed' : 'unavailable', probeFtsExtensionLoad: async () => nativeProbeState.fts, probeVectorExtensionLoad: async () => nativeProbeState.vectorLoaded ? { loaded: true } : { loaded: false, reason: 'synthetic VECTOR load failure' }, })); describe('doctor output formatting', () => { it('keeps ASCII padding equivalent to String.padEnd', () => { expect(displayWidth('OS:')).toBe(3); expect(padDisplayEnd('OS:', 10)).toBe('OS:'.padEnd(10)); }); it('pads CJK labels by terminal display width, not code-unit length', () => { const padded = padDisplayEnd('系统:', 10); expect(displayWidth('系统:')).toBe(6); expect(displayWidth(padded)).toBe(10); expect(padded).toBe('系统: '); }); it('does not truncate labels that are already wider than the target width', () => { expect(padDisplayEnd('图存储:', 4)).toBe('图存储:'); }); }); describe('doctor VECTOR capability claims', () => { const ENV_KEYS = [ 'GITNEXUS_EMBEDDING_URL', 'GITNEXUS_EMBEDDING_MODEL', 'GITNEXUS_EMBEDDING_DIMS', ] as const; const savedEnv = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]])); const renderDoctor = async ( vectorLoaded: boolean, language: SupportedCliLanguage = 'en', ): Promise => { nativeProbeState.vectorLoaded = vectorLoaded; setCliLanguage(language); process.env.GITNEXUS_EMBEDDING_URL = 'http://127.0.0.1:9/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'synthetic-doctor'; process.env.GITNEXUS_EMBEDDING_DIMS = '384'; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); await doctorCommand(); return log.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); }; afterEach(() => { nativeProbeState.vectorLoaded = true; nativeProbeState.fts = { loaded: true }; setCliLanguage(null); vi.restoreAllMocks(); for (const key of ENV_KEYS) { if (savedEnv[key] === undefined) delete process.env[key]; else process.env[key] = savedEnv[key]; } }); it('reports a loaded extension without claiming a repository index exists', async () => { const output = await renderDoctor(true); expect(output).toContain('VECTOR extension: available'); expect(output).toContain( 'Semantic support: vector-index capable (repository index not checked)', ); expect(output).not.toContain('VECTOR index:'); expect(output).not.toContain('Semantic mode:'); }); it('reports exact-scan capability when the extension cannot load', async () => { const output = await renderDoctor(false); expect(output).toContain('VECTOR extension: unavailable'); expect(output).toContain('Semantic support: exact-scan only (VECTOR extension unavailable)'); }); it('keeps the corrected capability claims localized', async () => { const output = await renderDoctor(true, 'zh-CN'); expect(output).toContain('VECTOR 扩展:'); expect(output).toContain('语义支持:'); expect(output).toContain('支持向量索引(未检查仓库索引)'); }); }); describe('doctor FTS policy claims (U12)', () => { afterEach(() => { nativeProbeState.fts = { loaded: true }; setCliLanguage(null); vi.restoreAllMocks(); }); it('reports suppressed-by-policy, not unavailable, when the probe is suppressed', async () => { nativeProbeState.fts = { loaded: false, suppressed: true, reason: 'suppressed by policy GITNEXUS_LBUG_EXTENSION_INSTALL=never', }; const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); await doctorCommand(); const output = log.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); expect(output).toMatch(/Full-text search:\s+suppressed/); expect(output).toContain('suppressed by policy GITNEXUS_LBUG_EXTENSION_INSTALL=never'); expect(output).not.toMatch(/Full-text search:\s+unavailable/); }); it('prints both serve/query and analyze extension install policies', async () => { const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); await doctorCommand(); const output = log.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); expect(output).toMatch(/serve\/query=/); expect(output).toMatch(/analyze=/); }); }); describe('doctor embedding-runtime support status', () => { it('flags local embeddings as unavailable on macOS Intel (darwin/x64)', () => { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: false, platform: 'darwin', arch: 'x64', }); expect(status).toBe('✗ local embeddings unavailable on darwin/x64'); expect(detail).not.toBeNull(); expect(detail).toMatch(/macOS Intel/); expect(detail).toMatch(/native binding/i); }); it('reports local embeddings as supported on darwin/arm64, linux/x64, and win32/x64', () => { for (const [platform, arch] of [ ['darwin', 'arm64'], ['linux', 'x64'], ['win32', 'x64'], ] as Array<[NodeJS.Platform, NodeJS.Architecture]>) { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: false, platform, arch, resolution: { source: 'package' }, }); expect(status).toBe('✓ local embeddings supported'); expect(detail).toBeNull(); } }); it('reports HTTP backend as configured and never blocks on platform', () => { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: true, platform: 'darwin', arch: 'x64', }); expect(status).toBe('✓ http endpoint configured'); expect(detail).toBeNull(); }); it('flags a missing local embedding stack with install guidance', () => { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: false, platform: 'linux', arch: 'x64', resolution: null, }); expect(status).toBe('✗ local embedding stack not installed'); expect(detail).toContain('gitnexus embeddings install'); expect(detail).not.toContain('ONNXRUNTIME_NODE_INSTALL=skip'); }); it('reports a package-sourced stack as supported regardless of Node loadability', () => { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: false, platform: 'linux', arch: 'x64', resolution: { source: 'package' }, prefixLoadable: false, }); expect(status).toBe('✓ local embeddings supported'); expect(detail).toBeNull(); }); it('flags a prefix-sourced stack that this Node cannot load (#2372)', () => { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: false, platform: 'linux', arch: 'x64', resolution: { source: 'runtime-prefix' }, prefixLoadable: false, }); expect(status).toBe('✗ embedding stack installed in the prefix but not loadable on this Node'); expect(detail).toContain('module.registerHooks'); }); it('reports a prefix-sourced stack as supported when this Node can load it', () => { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: false, platform: 'linux', arch: 'x64', resolution: { source: 'runtime-prefix' }, prefixLoadable: true, }); expect(status).toBe('✓ local embeddings supported'); expect(detail).toBeNull(); }); it('prefers the platform blocker over the missing-stack report on macOS Intel', () => { const { status } = localEmbeddingDoctorStatus({ httpMode: false, platform: 'darwin', arch: 'x64', resolution: null, }); expect(status).toBe('✗ local embeddings unavailable on darwin/x64'); }); it('never reports a missing stack in HTTP mode', () => { const { status, detail } = localEmbeddingDoctorStatus({ httpMode: true, resolution: null, }); expect(status).toBe('✓ http endpoint configured'); expect(detail).toBeNull(); }); }); describe('doctor page-size lines (#1231, #2424 review)', () => { it('warns on a non-4K page size with a pre-0.18.0 @ladybugdb/core', () => { const lines = pageSizeDoctorLines(16384, '0.17.1'); expect(lines).toHaveLength(2); expect(lines[0]).toBe(` ${padDisplayEnd('page size', 10)}16384`); // Byte-identical to the pre-extraction inline rendering — guards the // helper extraction against output drift. expect(lines[1]).toBe( ` ${padDisplayEnd('', 10)}⚠ non-4K page size with @ladybugdb/core < 0.18.0 — ` + `'gitnexus analyze' may fail during COPY (#1231). Upgrade gitnexus (npm install -g gitnexus@latest).`, ); }); it.each([ ['page-size-aware LadybugDB', 16384, '0.18.0'], ['a 4 KiB page size', 4096, '0.17.1'], ])('prints the page size without a warning for %s', (_label, pageSize, version) => { const lines = pageSizeDoctorLines(pageSize, version); expect(lines).toHaveLength(1); expect(lines[0]).toContain('page size'); expect(lines[0]).toContain(String(pageSize)); }); it('prints nothing when the page size is unknown', () => { expect(pageSizeDoctorLines(undefined, '0.17.1')).toHaveLength(0); }); it('names an unknown version instead of asserting "< 0.18.0" about it', () => { const lines = pageSizeDoctorLines(16384, undefined); expect(lines).toHaveLength(2); expect(lines[1]).toContain('an unknown @ladybugdb/core version (may predate 0.18.0)'); expect(lines[1]).not.toContain('with @ladybugdb/core < 0.18.0'); expect(lines[1]).toContain('npm install -g gitnexus@latest'); }); }); describe('doctor pool-size line (#2631)', () => { const MiB = 1024 * 1024; it('prints the hintless pool in MiB with no env note when the env var is unset', () => { expect(poolSizeDoctorLine(2048 * MiB, undefined)).toBe( ` ${padDisplayEnd('pool size', 10)}2048 MiB`, ); }); it('marks an operator-supplied absolute value as an env override, with no scaling suffix', () => { expect(poolSizeDoctorLine(4096 * MiB, String(4096 * MiB))).toBe( ` ${padDisplayEnd('pool size', 10)}4096 MiB (env override)`, ); }); it('labels the 0 sentinel as the native default instead of "0 MiB"', () => { expect(poolSizeDoctorLine(0, '0')).toBe( ` ${padDisplayEnd('pool size', 10)}native 80% of RAM (env override)`, ); }); }); // #2672: every failed check used to print "lbugjs.node missing", including the // glibc case where the binary is present and merely unloadable — contradicting // the detail printed directly beneath it and sending users to reinstall a file // they already had. describe('doctor native status line (#2672)', () => { const nativeStatusCases: ReadonlyArray = [ ['a loaded binary', { ok: true, binaryPath: '/x/lbugjs.node' }, '✓ lbugjs.node loaded'], [ 'an uninstalled package', { ok: false, kind: 'package_missing', message: 'x' }, '✗ @ladybugdb/core not installed', ], [ 'an absent binary', { ok: false, kind: 'binary_missing', binaryPath: '/x/lbugjs.node', message: 'x' }, '✗ lbugjs.node missing', ], [ 'a present-but-unloadable binary (glibc too old, truncated download)', { ok: false, kind: 'load_failed', binaryPath: '/x/lbugjs.node', message: 'x' }, '✗ lbugjs.node present but failed to load', ], [ 'a prebuilt that exists but could not be copied into a read-only node_modules', { ok: false, kind: 'binary_unwritable', binaryPath: '/x/lbugjs.node', message: 'x' }, '✗ lbugjs.node not installed (node_modules not writable)', ], ['a failure with no kind recorded', { ok: false, message: 'x' }, '✗ lbugjs.node missing'], ]; it.each(nativeStatusCases)('reports %s', (_name, check, expected) => { expect(nativeStatusLine(check)).toBe(` ${padDisplayEnd('native', 10)}${expected}`); }); }); describe('doctor survives a malformed GITNEXUS_EMBEDDING_DIMS (#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.restoreAllMocks(); for (const key of ENV_KEYS) { if (savedEnv[key] === undefined) { delete process.env[key]; } else { process.env[key] = savedEnv[key]; } } }); it('does not crash at the unguarded isHttpMode() call sites', async () => { process.env.GITNEXUS_EMBEDDING_URL = 'http://test:8080/v1'; process.env.GITNEXUS_EMBEDDING_MODEL = 'test-model'; process.env.GITNEXUS_EMBEDDING_DIMS = '1024abc'; vi.spyOn(console, 'log').mockImplementation(() => undefined); // Before the isHttpMode() root-cause fix (#2385) this threw at doctor.ts:167 // (isHttpMode -> readConfig -> throw on the malformed DIMS); now the presence // probe never throws, so `gitnexus doctor` completes and reports the backend. await expect(doctorCommand()).resolves.toBeUndefined(); }); }); describe('leftoverBranchSlotDoctorLines (#3331)', () => { const slot = (overrides: Partial): StaleBranchSlot => ({ branch: 'feature/x', dir: '/tmp/branches/feature_x', sizeBytes: 1024, reason: 'ref-missing', ...overrides, }); it('returns no lines when there are no leftover slots', () => { expect(leftoverBranchSlotDoctorLines([])).toEqual([]); }); it('prints branch, reason, size, total, and the clean --stale reclaim line', () => { const lines = leftoverBranchSlotDoctorLines([slot({ sizeBytes: 4_800_000 })]); expect(lines[0]).toBe(t('doctor.orphanedBranches')); expect(lines.join('\n')).toContain('feature/x'); expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing')); expect(lines.join('\n')).toContain('4.6 MB'); expect(lines.join('\n')).toContain(t('doctor.orphanedBranches.reclaim')); expect(lines.join('\n')).toContain('gitnexus clean --stale'); }); it('prints retry-git copy instead of reclaim when heads cannot be listed (#3337)', () => { const lines = leftoverBranchSlotDoctorLines([ slot({ reason: 'heads-unavailable', sizeBytes: 2048 }), slot({ branch: 'other', reason: 'ref-missing', sizeBytes: 4096 }), ]); expect(lines).toEqual([t('clean.stale.headsUnavailable')]); expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches')); expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim')); }); it('prints only listingFailed when listing-failed is mixed with ref-missing', () => { const lines = leftoverBranchSlotDoctorLines([ slot({ reason: 'listing-failed', branch: '', dir: null, sizeBytes: 0 }), slot({ reason: 'ref-missing' }), ]); expect(lines).toEqual([t('clean.stale.listingFailed')]); expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches')); expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim')); }); it('prints heading and probe-failed row without reclaim', () => { const lines = leftoverBranchSlotDoctorLines([slot({ reason: 'probe-failed' })]); expect(lines[0]).toBe(t('doctor.orphanedBranches')); expect(lines.join('\n')).toContain('feature/x'); expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed')); expect(lines.join('\n')).not.toContain(t('doctor.orphanedBranches.reclaim')); }); it('includes reclaim when probe-failed is mixed with ref-missing', () => { const lines = leftoverBranchSlotDoctorLines([ slot({ reason: 'probe-failed' }), slot({ branch: 'other', reason: 'ref-missing' }), ]); expect(lines[0]).toBe(t('doctor.orphanedBranches')); expect(lines.join('\n')).toContain(t('clean.stale.reason.probeFailed')); expect(lines.join('\n')).toContain(t('clean.stale.reason.refMissing')); expect(lines.join('\n')).toContain(t('doctor.orphanedBranches.reclaim')); }); it('does not print leftover slots when cwd is not an indexed repo', async () => { const tmp = await createTempDir(); try { vi.spyOn(process, 'cwd').mockReturnValue(tmp.dbPath); const log = vi.spyOn(console, 'log').mockImplementation(() => undefined); await doctorCommand(); const output = log.mock.calls.map((args) => args.map(String).join(' ')).join('\n'); expect(output).toContain(t('doctor.runtime')); expect(output).not.toContain(t('doctor.orphanedBranches')); } finally { vi.restoreAllMocks(); await tmp.cleanup(); } }); });