mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-29 06:32:28 +00:00
Some checks failed
Devcontainer Smoke / Build devcontainer image (push) Has been cancelled
Devcontainer Smoke / Config-transform unit tests (push) Has been cancelled
Skill copy sync / shipped skills drift guard (push) Has been cancelled
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Publish / Classify release event (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
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
275 lines
9.8 KiB
TypeScript
275 lines
9.8 KiB
TypeScript
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
|
|
const runFullAnalysisMock = vi.fn();
|
|
|
|
vi.mock('../../src/core/run-analyze.js', () => ({
|
|
runFullAnalysis: runFullAnalysisMock,
|
|
}));
|
|
|
|
// Flag validation must not install the optional runtime from the npm registry.
|
|
// Runtime readiness and installation have their own analyze-local-embedding-error tests.
|
|
vi.mock('../../src/core/embeddings/runtime-support.js', async (importOriginal) => ({
|
|
...(await importOriginal<typeof import('../../src/core/embeddings/runtime-support.js')>()),
|
|
assessLocalEmbeddingRuntime: () => ({ status: 'ready' }),
|
|
}));
|
|
|
|
vi.mock('../../src/core/lbug/lbug-adapter.js', () => ({
|
|
closeLbug: vi.fn(async () => undefined),
|
|
closeLbugBeforeExit: vi.fn(async () => undefined),
|
|
isLbugReady: vi.fn(() => false),
|
|
}));
|
|
|
|
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),
|
|
}));
|
|
|
|
// These tests invoke analyzeCommand directly (programmatic-call path), which
|
|
// bypasses the commander preAction hook. So they exercise analyzeCommandImpl's
|
|
// own validation/normalization and the confirmation-message gating — the
|
|
// direct-call half of the fixes. The hook half — the dims crash-path on the
|
|
// REAL program.parse path — is exercised end-to-end in
|
|
// test/integration/analyze-embedding-flags-e2e.test.ts (it would have caught
|
|
// the original raw-crash regression). The canonical dims normalization is
|
|
// unit-tested in embedding-dims.test.ts. (The postAction DIMS baseline restore
|
|
// is in-process-parseAsync-only and remains covered by code-read.)
|
|
const EMBED_ENV_KEYS = [
|
|
'GITNEXUS_EMBEDDING_URL',
|
|
'GITNEXUS_EMBEDDING_MODEL',
|
|
'GITNEXUS_EMBEDDING_API_KEY',
|
|
'GITNEXUS_EMBEDDING_DIMS',
|
|
] as const;
|
|
|
|
describe('analyzeCommand custom embedding endpoint flags', () => {
|
|
const savedEnv: Record<string, string | undefined> = {};
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
runFullAnalysisMock.mockReset();
|
|
runFullAnalysisMock.mockResolvedValue({
|
|
repoName: 'repo',
|
|
repoPath: '/repo',
|
|
stats: {},
|
|
alreadyUpToDate: true,
|
|
});
|
|
process.exitCode = undefined;
|
|
process.env.NODE_OPTIONS = `${process.env.NODE_OPTIONS ?? ''} --max-old-space-size=8192`.trim();
|
|
// Start every case from a clean embedding env so ambient values can't mask
|
|
// a regression (and restore the host's afterwards).
|
|
for (const k of EMBED_ENV_KEYS) {
|
|
savedEnv[k] = process.env[k];
|
|
delete process.env[k];
|
|
}
|
|
});
|
|
|
|
afterEach(() => {
|
|
for (const k of EMBED_ENV_KEYS) {
|
|
if (savedEnv[k] === undefined) delete process.env[k];
|
|
else process.env[k] = savedEnv[k];
|
|
}
|
|
});
|
|
|
|
const importCmd = async () => (await import('../../src/cli/analyze.js')).analyzeCommand;
|
|
|
|
const captureStderr = () => {
|
|
const spy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
|
return {
|
|
text: () => spy.mock.calls.map(([c]) => (typeof c === 'string' ? c : c.toString())).join(''),
|
|
restore: () => spy.mockRestore(),
|
|
};
|
|
};
|
|
|
|
const captureLog = () => {
|
|
const spy = vi.spyOn(console, 'log').mockImplementation(() => undefined);
|
|
return {
|
|
text: () => spy.mock.calls.map((args) => args.map(String).join(' ')).join('\n'),
|
|
restore: () => spy.mockRestore(),
|
|
};
|
|
};
|
|
|
|
// ── URL validation (analyzeCommandImpl) ────────────────────────────
|
|
|
|
it.each([
|
|
['', 'must not be empty'],
|
|
['not a url', 'not a valid URL'],
|
|
['ftp://host/v1', 'must use http:// or https://'],
|
|
])('rejects --embedding-base-url %j before analysis', async (embeddingBaseUrl, expected) => {
|
|
const err = captureStderr();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, { embeddingBaseUrl });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
|
expect(err.text()).toContain(expected);
|
|
err.restore();
|
|
});
|
|
|
|
it('accepts a valid http(s) base URL with model and proceeds to analysis', async () => {
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, {
|
|
embeddings: true,
|
|
embeddingBaseUrl: 'http://10.0.0.1:11434/v1',
|
|
embeddingModel: 'qwen3',
|
|
});
|
|
|
|
expect(process.exitCode).not.toBe(1);
|
|
expect(runFullAnalysisMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// ── Model / token emptiness ────────────────────────────────────────
|
|
|
|
it('rejects a whitespace-only --embedding-model', async () => {
|
|
const err = captureStderr();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, { embeddingModel: ' ' });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
|
expect(err.text()).toContain('--embedding-model must not be empty');
|
|
err.restore();
|
|
});
|
|
|
|
it('rejects an empty --embedding-auth-token', async () => {
|
|
const err = captureStderr();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, { embeddingAuthToken: '' });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
|
expect(err.text()).toContain('--embedding-auth-token must not be empty');
|
|
err.restore();
|
|
});
|
|
|
|
// ── Dims validation on the direct-call path (finding #1) ───────────
|
|
|
|
it.each(['1e3', 'abc', '0', '-5', '3.5'])(
|
|
'rejects invalid --embedding-dims %j with a friendly error (no crash)',
|
|
async (embeddingDims) => {
|
|
const err = captureStderr();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, { embeddings: true, embeddingDims });
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
expect(runFullAnalysisMock).not.toHaveBeenCalled();
|
|
expect(err.text()).toContain('--embedding-dims must be a positive integer');
|
|
err.restore();
|
|
},
|
|
);
|
|
|
|
it('accepts a valid --embedding-dims and proceeds', async () => {
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, { embeddings: true, embeddingDims: '4096' });
|
|
|
|
expect(process.exitCode).not.toBe(1);
|
|
expect(runFullAnalysisMock).toHaveBeenCalledTimes(1);
|
|
});
|
|
|
|
// ── Credential masking in the confirmation line (finding #2) ───────
|
|
|
|
it('masks userinfo credentials in the confirmation line', async () => {
|
|
const log = captureLog();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, {
|
|
embeddings: true,
|
|
embeddingBaseUrl: 'http://user:s3cret@host:11434/v1',
|
|
embeddingModel: 'qwen3',
|
|
});
|
|
|
|
const out = log.text();
|
|
expect(out).toContain('Using custom embedding endpoint');
|
|
expect(out).toContain('host:11434');
|
|
expect(out).not.toContain('s3cret');
|
|
expect(out).not.toContain('user:');
|
|
log.restore();
|
|
});
|
|
|
|
// ── Confirmation gating (finding #5 / U6) ──────────────────────────
|
|
|
|
it('does not print "Using custom embedding endpoint" when --embeddings is absent', async () => {
|
|
const log = captureLog();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, {
|
|
embeddingBaseUrl: 'http://host/v1',
|
|
embeddingModel: 'qwen3',
|
|
});
|
|
|
|
const out = log.text();
|
|
expect(out).not.toContain('Using custom embedding endpoint');
|
|
expect(out).toContain('no embeddings will be generated');
|
|
log.restore();
|
|
});
|
|
|
|
it('does not print the confirmation on a plain run when env vars merely happen to be set', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://ambient/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'ambient';
|
|
const log = captureLog();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
// No embedding flags and no --embeddings: ambient env must not trigger it.
|
|
await analyzeCommand(undefined, {});
|
|
|
|
expect(log.text()).not.toContain('Using custom embedding endpoint');
|
|
log.restore();
|
|
});
|
|
|
|
// ── CLI flag overrides ambient env during the run ──────────────────
|
|
|
|
it('uses the CLI base URL over a pre-existing GITNEXUS_EMBEDDING_URL', async () => {
|
|
process.env.GITNEXUS_EMBEDDING_URL = 'http://old/v1';
|
|
process.env.GITNEXUS_EMBEDDING_MODEL = 'old-model';
|
|
const log = captureLog();
|
|
const analyzeCommand = await importCmd();
|
|
|
|
await analyzeCommand(undefined, {
|
|
embeddings: true,
|
|
embeddingBaseUrl: 'http://new/v1',
|
|
embeddingModel: 'new-model',
|
|
});
|
|
|
|
const out = log.text();
|
|
expect(out).toContain('http://new/v1');
|
|
expect(out).toContain('new-model');
|
|
expect(out).not.toContain('old/v1');
|
|
expect(out).not.toContain('old-model');
|
|
log.restore();
|
|
});
|
|
|
|
// ── Env snapshot/restore round-trip (finding #4 / U5) ──────────────
|
|
|
|
it('restores GITNEXUS_EMBEDDING_* to their pre-call state after returning', async () => {
|
|
const analyzeCommand = await importCmd();
|
|
|
|
// Clean env (deleted in beforeEach); a validation early-return path
|
|
// guarantees the finally-block restore runs.
|
|
await analyzeCommand(undefined, {
|
|
embeddingBaseUrl: 'http://host/v1',
|
|
embeddingModel: 'qwen3',
|
|
embeddingDims: 'bad', // fails after URL/MODEL were written → early return
|
|
});
|
|
|
|
expect(process.exitCode).toBe(1);
|
|
for (const k of EMBED_ENV_KEYS) {
|
|
expect(process.env[k]).toBeUndefined();
|
|
}
|
|
});
|
|
});
|