mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-09-29 01:41:42 +00:00
test(group,cli): cover depth traversal, repoPaths, and CLI commands
Integration: - group-graph.test.ts: chain apps/web -> libs/shared -> libs/deep exercises findConnections recursion at depth 1/2/5, plus A -> B -> A cycle guard via the visited set. - auto-discover.test.ts: explicit repoPaths mode (happy path, unindexed- path error, basename fallback when resolveRepo throws). Unit: - cli-group.test.ts: 25 tests mocking LocalBackend/storage/sync across create, auto-discover, repos, add, remove, list, status, sync, graph, query, contracts. - cli-status.test.ts: 6 tests covering git-repo guard, stale-Kuzu prompt, up-to-date vs stale report. - cli-tool.test.ts: 16 tests covering query/context/impact/cypher arg validation, optional-param branches, backend-init failure, EPIPE/EACCES fallbacks in output(). Coverage delta vs main: +1.33% statements, +0.77% branches, +1.22% functions, +1.46% lines — clears the +0.5% review bar on all metrics. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
f450a3d084
commit
1cc6888e19
5 changed files with 1204 additions and 0 deletions
|
|
@ -193,4 +193,73 @@ console.log(formatDate(new Date()));`,
|
|||
// Should only find 2 (shared-utils and web-app), not 3
|
||||
expect(result.repoCount).toBe(2);
|
||||
});
|
||||
|
||||
describe('explicit repoPaths mode', () => {
|
||||
it('discovers and groups repos from an explicit repoPaths list', async () => {
|
||||
const service = new GroupService(makeMockPort());
|
||||
const repoA = path.join(tmpDir, 'repos', 'shared-utils');
|
||||
const repoB = path.join(tmpDir, 'repos', 'web-app');
|
||||
|
||||
const result = (await service.groupDiscover({
|
||||
repoPaths: [repoA, repoB],
|
||||
name: 'test-explicit',
|
||||
skipSync: true,
|
||||
})) as {
|
||||
group: string;
|
||||
repoCount: number;
|
||||
repos: Array<{ name: string; packageName: string | null }>;
|
||||
packageMappings: Record<string, string>;
|
||||
};
|
||||
|
||||
expect(result.group).toBe('test-explicit');
|
||||
expect(result.repoCount).toBe(2);
|
||||
const names = result.repos.map((r) => r.name).sort();
|
||||
expect(names).toEqual(['shared-utils', 'web-app']);
|
||||
expect(result.packageMappings['@test/shared-utils']).toBeDefined();
|
||||
expect(result.packageMappings['@test/web-app']).toBeDefined();
|
||||
|
||||
const groups = await listGroups(gitnexusHome);
|
||||
expect(groups).toContain('test-explicit');
|
||||
});
|
||||
|
||||
it('returns an error when one of the explicit repoPaths is not indexed', async () => {
|
||||
const unindexed = path.join(tmpDir, 'repos', 'unindexed');
|
||||
fs.mkdirSync(unindexed, { recursive: true });
|
||||
fs.writeFileSync(
|
||||
path.join(unindexed, 'package.json'),
|
||||
JSON.stringify({ name: 'unindexed' }),
|
||||
);
|
||||
|
||||
const service = new GroupService(makeMockPort());
|
||||
const result = (await service.groupDiscover({
|
||||
repoPaths: [path.join(tmpDir, 'repos', 'shared-utils'), unindexed],
|
||||
name: 'test-unindexed',
|
||||
skipSync: true,
|
||||
})) as { error: string };
|
||||
|
||||
expect(result.error).toContain('not indexed');
|
||||
expect(result.error).toContain(unindexed);
|
||||
});
|
||||
|
||||
it('falls back to directory basename when resolveRepo cannot find a handle', async () => {
|
||||
const port: GroupToolPort = {
|
||||
resolveRepo: async () => {
|
||||
throw new Error('not registered');
|
||||
},
|
||||
query: async () => ({ processes: [] }),
|
||||
};
|
||||
const service = new GroupService(port);
|
||||
const result = (await service.groupDiscover({
|
||||
repoPaths: [path.join(tmpDir, 'repos', 'shared-utils')],
|
||||
name: 'test-fallback',
|
||||
skipSync: true,
|
||||
})) as {
|
||||
repoCount: number;
|
||||
repos: Array<{ name: string }>;
|
||||
};
|
||||
|
||||
expect(result.repoCount).toBe(1);
|
||||
expect(result.repos[0].name).toBe('shared-utils');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -267,4 +267,204 @@ describe('Group graph traversal integration', () => {
|
|||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('depth traversal', () => {
|
||||
async function writeFixture(
|
||||
repos: Record<string, string>,
|
||||
registry: ContractRegistry,
|
||||
) {
|
||||
const { createRequire } = await import('node:module');
|
||||
const _require = createRequire(import.meta.url);
|
||||
const yaml = _require('js-yaml') as typeof import('js-yaml');
|
||||
const config = {
|
||||
version: 1,
|
||||
name: 'test-workspace',
|
||||
description: '',
|
||||
repos,
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: {
|
||||
http: false,
|
||||
grpc: false,
|
||||
topics: false,
|
||||
shared_libs: true,
|
||||
embedding_fallback: false,
|
||||
},
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
};
|
||||
fs.writeFileSync(path.join(groupDir, 'group.yaml'), yaml.dump(config), 'utf-8');
|
||||
await writeContractRegistry(groupDir, registry);
|
||||
}
|
||||
|
||||
function makeHandlePort(handles: Record<string, GroupRepoHandle>): GroupToolPort {
|
||||
return {
|
||||
resolveRepo: async (name?: string) => {
|
||||
if (!name || !handles[name]) throw new Error(`Repo not found: ${name}`);
|
||||
return handles[name];
|
||||
},
|
||||
query: async (_repo, params) => ({
|
||||
processes: [
|
||||
{ name: `proc-${(params as { query: string }).query}`, summary: 'mock' },
|
||||
],
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
describe('chain: apps/web → libs/shared → libs/deep', () => {
|
||||
const CHAIN_REGISTRY: ContractRegistry = {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoSnapshots: {
|
||||
'apps/web': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'a' },
|
||||
'libs/shared': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'b' },
|
||||
'libs/deep': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'c' },
|
||||
},
|
||||
missingRepos: [],
|
||||
contracts: [],
|
||||
crossLinks: [
|
||||
{
|
||||
from: {
|
||||
repo: 'apps/web',
|
||||
symbolUid: '',
|
||||
symbolRef: { filePath: 'src/app.ts', name: 'callShared' },
|
||||
},
|
||||
to: {
|
||||
repo: 'libs/shared',
|
||||
symbolUid: 'uid-shared',
|
||||
symbolRef: { filePath: 'src/shared.ts', name: 'shared' },
|
||||
},
|
||||
type: 'lib',
|
||||
contractId: 'lib::@test/shared::shared',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
},
|
||||
{
|
||||
from: {
|
||||
repo: 'libs/shared',
|
||||
symbolUid: 'uid-shared',
|
||||
symbolRef: { filePath: 'src/shared.ts', name: 'callDeep' },
|
||||
},
|
||||
to: {
|
||||
repo: 'libs/deep',
|
||||
symbolUid: 'uid-deep',
|
||||
symbolRef: { filePath: 'src/deep.ts', name: 'deep' },
|
||||
},
|
||||
type: 'lib',
|
||||
contractId: 'lib::@test/deep::deep',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const chainRepos = {
|
||||
'apps/web': 'web-app',
|
||||
'libs/shared': 'shared-utils',
|
||||
'libs/deep': 'deep-lib',
|
||||
};
|
||||
|
||||
const chainHandles: Record<string, GroupRepoHandle> = {
|
||||
'web-app': { id: 'web-app', name: 'web-app', repoPath: '/mock/web', storagePath: '/mock/web/.gitnexus' },
|
||||
'shared-utils': { id: 'shared-utils', name: 'shared-utils', repoPath: '/mock/shared', storagePath: '/mock/shared/.gitnexus' },
|
||||
'deep-lib': { id: 'deep-lib', name: 'deep-lib', repoPath: '/mock/deep', storagePath: '/mock/deep/.gitnexus' },
|
||||
};
|
||||
|
||||
beforeEach(async () => {
|
||||
await writeFixture(chainRepos, CHAIN_REGISTRY);
|
||||
});
|
||||
|
||||
it('depth 1 stops at direct cross-links and does not recurse', async () => {
|
||||
const service = new GroupService(makeHandlePort(chainHandles));
|
||||
const result = (await service.groupGraph({
|
||||
name: 'test-workspace',
|
||||
symbol: 'callShared',
|
||||
repo: 'web-app',
|
||||
depth: 1,
|
||||
})) as {
|
||||
totalCrossLinks: number;
|
||||
crossConnections: Array<{ remoteRepo: string }>;
|
||||
};
|
||||
|
||||
expect(result.totalCrossLinks).toBe(1);
|
||||
expect(result.crossConnections.map((c) => c.remoteRepo)).toEqual(['libs/shared']);
|
||||
});
|
||||
|
||||
it('depth 2 recurses through the intermediate repo to reach the transitive target', async () => {
|
||||
const service = new GroupService(makeHandlePort(chainHandles));
|
||||
const result = (await service.groupGraph({
|
||||
name: 'test-workspace',
|
||||
symbol: 'callShared',
|
||||
repo: 'web-app',
|
||||
depth: 2,
|
||||
})) as {
|
||||
totalCrossLinks: number;
|
||||
crossConnections: Array<{ remoteRepo: string }>;
|
||||
};
|
||||
|
||||
expect(result.totalCrossLinks).toBe(2);
|
||||
const repos = result.crossConnections.map((c) => c.remoteRepo).sort();
|
||||
expect(repos).toEqual(['libs/deep', 'libs/shared']);
|
||||
});
|
||||
|
||||
it('clamps depth > 2 to 2 (depth 5 behaves like depth 2)', async () => {
|
||||
const service = new GroupService(makeHandlePort(chainHandles));
|
||||
const result = (await service.groupGraph({
|
||||
name: 'test-workspace',
|
||||
symbol: 'callShared',
|
||||
repo: 'web-app',
|
||||
depth: 5,
|
||||
})) as { totalCrossLinks: number };
|
||||
|
||||
expect(result.totalCrossLinks).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
it('visited-set prevents infinite recursion on a cycle (A → B → A)', async () => {
|
||||
const cycleRegistry: ContractRegistry = {
|
||||
version: 1,
|
||||
generatedAt: new Date().toISOString(),
|
||||
repoSnapshots: {
|
||||
'apps/a': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'a' },
|
||||
'apps/b': { indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'b' },
|
||||
},
|
||||
missingRepos: [],
|
||||
contracts: [],
|
||||
crossLinks: [
|
||||
{
|
||||
from: { repo: 'apps/a', symbolUid: 'uid-a', symbolRef: { filePath: 'a.ts', name: 'a' } },
|
||||
to: { repo: 'apps/b', symbolUid: 'uid-b', symbolRef: { filePath: 'b.ts', name: 'b' } },
|
||||
type: 'lib',
|
||||
contractId: 'lib::a-to-b',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
},
|
||||
{
|
||||
from: { repo: 'apps/b', symbolUid: 'uid-b', symbolRef: { filePath: 'b.ts', name: 'b' } },
|
||||
to: { repo: 'apps/a', symbolUid: 'uid-a', symbolRef: { filePath: 'a.ts', name: 'a' } },
|
||||
type: 'lib',
|
||||
contractId: 'lib::b-to-a',
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
await writeFixture({ 'apps/a': 'repo-a', 'apps/b': 'repo-b' }, cycleRegistry);
|
||||
|
||||
const cyclePort = makeHandlePort({
|
||||
'repo-a': { id: 'repo-a', name: 'repo-a', repoPath: '/mock/a', storagePath: '/mock/a/.gitnexus' },
|
||||
'repo-b': { id: 'repo-b', name: 'repo-b', repoPath: '/mock/b', storagePath: '/mock/b/.gitnexus' },
|
||||
});
|
||||
|
||||
const service = new GroupService(cyclePort);
|
||||
const result = (await service.groupGraph({
|
||||
name: 'test-workspace',
|
||||
symbol: 'a',
|
||||
repo: 'repo-a',
|
||||
depth: 2,
|
||||
})) as { totalCrossLinks: number };
|
||||
|
||||
expect(result.totalCrossLinks).toBe(1);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
580
gitnexus/test/unit/cli-group.test.ts
Normal file
580
gitnexus/test/unit/cli-group.test.ts
Normal file
|
|
@ -0,0 +1,580 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { Command } from 'commander';
|
||||
|
||||
const mockGroupService = {
|
||||
groupDiscover: vi.fn(),
|
||||
groupStatus: vi.fn(),
|
||||
groupGraph: vi.fn(),
|
||||
groupQuery: vi.fn(),
|
||||
groupContracts: vi.fn(),
|
||||
};
|
||||
|
||||
const mockBackend = {
|
||||
init: vi.fn(async () => {}),
|
||||
dispose: vi.fn(async () => {}),
|
||||
getGroupService: vi.fn(() => mockGroupService),
|
||||
};
|
||||
|
||||
const mockCreateGroupDir = vi.fn();
|
||||
const mockGetGroupDir = vi.fn();
|
||||
const mockGetDefaultGitnexusDir = vi.fn();
|
||||
const mockListGroups = vi.fn();
|
||||
const mockReadContractRegistry = vi.fn();
|
||||
const mockLoadGroupConfig = vi.fn();
|
||||
const mockSyncGroup = vi.fn();
|
||||
const mockFsWriteFile = vi.fn();
|
||||
|
||||
vi.mock('../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
init = mockBackend.init;
|
||||
dispose = mockBackend.dispose;
|
||||
getGroupService = mockBackend.getGroupService;
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/group/storage.js', () => ({
|
||||
createGroupDir: mockCreateGroupDir,
|
||||
getGroupDir: mockGetGroupDir,
|
||||
getDefaultGitnexusDir: mockGetDefaultGitnexusDir,
|
||||
listGroups: mockListGroups,
|
||||
readContractRegistry: mockReadContractRegistry,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/group/config-parser.js', () => ({
|
||||
loadGroupConfig: mockLoadGroupConfig,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/core/group/sync.js', () => ({
|
||||
syncGroup: mockSyncGroup,
|
||||
}));
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
writeFile: mockFsWriteFile,
|
||||
default: { writeFile: mockFsWriteFile },
|
||||
}));
|
||||
|
||||
async function runCommand(args: string[]): Promise<void> {
|
||||
const { registerGroupCommands } = await import('../../src/cli/group.js');
|
||||
const program = new Command();
|
||||
program.exitOverride();
|
||||
registerGroupCommands(program);
|
||||
await program.parseAsync(['node', 'gitnexus', ...args]);
|
||||
}
|
||||
|
||||
describe('group CLI', () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
let errSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
process.exitCode = undefined;
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
|
||||
mockGetDefaultGitnexusDir.mockReturnValue('/home/.gitnexus');
|
||||
mockGetGroupDir.mockImplementation((home: string, name: string) => `${home}/groups/${name}`);
|
||||
});
|
||||
|
||||
describe('create', () => {
|
||||
it('creates a group dir and prints next-step instructions', async () => {
|
||||
mockCreateGroupDir.mockResolvedValueOnce('/home/.gitnexus/groups/team-a');
|
||||
|
||||
await runCommand(['group', 'create', 'team-a']);
|
||||
|
||||
expect(mockCreateGroupDir).toHaveBeenCalledWith('/home/.gitnexus', 'team-a', undefined);
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Created group "team-a"'),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --force to createGroupDir', async () => {
|
||||
mockCreateGroupDir.mockResolvedValueOnce('/home/.gitnexus/groups/team-a');
|
||||
|
||||
await runCommand(['group', 'create', 'team-a', '--force']);
|
||||
|
||||
expect(mockCreateGroupDir).toHaveBeenCalledWith('/home/.gitnexus', 'team-a', true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-discover', () => {
|
||||
it('calls groupDiscover with resolved directory and prints repos + package mappings', async () => {
|
||||
mockGroupService.groupDiscover.mockResolvedValueOnce({
|
||||
group: 'workspace',
|
||||
groupDir: '/home/.gitnexus/groups/workspace',
|
||||
repoCount: 2,
|
||||
repos: [
|
||||
{ name: 'shared-utils', packageName: '@test/shared' },
|
||||
{ name: 'web-app', packageName: null },
|
||||
],
|
||||
packageMappings: { '@test/shared': 'libs/shared' },
|
||||
synced: true,
|
||||
contracts: 5,
|
||||
crossLinks: 3,
|
||||
});
|
||||
|
||||
await runCommand(['group', 'auto-discover', '/repos']);
|
||||
|
||||
expect(mockGroupService.groupDiscover).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
directory: expect.stringContaining('repos'),
|
||||
name: 'workspace',
|
||||
force: false,
|
||||
skipSync: false,
|
||||
}),
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Discovering indexed repos'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Repos (2)'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('shared-utils (@test/shared)'));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('5 contracts, 3 cross-links'),
|
||||
);
|
||||
expect(mockBackend.dispose).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prints JSON when --json is set', async () => {
|
||||
const payload = { group: 'workspace', groupDir: '/g', repoCount: 0, repos: [], packageMappings: {} };
|
||||
mockGroupService.groupDiscover.mockResolvedValueOnce(payload);
|
||||
|
||||
await runCommand(['group', 'auto-discover', '/repos', '--json']);
|
||||
|
||||
const jsonCall = logSpy.mock.calls.find((c) =>
|
||||
typeof c[0] === 'string' && c[0].trim().startsWith('{'),
|
||||
);
|
||||
expect(jsonCall).toBeDefined();
|
||||
expect(JSON.parse(jsonCall![0] as string)).toEqual(payload);
|
||||
});
|
||||
|
||||
it('prints error and sets exitCode=1 on service error', async () => {
|
||||
mockGroupService.groupDiscover.mockResolvedValueOnce({ error: 'boom' });
|
||||
|
||||
await runCommand(['group', 'auto-discover', '/repos']);
|
||||
|
||||
expect(errSpy).toHaveBeenCalledWith('boom');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('repos', () => {
|
||||
it('resolves each repo path and calls groupDiscover with repoPaths', async () => {
|
||||
mockGroupService.groupDiscover.mockResolvedValueOnce({
|
||||
group: 'workspace',
|
||||
groupDir: '/g',
|
||||
repoCount: 2,
|
||||
repos: [],
|
||||
packageMappings: {},
|
||||
});
|
||||
|
||||
await runCommand(['group', 'repos', '/a', '/b', '--name', 'custom']);
|
||||
|
||||
expect(mockGroupService.groupDiscover).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
repoPaths: expect.arrayContaining([
|
||||
expect.stringContaining('/a'),
|
||||
expect.stringContaining('/b'),
|
||||
]),
|
||||
name: 'custom',
|
||||
force: false,
|
||||
skipSync: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('add', () => {
|
||||
it('adds a repo to the group yaml and prints sync reminder', async () => {
|
||||
mockLoadGroupConfig.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
name: 'team-a',
|
||||
description: '',
|
||||
repos: {},
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false },
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
|
||||
await runCommand(['group', 'add', 'team-a', 'apps/web', 'web-app']);
|
||||
|
||||
expect(mockFsWriteFile).toHaveBeenCalled();
|
||||
const writtenArg = mockFsWriteFile.mock.calls[0][1] as string;
|
||||
expect(writtenArg).toContain('apps/web');
|
||||
expect(writtenArg).toContain('web-app');
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Added web-app as "apps/web" to group "team-a"'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove', () => {
|
||||
it('removes a repo path from the group yaml', async () => {
|
||||
mockLoadGroupConfig.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
name: 'team-a',
|
||||
description: '',
|
||||
repos: { 'apps/web': 'web-app', 'libs/shared': 'shared-utils' },
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false },
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
|
||||
await runCommand(['group', 'remove', 'team-a', 'apps/web']);
|
||||
|
||||
expect(mockFsWriteFile).toHaveBeenCalled();
|
||||
const written = mockFsWriteFile.mock.calls[0][1] as string;
|
||||
expect(written).not.toContain('apps/web');
|
||||
expect(written).toContain('libs/shared');
|
||||
});
|
||||
|
||||
it('errors when the repo path is not present', async () => {
|
||||
mockLoadGroupConfig.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
name: 'team-a',
|
||||
description: '',
|
||||
repos: { 'libs/shared': 'shared-utils' },
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false },
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
|
||||
await runCommand(['group', 'remove', 'team-a', 'apps/web']);
|
||||
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('not found in group "team-a"'),
|
||||
);
|
||||
expect(process.exitCode).toBe(1);
|
||||
expect(mockFsWriteFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('list', () => {
|
||||
it('without name: prints all groups', async () => {
|
||||
mockListGroups.mockResolvedValueOnce(['team-a', 'team-b']);
|
||||
|
||||
await runCommand(['group', 'list']);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('Groups:');
|
||||
expect(logSpy).toHaveBeenCalledWith(' team-a');
|
||||
expect(logSpy).toHaveBeenCalledWith(' team-b');
|
||||
});
|
||||
|
||||
it('without name, empty: prints create instruction', async () => {
|
||||
mockListGroups.mockResolvedValueOnce([]);
|
||||
|
||||
await runCommand(['group', 'list']);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No groups configured'),
|
||||
);
|
||||
});
|
||||
|
||||
it('with name: prints repos and manifest links', async () => {
|
||||
mockLoadGroupConfig.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
name: 'team-a',
|
||||
description: 'A team group',
|
||||
repos: { 'apps/web': 'web-app' },
|
||||
links: [
|
||||
{ from: 'apps/web', to: 'services/api', type: 'http', contract: 'GET::/x', role: 'consumer' },
|
||||
],
|
||||
packages: {},
|
||||
detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false },
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
|
||||
await runCommand(['group', 'list', 'team-a']);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('Group: team-a');
|
||||
expect(logSpy).toHaveBeenCalledWith('Description: A team group');
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('apps/web -> web-app'));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('apps/web -> services/api [http: GET::/x]'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('status', () => {
|
||||
it('prints OK/STALE/MISSING rows and last-sync timestamp', async () => {
|
||||
mockReadContractRegistry.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
generatedAt: '2026-04-01T00:00:00Z',
|
||||
repoSnapshots: {},
|
||||
missingRepos: ['legacy/old'],
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
});
|
||||
mockGroupService.groupStatus.mockResolvedValueOnce({
|
||||
repos: {
|
||||
'apps/web': { indexStale: false, contractsStale: false, missing: false, commitsBehind: 0 },
|
||||
'libs/shared': { indexStale: true, contractsStale: true, missing: false, commitsBehind: 3 },
|
||||
'legacy/old': { indexStale: false, contractsStale: false, missing: true },
|
||||
},
|
||||
missingRepos: ['legacy/old'],
|
||||
});
|
||||
|
||||
await runCommand(['group', 'status', 'team-a']);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('last sync: 2026-04-01T00:00:00Z'),
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('MISSING'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('STALE'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('3 commits behind'));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Last sync missing repos: legacy/old'),
|
||||
);
|
||||
});
|
||||
|
||||
it('shows "never synced" when no registry exists', async () => {
|
||||
mockReadContractRegistry.mockResolvedValueOnce(null);
|
||||
mockGroupService.groupStatus.mockResolvedValueOnce({ repos: {}, missingRepos: [] });
|
||||
|
||||
await runCommand(['group', 'status', 'team-a']);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('never synced'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync', () => {
|
||||
it('calls syncGroup and prints cascade summary', async () => {
|
||||
mockLoadGroupConfig.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
name: 'team-a',
|
||||
description: '',
|
||||
repos: { 'apps/web': 'web-app' },
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false },
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
mockSyncGroup.mockResolvedValueOnce({
|
||||
contracts: [{ id: 1 }, { id: 2 }],
|
||||
crossLinks: [
|
||||
{ matchType: 'exact' },
|
||||
{ matchType: 'exact' },
|
||||
{ matchType: 'manifest' },
|
||||
],
|
||||
unmatched: [{ id: 3 }],
|
||||
missingRepos: [],
|
||||
repoSnapshots: {},
|
||||
});
|
||||
|
||||
await runCommand(['group', 'sync', 'team-a', '--allow-stale', '--verbose']);
|
||||
|
||||
expect(mockSyncGroup).toHaveBeenCalledWith(
|
||||
expect.anything(),
|
||||
expect.objectContaining({ allowStale: true, verbose: true }),
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('exact: 2 cross-links'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('unmatched: 1 contracts'));
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('Wrote contracts.json (2 contracts, 3 cross-links)'),
|
||||
);
|
||||
});
|
||||
|
||||
it('prints JSON when --json is set', async () => {
|
||||
mockLoadGroupConfig.mockResolvedValueOnce({
|
||||
version: 1,
|
||||
name: 'team-a',
|
||||
description: '',
|
||||
repos: {},
|
||||
links: [],
|
||||
packages: {},
|
||||
detect: { http: false, grpc: false, topics: false, shared_libs: false, embedding_fallback: false },
|
||||
matching: { bm25_threshold: 0.7, embedding_threshold: 0.65, max_candidates_per_step: 3 },
|
||||
});
|
||||
const result = {
|
||||
contracts: [],
|
||||
crossLinks: [],
|
||||
unmatched: [],
|
||||
missingRepos: [],
|
||||
repoSnapshots: {},
|
||||
};
|
||||
mockSyncGroup.mockResolvedValueOnce(result);
|
||||
|
||||
await runCommand(['group', 'sync', 'team-a', '--json']);
|
||||
|
||||
const jsonCall = logSpy.mock.calls.find((c) =>
|
||||
typeof c[0] === 'string' && c[0].trim().startsWith('{'),
|
||||
);
|
||||
expect(jsonCall).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('graph', () => {
|
||||
it('parses --depth and --direction, prints cross connections', async () => {
|
||||
mockGroupService.groupGraph.mockResolvedValueOnce({
|
||||
sourceRepo: 'web-app',
|
||||
localContext: {},
|
||||
crossConnections: [
|
||||
{
|
||||
direction: 'outgoing',
|
||||
remoteRepo: 'libs/shared',
|
||||
contractId: 'lib::x',
|
||||
contractType: 'lib',
|
||||
confidence: 1.0,
|
||||
},
|
||||
{
|
||||
direction: 'incoming',
|
||||
remoteRepo: 'apps/other',
|
||||
contractId: 'http::GET::/y',
|
||||
contractType: 'http',
|
||||
confidence: 0.8,
|
||||
},
|
||||
],
|
||||
totalCrossLinks: 2,
|
||||
});
|
||||
|
||||
await runCommand([
|
||||
'group', 'graph', 'team-a', 'mySymbol',
|
||||
'--repo', 'web-app',
|
||||
'--depth', '2',
|
||||
'--direction', 'both',
|
||||
]);
|
||||
|
||||
expect(mockGroupService.groupGraph).toHaveBeenCalledWith({
|
||||
name: 'team-a',
|
||||
symbol: 'mySymbol',
|
||||
repo: 'web-app',
|
||||
depth: 2,
|
||||
direction: 'both',
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith('Source repo: web-app');
|
||||
expect(logSpy).toHaveBeenCalledWith('Cross-repo connections: 2\n');
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('→ libs/shared [lib] lib::x'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('← apps/other [http]'));
|
||||
});
|
||||
|
||||
it('prints "No cross-repo connections" when empty', async () => {
|
||||
mockGroupService.groupGraph.mockResolvedValueOnce({
|
||||
sourceRepo: 'web-app',
|
||||
localContext: {},
|
||||
crossConnections: [],
|
||||
totalCrossLinks: 0,
|
||||
});
|
||||
|
||||
await runCommand(['group', 'graph', 'team-a', 'mySymbol']);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No cross-repo connections found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('sets exitCode=1 on error', async () => {
|
||||
mockGroupService.groupGraph.mockResolvedValueOnce({ error: 'not found' });
|
||||
|
||||
await runCommand(['group', 'graph', 'team-a', 'mySymbol']);
|
||||
|
||||
expect(errSpy).toHaveBeenCalledWith('not found');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('defaults depth to 1 when --depth is invalid', async () => {
|
||||
mockGroupService.groupGraph.mockResolvedValueOnce({
|
||||
sourceRepo: 'r',
|
||||
localContext: {},
|
||||
crossConnections: [],
|
||||
totalCrossLinks: 0,
|
||||
});
|
||||
|
||||
await runCommand(['group', 'graph', 'team-a', 'x', '--depth', 'not-a-number']);
|
||||
|
||||
expect(mockGroupService.groupGraph).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ depth: 1 }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('query', () => {
|
||||
it('prints merged results with RRF scores', async () => {
|
||||
mockGroupService.groupQuery.mockResolvedValueOnce({
|
||||
results: [
|
||||
{ summary: 'flow A', _repo: 'apps/web', _rrf_score: 0.0164 },
|
||||
{ name: 'flow B', _repo: 'libs/shared', _rrf_score: 0.0161 },
|
||||
],
|
||||
per_repo: [{ repo: 'apps/web', count: 1 }, { repo: 'libs/shared', count: 1 }],
|
||||
});
|
||||
|
||||
await runCommand(['group', 'query', 'team-a', 'auth', '--limit', '10', '--subgroup', 'apps']);
|
||||
|
||||
expect(mockGroupService.groupQuery).toHaveBeenCalledWith({
|
||||
name: 'team-a',
|
||||
query: 'auth',
|
||||
limit: 10,
|
||||
subgroup: 'apps',
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('Results (top 2)'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[apps/web] flow A'));
|
||||
});
|
||||
|
||||
it('prints empty-result message when no flows match', async () => {
|
||||
mockGroupService.groupQuery.mockResolvedValueOnce({ results: [], per_repo: [] });
|
||||
|
||||
await runCommand(['group', 'query', 'team-a', 'nothing']);
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No matching execution flows'),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contracts', () => {
|
||||
it('prints contracts and cross-links', async () => {
|
||||
mockGroupService.groupContracts.mockResolvedValueOnce({
|
||||
contracts: [
|
||||
{ role: 'provider', contractId: 'lib::x', repo: 'libs/shared', symbolRef: { name: 'x' } },
|
||||
{ role: 'consumer', contractId: 'lib::x', repo: 'apps/web', symbolRef: { name: 'x' } },
|
||||
],
|
||||
crossLinks: [
|
||||
{
|
||||
from: { repo: 'apps/web' },
|
||||
to: { repo: 'libs/shared' },
|
||||
matchType: 'exact',
|
||||
confidence: 1.0,
|
||||
contractId: 'lib::x',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
await runCommand(['group', 'contracts', 'team-a', '--type', 'lib']);
|
||||
|
||||
expect(mockGroupService.groupContracts).toHaveBeenCalledWith({
|
||||
name: 'team-a',
|
||||
type: 'lib',
|
||||
repo: undefined,
|
||||
unmatchedOnly: false,
|
||||
});
|
||||
expect(logSpy).toHaveBeenCalledWith('Contracts (2):');
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('[provider] lib::x'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('apps/web -> libs/shared'));
|
||||
});
|
||||
|
||||
it('errors and exits 1 when service returns an error', async () => {
|
||||
mockGroupService.groupContracts.mockResolvedValueOnce({ error: 'bad' });
|
||||
|
||||
await runCommand(['group', 'contracts', 'team-a']);
|
||||
|
||||
expect(errSpy).toHaveBeenCalledWith('bad');
|
||||
expect(process.exitCode).toBe(1);
|
||||
});
|
||||
|
||||
it('forwards --unmatched and --repo filters', async () => {
|
||||
mockGroupService.groupContracts.mockResolvedValueOnce({ contracts: [], crossLinks: [] });
|
||||
|
||||
await runCommand([
|
||||
'group', 'contracts', 'team-a',
|
||||
'--unmatched',
|
||||
'--repo', 'apps/web',
|
||||
'--json',
|
||||
]);
|
||||
|
||||
expect(mockGroupService.groupContracts).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
unmatchedOnly: true,
|
||||
repo: 'apps/web',
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
116
gitnexus/test/unit/cli-status.test.ts
Normal file
116
gitnexus/test/unit/cli-status.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockIsGitRepo = vi.fn();
|
||||
const mockGetGitRoot = vi.fn();
|
||||
const mockGetCurrentCommit = vi.fn();
|
||||
const mockFindRepo = vi.fn();
|
||||
const mockGetStoragePaths = vi.fn();
|
||||
const mockHasKuzuIndex = vi.fn();
|
||||
|
||||
vi.mock('../../src/storage/git.js', () => ({
|
||||
isGitRepo: mockIsGitRepo,
|
||||
getGitRoot: mockGetGitRoot,
|
||||
getCurrentCommit: mockGetCurrentCommit,
|
||||
}));
|
||||
|
||||
vi.mock('../../src/storage/repo-manager.js', () => ({
|
||||
findRepo: mockFindRepo,
|
||||
getStoragePaths: mockGetStoragePaths,
|
||||
hasKuzuIndex: mockHasKuzuIndex,
|
||||
}));
|
||||
|
||||
describe('statusCommand', () => {
|
||||
let logSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
logSpy = vi.spyOn(console, 'log').mockImplementation(() => {});
|
||||
mockGetStoragePaths.mockReturnValue({ storagePath: '/repo/.gitnexus' });
|
||||
});
|
||||
|
||||
it('prints "Not a git repository" when cwd is not a git repo', async () => {
|
||||
mockIsGitRepo.mockReturnValue(false);
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
await statusCommand();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('Not a git repository.');
|
||||
expect(mockFindRepo).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('prompts for migration when a stale KuzuDB index exists', async () => {
|
||||
mockIsGitRepo.mockReturnValue(true);
|
||||
mockFindRepo.mockResolvedValue(null);
|
||||
mockGetGitRoot.mockReturnValue('/repo');
|
||||
mockHasKuzuIndex.mockResolvedValue(true);
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
await statusCommand();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('stale KuzuDB index'),
|
||||
);
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('rebuilds the index'));
|
||||
});
|
||||
|
||||
it('prompts to analyze when no index exists at all', async () => {
|
||||
mockIsGitRepo.mockReturnValue(true);
|
||||
mockFindRepo.mockResolvedValue(null);
|
||||
mockGetGitRoot.mockReturnValue('/repo');
|
||||
mockHasKuzuIndex.mockResolvedValue(false);
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
await statusCommand();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('Repository not indexed.');
|
||||
expect(logSpy).toHaveBeenCalledWith('Run: gitnexus analyze');
|
||||
});
|
||||
|
||||
it('falls back to cwd when getGitRoot returns null', async () => {
|
||||
mockIsGitRepo.mockReturnValue(true);
|
||||
mockFindRepo.mockResolvedValue(null);
|
||||
mockGetGitRoot.mockReturnValue(null);
|
||||
mockHasKuzuIndex.mockResolvedValue(false);
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
await statusCommand();
|
||||
|
||||
expect(mockGetStoragePaths).toHaveBeenCalledWith(process.cwd());
|
||||
});
|
||||
|
||||
it('reports up-to-date when current commit matches indexed commit', async () => {
|
||||
mockIsGitRepo.mockReturnValue(true);
|
||||
mockFindRepo.mockResolvedValue({
|
||||
repoPath: '/repo',
|
||||
meta: {
|
||||
lastCommit: 'abc1234567',
|
||||
indexedAt: '2026-04-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
mockGetCurrentCommit.mockReturnValue('abc1234567');
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
await statusCommand();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith('Repository: /repo');
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('abc1234'));
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('up-to-date'));
|
||||
});
|
||||
|
||||
it('reports stale when current commit diverges from indexed commit', async () => {
|
||||
mockIsGitRepo.mockReturnValue(true);
|
||||
mockFindRepo.mockResolvedValue({
|
||||
repoPath: '/repo',
|
||||
meta: {
|
||||
lastCommit: 'old12345',
|
||||
indexedAt: '2026-04-01T00:00:00Z',
|
||||
},
|
||||
});
|
||||
mockGetCurrentCommit.mockReturnValue('new67890');
|
||||
|
||||
const { statusCommand } = await import('../../src/cli/status.js');
|
||||
await statusCommand();
|
||||
|
||||
expect(logSpy).toHaveBeenCalledWith(expect.stringContaining('stale'));
|
||||
});
|
||||
});
|
||||
239
gitnexus/test/unit/cli-tool.test.ts
Normal file
239
gitnexus/test/unit/cli-tool.test.ts
Normal file
|
|
@ -0,0 +1,239 @@
|
|||
import { beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const mockCallTool = vi.fn();
|
||||
const mockInit = vi.fn();
|
||||
|
||||
vi.mock('../../src/mcp/local/local-backend.js', () => ({
|
||||
LocalBackend: class {
|
||||
callTool = mockCallTool;
|
||||
init = mockInit;
|
||||
},
|
||||
}));
|
||||
|
||||
const writeSyncMock = vi.fn();
|
||||
vi.mock('node:fs', () => ({
|
||||
writeSync: writeSyncMock,
|
||||
}));
|
||||
|
||||
describe('CLI tool commands', () => {
|
||||
let errSpy: ReturnType<typeof vi.spyOn>;
|
||||
let exitSpy: ReturnType<typeof vi.spyOn>;
|
||||
let stderrSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(async () => {
|
||||
vi.clearAllMocks();
|
||||
vi.resetModules();
|
||||
errSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
exitSpy = vi.spyOn(process, 'exit').mockImplementation(() => {
|
||||
throw new Error('__exit__');
|
||||
}) as ReturnType<typeof vi.spyOn>;
|
||||
stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
|
||||
mockInit.mockResolvedValue(true);
|
||||
});
|
||||
|
||||
describe('queryCommand', () => {
|
||||
it('errors when queryText is empty', async () => {
|
||||
const { queryCommand } = await import('../../src/cli/tool.js');
|
||||
await expect(queryCommand('')).rejects.toThrow('__exit__');
|
||||
expect(errSpy).toHaveBeenCalledWith('Usage: gitnexus query <search_query>');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
|
||||
it('forwards options to callTool and writes result', async () => {
|
||||
mockCallTool.mockResolvedValueOnce({ results: ['a', 'b'] });
|
||||
const { queryCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await queryCommand('auth flow', {
|
||||
repo: 'web',
|
||||
context: 'onboarding',
|
||||
goal: 'debug',
|
||||
limit: '5',
|
||||
content: true,
|
||||
});
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith('query', {
|
||||
query: 'auth flow',
|
||||
task_context: 'onboarding',
|
||||
goal: 'debug',
|
||||
limit: 5,
|
||||
include_content: true,
|
||||
repo: 'web',
|
||||
});
|
||||
expect(writeSyncMock).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('passes include_content=false and limit=undefined by default', async () => {
|
||||
mockCallTool.mockResolvedValueOnce({});
|
||||
const { queryCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await queryCommand('x');
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
'query',
|
||||
expect.objectContaining({ include_content: false, limit: undefined }),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('contextCommand', () => {
|
||||
it('errors when both name and uid are empty', async () => {
|
||||
const { contextCommand } = await import('../../src/cli/tool.js');
|
||||
await expect(contextCommand('')).rejects.toThrow('__exit__');
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gitnexus context'),
|
||||
);
|
||||
});
|
||||
|
||||
it('accepts uid without a name', async () => {
|
||||
mockCallTool.mockResolvedValueOnce({});
|
||||
const { contextCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await contextCommand('', { uid: 'uid-123' });
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
'context',
|
||||
expect.objectContaining({ uid: 'uid-123', name: undefined }),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --file and --content', async () => {
|
||||
mockCallTool.mockResolvedValueOnce({});
|
||||
const { contextCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await contextCommand('validateUser', { file: 'src/auth.ts', content: true });
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
'context',
|
||||
expect.objectContaining({
|
||||
name: 'validateUser',
|
||||
file_path: 'src/auth.ts',
|
||||
include_content: true,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('impactCommand', () => {
|
||||
it('errors when target is empty', async () => {
|
||||
const { impactCommand } = await import('../../src/cli/tool.js');
|
||||
await expect(impactCommand('')).rejects.toThrow('__exit__');
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gitnexus impact'),
|
||||
);
|
||||
});
|
||||
|
||||
it('defaults direction to upstream and passes depth as integer', async () => {
|
||||
mockCallTool.mockResolvedValueOnce({ impacts: [] });
|
||||
const { impactCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await impactCommand('AuthService', { depth: '3' });
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
'impact',
|
||||
expect.objectContaining({
|
||||
target: 'AuthService',
|
||||
direction: 'upstream',
|
||||
maxDepth: 3,
|
||||
includeTests: false,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --direction downstream', async () => {
|
||||
mockCallTool.mockResolvedValueOnce({});
|
||||
const { impactCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await impactCommand('AuthService', { direction: 'downstream' });
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith(
|
||||
'impact',
|
||||
expect.objectContaining({ direction: 'downstream' }),
|
||||
);
|
||||
});
|
||||
|
||||
it('emits a structured error object and exits 1 when callTool throws', async () => {
|
||||
mockCallTool.mockRejectedValueOnce(new Error('transport failed'));
|
||||
const { impactCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await expect(impactCommand('AuthService')).rejects.toThrow('__exit__');
|
||||
expect(writeSyncMock).toHaveBeenCalled();
|
||||
const firstWrite = writeSyncMock.mock.calls[0][1] as string;
|
||||
expect(firstWrite).toContain('transport failed');
|
||||
expect(firstWrite).toContain('AuthService');
|
||||
expect(exitSpy).toHaveBeenCalledWith(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cypherCommand', () => {
|
||||
it('errors when query is empty', async () => {
|
||||
const { cypherCommand } = await import('../../src/cli/tool.js');
|
||||
await expect(cypherCommand('')).rejects.toThrow('__exit__');
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('gitnexus cypher'),
|
||||
);
|
||||
});
|
||||
|
||||
it('forwards --repo option', async () => {
|
||||
mockCallTool.mockResolvedValueOnce({ rows: [] });
|
||||
const { cypherCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await cypherCommand('MATCH (n) RETURN n', { repo: 'my-repo' });
|
||||
|
||||
expect(mockCallTool).toHaveBeenCalledWith('cypher', {
|
||||
query: 'MATCH (n) RETURN n',
|
||||
repo: 'my-repo',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBackend singleton + init failure', () => {
|
||||
it('errors and exits 1 when backend init returns false', async () => {
|
||||
mockInit.mockResolvedValueOnce(false);
|
||||
const { queryCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await expect(queryCommand('x')).rejects.toThrow('__exit__');
|
||||
expect(errSpy).toHaveBeenCalledWith(
|
||||
expect.stringContaining('No indexed repositories found'),
|
||||
);
|
||||
});
|
||||
|
||||
it('reuses the same backend across calls', async () => {
|
||||
mockCallTool.mockResolvedValue({});
|
||||
const { queryCommand, cypherCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await queryCommand('a');
|
||||
await cypherCommand('MATCH (n) RETURN n');
|
||||
|
||||
expect(mockInit).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('output() EPIPE fallback', () => {
|
||||
it('falls back to stderr and does not crash when stdout write throws non-EPIPE', async () => {
|
||||
writeSyncMock.mockImplementationOnce(() => {
|
||||
const err = new Error('broken pipe') as NodeJS.ErrnoException;
|
||||
err.code = 'EACCES';
|
||||
throw err;
|
||||
});
|
||||
mockCallTool.mockResolvedValueOnce({ ok: true });
|
||||
const { queryCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await queryCommand('x');
|
||||
|
||||
expect(stderrSpy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('exits cleanly (0) on EPIPE write failure', async () => {
|
||||
writeSyncMock.mockImplementationOnce(() => {
|
||||
const err = new Error('EPIPE') as NodeJS.ErrnoException;
|
||||
err.code = 'EPIPE';
|
||||
throw err;
|
||||
});
|
||||
mockCallTool.mockResolvedValueOnce({ ok: true });
|
||||
const { queryCommand } = await import('../../src/cli/tool.js');
|
||||
|
||||
await expect(queryCommand('x')).rejects.toThrow('__exit__');
|
||||
expect(exitSpy).toHaveBeenCalledWith(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Add table
Reference in a new issue