mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-10-11 03:38:07 +00:00
803 lines
27 KiB
TypeScript
803 lines
27 KiB
TypeScript
import { describe, it, expect, vi } from 'vitest';
|
|
import * as fs from 'node:fs';
|
|
import * as path from 'node:path';
|
|
import * as os from 'node:os';
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
GroupService,
|
|
type GroupToolPort,
|
|
type GroupRepoHandle,
|
|
} from '../../../src/core/group/service.js';
|
|
import { writeContractRegistry } from '../../../src/core/group/storage.js';
|
|
import {
|
|
formatIndexStatusCell,
|
|
type GroupRepoIndexRow,
|
|
} from '../../../src/cli/group-status-format.js';
|
|
import type { ContractRegistry, StoredContract, CrossLink } from '../../../src/core/group/types.js';
|
|
|
|
function makeTmpGroup(): { tmpDir: string; groupDir: string; cleanup: () => void } {
|
|
const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-${Date.now()}`);
|
|
const groupDir = path.join(tmpDir, 'groups', 'test-group');
|
|
fs.mkdirSync(groupDir, { recursive: true });
|
|
|
|
const yaml = `version: 1
|
|
name: test-group
|
|
description: Test
|
|
repos:
|
|
app/backend: test-backend
|
|
app/frontend: test-frontend
|
|
`;
|
|
fs.writeFileSync(path.join(groupDir, 'group.yaml'), yaml);
|
|
|
|
return {
|
|
tmpDir,
|
|
groupDir,
|
|
cleanup: () => fs.rmSync(tmpDir, { recursive: true, force: true }),
|
|
};
|
|
}
|
|
|
|
function makePort(overrides: Partial<GroupToolPort> = {}): GroupToolPort {
|
|
return {
|
|
resolveRepo: vi.fn(
|
|
async (name?: string): Promise<GroupRepoHandle> => ({
|
|
id: name || 'test',
|
|
name: name || 'test',
|
|
repoPath: '/tmp/repo',
|
|
storagePath: '/tmp/repo/.gitnexus',
|
|
}),
|
|
),
|
|
impact: vi.fn(async () => ({ symbols: [] })),
|
|
query: vi.fn(async () => ({ processes: [] })),
|
|
impactByUid: vi.fn(async () => null),
|
|
context: vi.fn(async () => ({
|
|
status: 'found',
|
|
symbol: { filePath: 'services/auth/x.ts', uid: 'u1', name: 'X' },
|
|
})),
|
|
...overrides,
|
|
};
|
|
}
|
|
|
|
function makeContract(id: string, role: 'provider' | 'consumer', repo: string): StoredContract {
|
|
return {
|
|
contractId: id,
|
|
type: 'http',
|
|
role,
|
|
symbolUid: `uid-${repo}-${id}`,
|
|
symbolRef: { filePath: `src/${repo}.ts`, name: `fn-${id}` },
|
|
symbolName: `fn-${id}`,
|
|
confidence: 0.8,
|
|
meta: {},
|
|
repo,
|
|
};
|
|
}
|
|
|
|
function makeRegistry(contracts: StoredContract[], crossLinks: CrossLink[] = []): ContractRegistry {
|
|
return {
|
|
version: 1,
|
|
generatedAt: new Date().toISOString(),
|
|
repoSnapshots: {},
|
|
missingRepos: [],
|
|
contracts,
|
|
crossLinks,
|
|
};
|
|
}
|
|
|
|
describe('GroupService', () => {
|
|
describe('groupList', () => {
|
|
it('test_groupList_without_name_returns_group_names', async () => {
|
|
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupList({})) as { groups: string[] };
|
|
expect(result.groups).toContain('test-group');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupList_with_name_returns_config_details', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupList({ name: 'test-group' })) as {
|
|
name: string;
|
|
repos: Record<string, string>;
|
|
};
|
|
expect(result.name).toBe('test-group');
|
|
expect(result.repos['app/backend']).toBe('test-backend');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('groupContracts', () => {
|
|
it('test_groupContracts_returns_error_when_name_empty', async () => {
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupContracts({})) as { error: string };
|
|
expect(result.error).toContain('name is required');
|
|
});
|
|
|
|
it('test_groupContracts_returns_error_when_no_registry', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupContracts({ name: 'test-group' })) as { error: string };
|
|
expect(result.error).toContain('No contracts.json');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContracts_returns_all_contracts', async () => {
|
|
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const contracts = [
|
|
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
|
|
];
|
|
await writeContractRegistry(groupDir, makeRegistry(contracts));
|
|
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupContracts({ name: 'test-group' })) as {
|
|
contracts: StoredContract[];
|
|
};
|
|
expect(result.contracts).toHaveLength(2);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContracts_filters_by_type', async () => {
|
|
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const contracts = [
|
|
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
|
|
{
|
|
...makeContract('grpc::auth.AuthService/Login', 'provider', 'app/backend'),
|
|
type: 'grpc' as const,
|
|
},
|
|
];
|
|
await writeContractRegistry(groupDir, makeRegistry(contracts));
|
|
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupContracts({ name: 'test-group', type: 'grpc' })) as {
|
|
contracts: StoredContract[];
|
|
};
|
|
expect(result.contracts).toHaveLength(1);
|
|
expect(result.contracts[0].type).toBe('grpc');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContracts_filters_by_repo', async () => {
|
|
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const contracts = [
|
|
makeContract('http::GET::/api/users', 'provider', 'app/backend'),
|
|
makeContract('http::GET::/api/users', 'consumer', 'app/frontend'),
|
|
];
|
|
await writeContractRegistry(groupDir, makeRegistry(contracts));
|
|
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupContracts({ name: 'test-group', repo: 'app/backend' })) as {
|
|
contracts: StoredContract[];
|
|
};
|
|
expect(result.contracts).toHaveLength(1);
|
|
expect(result.contracts[0].repo).toBe('app/backend');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContracts_unmatchedOnly_filters_matched', async () => {
|
|
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const provider = makeContract('http::GET::/api/users', 'provider', 'app/backend');
|
|
const consumer = makeContract('http::GET::/api/users', 'consumer', 'app/frontend');
|
|
const orphan = makeContract('http::GET::/api/health', 'provider', 'app/backend');
|
|
const crossLink: CrossLink = {
|
|
from: {
|
|
repo: 'app/frontend',
|
|
symbolUid: 'uid-c',
|
|
symbolRef: { filePath: 'f.ts', name: 'fn' },
|
|
},
|
|
to: {
|
|
repo: 'app/backend',
|
|
symbolUid: 'uid-p',
|
|
symbolRef: { filePath: 'f.ts', name: 'fn' },
|
|
},
|
|
type: 'http',
|
|
contractId: 'http::GET::/api/users',
|
|
matchType: 'exact',
|
|
confidence: 1.0,
|
|
};
|
|
await writeContractRegistry(
|
|
groupDir,
|
|
makeRegistry([provider, consumer, orphan], [crossLink]),
|
|
);
|
|
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupContracts({ name: 'test-group', unmatchedOnly: true })) as {
|
|
contracts: StoredContract[];
|
|
};
|
|
expect(result.contracts).toHaveLength(1);
|
|
expect(result.contracts[0].contractId).toBe('http::GET::/api/health');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContracts_skips_corrupt_contract_rows', async () => {
|
|
const { groupDir, cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const badJson = `{
|
|
"version": 1,
|
|
"generatedAt": "2026-01-01T00:00:00.000Z",
|
|
"repoSnapshots": {},
|
|
"missingRepos": [],
|
|
"contracts": [
|
|
{ "not": "a-contract" },
|
|
{
|
|
"contractId": "http::GET::/ok",
|
|
"type": "http",
|
|
"repo": "app/backend",
|
|
"role": "provider",
|
|
"symbolUid": "u",
|
|
"symbolRef": { "filePath": "a.ts", "name": "f" },
|
|
"symbolName": "f",
|
|
"confidence": 1,
|
|
"meta": {}
|
|
}
|
|
],
|
|
"crossLinks": []
|
|
}`;
|
|
fs.writeFileSync(path.join(groupDir, 'contracts.json'), badJson, 'utf-8');
|
|
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupContracts({ name: 'test-group' })) as {
|
|
contracts: unknown[];
|
|
skippedCorrupt?: number;
|
|
};
|
|
expect(result.contracts).toHaveLength(1);
|
|
expect(result.skippedCorrupt).toBe(1);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('groupSync', () => {
|
|
it('test_groupSync_returns_error_when_name_empty', async () => {
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupSync({})) as { error: string };
|
|
expect(result.error).toContain('name is required');
|
|
});
|
|
});
|
|
|
|
describe('groupQuery', () => {
|
|
it('test_groupQuery_returns_error_when_params_missing', async () => {
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupQuery({})) as { error: string };
|
|
expect(result.error).toContain('name and query are required');
|
|
});
|
|
|
|
it('test_groupQuery_uses_advertised_query_defaults_when_omitted', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const query = vi.fn(async () => ({ processes: [] }));
|
|
const svc = new GroupService(makePort({ query }));
|
|
await svc.groupQuery({ name: 'test-group', query: 'auth flow' });
|
|
expect(query).toHaveBeenCalled();
|
|
for (const call of query.mock.calls) {
|
|
expect(call[1]).toMatchObject({ limit: 10, max_symbols: 25 });
|
|
}
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupQuery_forwards_explicit_limit_and_max_symbols', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const query = vi.fn(async () => ({ processes: [] }));
|
|
const svc = new GroupService(makePort({ query }));
|
|
await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'auth flow',
|
|
limit: 3,
|
|
max_symbols: 7,
|
|
});
|
|
for (const call of query.mock.calls) {
|
|
expect(call[1]).toMatchObject({ limit: 3, max_symbols: 7 });
|
|
}
|
|
expect(query).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupQuery_rejects_infinite_and_oversized_limit_and_max_symbols', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const query = vi.fn(async () => ({ processes: [] }));
|
|
const svc = new GroupService(makePort({ query }));
|
|
|
|
const infiniteLimit = await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'auth flow',
|
|
limit: Infinity,
|
|
});
|
|
expect(infiniteLimit).toMatchObject({ error: expect.stringMatching(/Invalid "limit"/) });
|
|
|
|
const oversizedLimit = await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'auth flow',
|
|
limit: 101,
|
|
});
|
|
expect(oversizedLimit).toMatchObject({ error: expect.stringMatching(/Invalid "limit"/) });
|
|
|
|
const infiniteSymbols = await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'auth flow',
|
|
max_symbols: Infinity,
|
|
});
|
|
expect(infiniteSymbols).toMatchObject({
|
|
error: expect.stringMatching(/Invalid "max_symbols"/),
|
|
});
|
|
|
|
const oversizedSymbols = await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'auth flow',
|
|
max_symbols: 201,
|
|
});
|
|
expect(oversizedSymbols).toMatchObject({
|
|
error: expect.stringMatching(/Invalid "max_symbols"/),
|
|
});
|
|
|
|
const cyclic = { self: null as unknown };
|
|
cyclic.self = cyclic;
|
|
const cyclicLimit = await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'auth flow',
|
|
limit: cyclic,
|
|
});
|
|
expect(cyclicLimit).toMatchObject({ error: expect.stringMatching(/Invalid "limit"/) });
|
|
|
|
const badDepth = await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'auth flow',
|
|
chain_depth: 0.1,
|
|
});
|
|
expect(badDepth).toMatchObject({ error: expect.stringMatching(/Invalid "chain_depth"/) });
|
|
expect(query).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupQuery_forwards_chain_depth', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const query = vi.fn(async () => ({ processes: [] }));
|
|
const svc = new GroupService(makePort({ query }));
|
|
await svc.groupQuery({ name: 'test-group', query: 'auth flow', chain_depth: 2 });
|
|
expect(query).toHaveBeenCalled();
|
|
for (const call of query.mock.calls) {
|
|
expect(call[1]).toMatchObject({ chain_depth: 2 });
|
|
}
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupQuery_merges_results_across_repos', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
|
|
const port = makePort({
|
|
query: vi.fn(async () => ({
|
|
processes: [{ name: 'process1', score: 0.9 }],
|
|
})),
|
|
});
|
|
|
|
const svc = new GroupService(port);
|
|
const result = (await svc.groupQuery({ name: 'test-group', query: 'auth flow' })) as {
|
|
group: string;
|
|
query: string;
|
|
results: unknown[];
|
|
per_repo: Array<{ repo: string; count: number }>;
|
|
};
|
|
|
|
expect(result.group).toBe('test-group');
|
|
expect(result.query).toBe('auth flow');
|
|
expect(result.results.length).toBeGreaterThan(0);
|
|
expect(result.per_repo).toHaveLength(2);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupQuery_handles_failing_repo_gracefully', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
|
|
const port = makePort({
|
|
resolveRepo: vi.fn(async (name?: string) => {
|
|
if (name === 'test-backend') throw new Error('not indexed');
|
|
return { id: 'fe', name: 'fe', repoPath: '/tmp', storagePath: '/tmp/.gitnexus' };
|
|
}),
|
|
query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })),
|
|
});
|
|
|
|
const svc = new GroupService(port);
|
|
const result = (await svc.groupQuery({ name: 'test-group', query: 'test' })) as {
|
|
per_repo: Array<{ repo: string; count: number }>;
|
|
};
|
|
|
|
const backendRepo = result.per_repo.find((r) => r.repo === 'app/backend');
|
|
expect(backendRepo?.count).toBe(0);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupQuery_respects_subgroup_filter', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
|
|
const port = makePort({
|
|
query: vi.fn(async () => ({ processes: [{ name: 'p1' }] })),
|
|
});
|
|
|
|
const svc = new GroupService(port);
|
|
const result = (await svc.groupQuery({
|
|
name: 'test-group',
|
|
query: 'test',
|
|
subgroup: 'app/backend',
|
|
})) as { per_repo: Array<{ repo: string; count: number }> };
|
|
|
|
expect(result.per_repo).toHaveLength(1);
|
|
expect(result.per_repo[0].repo).toBe('app/backend');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupQuery_subgroupExact_skips_descendant_member_paths', async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `gitnexus-svc-nest-${Date.now()}`);
|
|
const groupDir = path.join(tmpDir, 'groups', 'nest-group');
|
|
fs.mkdirSync(groupDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(groupDir, 'group.yaml'),
|
|
`version: 1
|
|
name: nest-group
|
|
repos:
|
|
app/frontend: fe-root
|
|
app/frontend/mobile: fe-nested
|
|
app/backend: be1
|
|
`,
|
|
);
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const query = vi.fn(async () => ({ processes: [{ name: 'p1' }] }));
|
|
const port = makePort({ query });
|
|
const svc = new GroupService(port);
|
|
|
|
const prefixOnly = (await svc.groupQuery({
|
|
name: 'nest-group',
|
|
query: 'x',
|
|
subgroup: 'app/frontend',
|
|
})) as { per_repo: Array<{ repo: string }> };
|
|
expect(prefixOnly.per_repo.map((r) => r.repo).sort()).toEqual([
|
|
'app/frontend',
|
|
'app/frontend/mobile',
|
|
]);
|
|
|
|
const exact = (await svc.groupQuery({
|
|
name: 'nest-group',
|
|
query: 'x',
|
|
subgroup: 'app/frontend',
|
|
subgroupExact: true,
|
|
})) as { per_repo: Array<{ repo: string }> };
|
|
expect(exact.per_repo).toHaveLength(1);
|
|
expect(exact.per_repo[0].repo).toBe('app/frontend');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('groupImpact', () => {
|
|
it('test_groupImpact_returns_validation_error', async () => {
|
|
const svc = new GroupService(makePort());
|
|
const r = (await svc.groupImpact({})) as { error: string };
|
|
expect(r.error).toContain('name');
|
|
});
|
|
});
|
|
|
|
describe('groupContext', () => {
|
|
it('test_groupContext_requires_target_or_uid', async () => {
|
|
const svc = new GroupService(makePort());
|
|
const r = await svc.groupContext({ name: 'test-group' });
|
|
expect(r.error).toContain('target');
|
|
});
|
|
|
|
it('test_groupContext_iterates_repos', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const port = makePort();
|
|
const svc = new GroupService(port);
|
|
const r = await svc.groupContext({ name: 'test-group', target: 'MySym' });
|
|
expect(r.group).toBe('test-group');
|
|
expect(r.results).toHaveLength(2);
|
|
expect(port.context).toHaveBeenCalledTimes(2);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContext_rejects_non_integer_chain_depth', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const context = vi.fn(async () => ({ status: 'found' }));
|
|
const svc = new GroupService(makePort({ context }));
|
|
const r = await svc.groupContext({
|
|
name: 'test-group',
|
|
target: 'MySym',
|
|
chain_depth: '2',
|
|
});
|
|
expect(r).toMatchObject({ error: expect.stringMatching(/Invalid "chain_depth"/) });
|
|
expect(context).not.toHaveBeenCalled();
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContext_forwards_chain_depth', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const context = vi.fn(async () => ({
|
|
status: 'found',
|
|
symbol: { filePath: 'services/auth/x.ts', uid: 'u1', name: 'X' },
|
|
}));
|
|
const svc = new GroupService(makePort({ context }));
|
|
await svc.groupContext({ name: 'test-group', target: 'MySym', chain_depth: 2 });
|
|
expect(context).toHaveBeenCalled();
|
|
for (const call of context.mock.calls) {
|
|
expect(call[1]).toMatchObject({ chain_depth: 2 });
|
|
}
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('test_groupContext_subgroupExact_skips_descendant_member_paths', async () => {
|
|
const tmpDir = path.join(os.tmpdir(), `gitnexus-ctx-nest-${Date.now()}`);
|
|
const groupDir = path.join(tmpDir, 'groups', 'nest-group');
|
|
fs.mkdirSync(groupDir, { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(groupDir, 'group.yaml'),
|
|
`version: 1
|
|
name: nest-group
|
|
repos:
|
|
app/frontend: fe-root
|
|
app/frontend/mobile: fe-nested
|
|
`,
|
|
);
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const port = makePort();
|
|
const svc = new GroupService(port);
|
|
await svc.groupContext({
|
|
name: 'nest-group',
|
|
target: 'X',
|
|
subgroup: 'app/frontend',
|
|
subgroupExact: true,
|
|
});
|
|
expect(port.context).toHaveBeenCalledTimes(1);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
fs.rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('test_groupContext_service_prefix_filters_payload', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const port = makePort({
|
|
context: vi.fn(async () => ({
|
|
status: 'found',
|
|
symbol: { filePath: 'other/path/x.ts', uid: 'u1', name: 'X' },
|
|
})),
|
|
});
|
|
const svc = new GroupService(port);
|
|
const r = await svc.groupContext({
|
|
name: 'test-group',
|
|
target: 'MySym',
|
|
service: 'services/auth',
|
|
});
|
|
expect(r.results.every((x) => Object.keys(x.payload as object).length === 0)).toBe(true);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
|
|
describe('groupStatus', () => {
|
|
it('test_groupStatus_returns_error_when_name_empty', async () => {
|
|
const svc = new GroupService(makePort());
|
|
const result = (await svc.groupStatus({})) as { error: string };
|
|
expect(result.error).toContain('name is required');
|
|
});
|
|
|
|
it('test_groupStatus_marks_unresolvable_repos_as_missing', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
|
|
const port = makePort({
|
|
resolveRepo: vi.fn(async () => {
|
|
throw new Error('repo not found');
|
|
}),
|
|
});
|
|
|
|
const svc = new GroupService(port);
|
|
const result = (await svc.groupStatus({ name: 'test-group' })) as {
|
|
group: string;
|
|
repos: Record<string, { missing: boolean }>;
|
|
};
|
|
|
|
expect(result.group).toBe('test-group');
|
|
expect(result.repos['app/backend'].missing).toBe(true);
|
|
expect(result.repos['app/frontend'].missing).toBe(true);
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
// #3256: a resolvable repo with no recorded commit. The formatter tests
|
|
// hand-build this row; this one drives the real composition, so swapping
|
|
// the no-commit literal for the git-probe `unknown()` (indexStale: false,
|
|
// commitsBehind: 0) would print `OK` here and fail.
|
|
it('test_groupStatus_reports_no_recorded_commit_as_unknown_and_renders_it_as_?', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
// Resolves, but its storage holds no meta.json, so nothing was recorded.
|
|
const storagePath = path.join(tmpDir, 'no-meta', '.gitnexus');
|
|
fs.mkdirSync(storagePath, { recursive: true });
|
|
const port = makePort({
|
|
resolveRepo: vi.fn(
|
|
async (name?: string): Promise<GroupRepoHandle> => ({
|
|
id: name || 'test',
|
|
name: name || 'test',
|
|
repoPath: path.join(tmpDir, 'no-meta'),
|
|
storagePath,
|
|
}),
|
|
),
|
|
});
|
|
|
|
const svc = new GroupService(port);
|
|
const result = (await svc.groupStatus({ name: 'test-group' })) as {
|
|
repos: Record<string, GroupRepoIndexRow & { missing: boolean }>;
|
|
};
|
|
|
|
const row = result.repos['app/backend'];
|
|
expect(row).toMatchObject({
|
|
missing: false,
|
|
indexStale: true,
|
|
commitsBehind: -1,
|
|
status: 'unknown',
|
|
});
|
|
expect(formatIndexStatusCell(row)).toBe('STALE (? commits behind)');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
|
|
it('renders a real rollback as an index that differs from HEAD', async () => {
|
|
const { cleanup, tmpDir } = makeTmpGroup();
|
|
try {
|
|
vi.stubEnv('GITNEXUS_HOME', tmpDir);
|
|
const repoPath = path.join(tmpDir, 'rollback');
|
|
fs.mkdirSync(repoPath);
|
|
const git = (...args: string[]): string =>
|
|
execFileSync(
|
|
'git',
|
|
[
|
|
'-c',
|
|
'user.email=t@example.com',
|
|
'-c',
|
|
'user.name=T',
|
|
'-c',
|
|
'commit.gpgsign=false',
|
|
...args,
|
|
],
|
|
{ cwd: repoPath, encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] },
|
|
).trim();
|
|
git('init', '-q', '--initial-branch=main');
|
|
git('commit', '--allow-empty', '-qm', 'first');
|
|
const firstCommit = git('rev-parse', 'HEAD');
|
|
git('commit', '--allow-empty', '-qm', 'indexed');
|
|
const indexedCommit = git('rev-parse', 'HEAD');
|
|
git('checkout', '-q', '--detach', firstCommit);
|
|
|
|
const storagePath = path.join(repoPath, '.gitnexus');
|
|
fs.mkdirSync(storagePath);
|
|
fs.writeFileSync(
|
|
path.join(storagePath, 'gitnexus.json'),
|
|
JSON.stringify({ lastCommit: indexedCommit, indexedAt: '2026-01-01T00:00:00.000Z' }),
|
|
);
|
|
const port = makePort({
|
|
resolveRepo: vi.fn(
|
|
async (name?: string): Promise<GroupRepoHandle> => ({
|
|
id: name || 'test',
|
|
name: name || 'test',
|
|
repoPath,
|
|
storagePath,
|
|
}),
|
|
),
|
|
});
|
|
|
|
const svc = new GroupService(port);
|
|
const result = (await svc.groupStatus({ name: 'test-group' })) as {
|
|
repos: Record<string, GroupRepoIndexRow & { missing: boolean }>;
|
|
};
|
|
const row = result.repos['app/backend'];
|
|
expect(row).toMatchObject({
|
|
missing: false,
|
|
indexStale: true,
|
|
commitsBehind: 0,
|
|
status: 'diverged',
|
|
});
|
|
expect(formatIndexStatusCell(row)).toBe('STALE (index differs from HEAD)');
|
|
} finally {
|
|
vi.unstubAllEnvs();
|
|
cleanup();
|
|
}
|
|
});
|
|
});
|
|
});
|