GitNexus/gitnexus/test/integration/group/auto-discover.test.ts
sahalterion 1cc6888e19 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>
2026-04-28 06:50:16 -07:00

265 lines
9.2 KiB
TypeScript

/**
* Integration test for group auto-discover.
*
* Tests scanning a parent directory to find indexed repos and create a group.
* Uses mock GroupToolPort to avoid needing a real LocalBackend.
*/
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
import * as fs from 'node:fs';
import * as path from 'node:path';
import * as os from 'node:os';
import { GroupService, type GroupToolPort, type GroupRepoHandle } from '../../../src/core/group/service.js';
import { listGroups } from '../../../src/core/group/storage.js';
describe('Group auto-discover integration', () => {
let tmpDir: string;
let gitnexusHome: string;
let originalHome: string | undefined;
beforeEach(() => {
tmpDir = path.join(os.tmpdir(), `gitnexus-discover-${Date.now()}`);
gitnexusHome = path.join(tmpDir, '.gitnexus-home');
fs.mkdirSync(gitnexusHome, { recursive: true });
// Override GITNEXUS_HOME so we don't pollute the real home dir
originalHome = process.env.GITNEXUS_HOME;
process.env.GITNEXUS_HOME = gitnexusHome;
// Create two mock repos with .gitnexus/meta.json
const repoA = path.join(tmpDir, 'repos', 'shared-utils');
const repoB = path.join(tmpDir, 'repos', 'web-app');
fs.mkdirSync(path.join(repoA, '.gitnexus'), { recursive: true });
fs.writeFileSync(
path.join(repoA, '.gitnexus', 'meta.json'),
JSON.stringify({ indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'abc123' }),
);
fs.writeFileSync(
path.join(repoA, 'package.json'),
JSON.stringify({ name: '@test/shared-utils', version: '1.0.0', dependencies: {} }),
);
fs.mkdirSync(path.join(repoA, 'src'), { recursive: true });
fs.writeFileSync(
path.join(repoA, 'src', 'index.ts'),
'export function formatDate(d: Date): string { return d.toISOString(); }',
);
fs.mkdirSync(path.join(repoB, '.gitnexus'), { recursive: true });
fs.writeFileSync(
path.join(repoB, '.gitnexus', 'meta.json'),
JSON.stringify({ indexedAt: '2026-04-01T00:00:00Z', lastCommit: 'def456' }),
);
fs.writeFileSync(
path.join(repoB, 'package.json'),
JSON.stringify({
name: '@test/web-app',
version: '2.0.0',
dependencies: { '@test/shared-utils': '^1.0.0' },
}),
);
fs.mkdirSync(path.join(repoB, 'src'), { recursive: true });
fs.writeFileSync(
path.join(repoB, 'src', 'app.ts'),
`import { formatDate } from '@test/shared-utils';
console.log(formatDate(new Date()));`,
);
});
afterEach(() => {
if (originalHome !== undefined) {
process.env.GITNEXUS_HOME = originalHome;
} else {
delete process.env.GITNEXUS_HOME;
}
fs.rmSync(tmpDir, { recursive: true, force: true });
});
function makeMockPort(): GroupToolPort {
const repoHandles: Record<string, GroupRepoHandle> = {
'shared-utils': {
id: 'shared-utils',
name: 'shared-utils',
repoPath: path.join(tmpDir, 'repos', 'shared-utils'),
storagePath: path.join(tmpDir, 'repos', 'shared-utils', '.gitnexus'),
},
'web-app': {
id: 'web-app',
name: 'web-app',
repoPath: path.join(tmpDir, 'repos', 'web-app'),
storagePath: path.join(tmpDir, 'repos', 'web-app', '.gitnexus'),
},
};
return {
resolveRepo: async (nameOrPath?: string): Promise<GroupRepoHandle> => {
if (nameOrPath && repoHandles[nameOrPath]) {
return repoHandles[nameOrPath];
}
// Try matching by path
for (const handle of Object.values(repoHandles)) {
if (nameOrPath && (handle.repoPath === nameOrPath || handle.repoPath.includes(nameOrPath))) {
return handle;
}
}
throw new Error(`Repo not found: ${nameOrPath}`);
},
query: async () => ({ processes: [] }),
};
}
it('discovers repos and creates a group', async () => {
const service = new GroupService(makeMockPort());
const reposDir = path.join(tmpDir, 'repos');
const result = (await service.groupDiscover({
directory: reposDir,
name: 'test-discover',
skipSync: true,
})) as {
group: string;
groupDir: string;
repos: Array<{ name: string; packageName: string | null }>;
repoCount: number;
packageMappings: Record<string, string>;
};
expect(result.group).toBe('test-discover');
expect(result.repoCount).toBe(2);
// Check repos were discovered
const repoNames = result.repos.map((r) => r.name).sort();
expect(repoNames).toEqual(['shared-utils', 'web-app']);
// Check package names detected
const sharedRepo = result.repos.find((r) => r.name === 'shared-utils');
expect(sharedRepo?.packageName).toBe('@test/shared-utils');
const webRepo = result.repos.find((r) => r.name === 'web-app');
expect(webRepo?.packageName).toBe('@test/web-app');
// Check package mappings
expect(result.packageMappings['@test/shared-utils']).toBeDefined();
expect(result.packageMappings['@test/web-app']).toBeDefined();
// Check group.yaml was created
const groups = await listGroups(gitnexusHome);
expect(groups).toContain('test-discover');
});
it('returns error for empty directory', async () => {
const emptyDir = path.join(tmpDir, 'empty');
fs.mkdirSync(emptyDir, { recursive: true });
const service = new GroupService(makeMockPort());
const result = (await service.groupDiscover({
directory: emptyDir,
name: 'test-empty',
skipSync: true,
})) as { error: string };
expect(result.error).toContain('No indexed repos found');
});
it('returns error for non-existent directory', async () => {
const service = new GroupService(makeMockPort());
const result = (await service.groupDiscover({
directory: '/nonexistent/path',
name: 'test-missing',
})) as { error: string };
expect(result.error).toContain('Cannot read directory');
});
it('returns error when directory param is missing', async () => {
const service = new GroupService(makeMockPort());
const result = (await service.groupDiscover({})) as { error: string };
expect(result.error).toBe('directory or repoPaths is required');
});
it('skips non-indexed subdirectories', async () => {
// Add a non-indexed directory
const nonIndexed = path.join(tmpDir, 'repos', 'not-indexed');
fs.mkdirSync(nonIndexed, { recursive: true });
fs.writeFileSync(path.join(nonIndexed, 'package.json'), JSON.stringify({ name: 'not-indexed' }));
// No .gitnexus/meta.json
const service = new GroupService(makeMockPort());
const result = (await service.groupDiscover({
directory: path.join(tmpDir, 'repos'),
name: 'test-skip',
skipSync: true,
})) as { repoCount: number };
// 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');
});
});
});