mirror of
https://github.com/abhigyanpatwari/GitNexus.git
synced 2026-08-28 05:25:25 +00:00
* feat(eval-server): added --host for user configured host IP instead of system hardcoded IP (127.0.0.1) * fix(eval-server): localhost value in --host now returns 127.0.0.1 instead of the raw input to fix wrong address, handled error for ipv6 disabled containers * feat(eval-server): add --host flag with validation and error handling Co-Authored-By: Val Vladescu <val.vladescu@thirdbridge.com> * fix(eval-server): bracketed IPv6 addresses to remove ambiguity * docs(eval-server): document --host flag, READY signal format, and parser migration note * fix(eval-server): use actual bound port in READY signal; strengthen --host e2e tests Co-Authored-By: Val Vladescu <val.vladescu@thirdbridge.com> * feat(eval): wire eval-server --host through gitnexus_docker.py * docs(eval): added guidance for docker user * docs(eval): revise the imprecise documentation * fix(e2e): updated original stdout for new format
407 lines
13 KiB
TypeScript
407 lines
13 KiB
TypeScript
/**
|
|
* P1 Unit Tests: Eval Server Formatters
|
|
*
|
|
* Tests: formatQueryResult, formatContextResult, formatImpactResult,
|
|
* formatCypherResult, formatDetectChangesResult, formatListReposResult, MAX_BODY_SIZE
|
|
*/
|
|
import { describe, it, expect } from 'vitest';
|
|
import {
|
|
formatQueryResult,
|
|
formatContextResult,
|
|
formatImpactResult,
|
|
formatCypherResult,
|
|
formatDetectChangesResult,
|
|
formatListReposResult,
|
|
MAX_BODY_SIZE,
|
|
validateHost,
|
|
} from '../../src/cli/eval-server.js';
|
|
|
|
// ─── validateHost ────────────────────────────────────────────────────
|
|
|
|
describe('validateHost', () => {
|
|
it('normalizes "localhost" to "127.0.0.1"', () => {
|
|
expect(validateHost('localhost')).toBe('127.0.0.1');
|
|
});
|
|
|
|
it('accepts valid IPv4 addresses', () => {
|
|
expect(validateHost('127.0.0.1')).toBe('127.0.0.1');
|
|
expect(validateHost('0.0.0.0')).toBe('0.0.0.0');
|
|
expect(validateHost('192.168.1.5')).toBe('192.168.1.5');
|
|
expect(validateHost('10.0.0.1')).toBe('10.0.0.1');
|
|
});
|
|
|
|
it('accepts valid IPv6 addresses', () => {
|
|
expect(validateHost('::1')).toBe('::1');
|
|
expect(validateHost('::')).toBe('::');
|
|
expect(validateHost('2001:db8::1')).toBe('2001:db8::1');
|
|
});
|
|
|
|
it('returns null for a non-IP hostname', () => {
|
|
expect(validateHost('foo.bar')).toBeNull();
|
|
expect(validateHost('myhost.local')).toBeNull();
|
|
expect(validateHost('example.com')).toBeNull();
|
|
});
|
|
|
|
it('returns null for out-of-range IPv4 octets', () => {
|
|
expect(validateHost('999.999.999.999')).toBeNull();
|
|
expect(validateHost('192.168.1.256')).toBeNull();
|
|
});
|
|
|
|
it('returns null for incomplete IPv4 addresses', () => {
|
|
expect(validateHost('192.168.1')).toBeNull();
|
|
expect(validateHost('192.168')).toBeNull();
|
|
});
|
|
|
|
it('returns null for an empty string', () => {
|
|
expect(validateHost('')).toBeNull();
|
|
});
|
|
|
|
it('returns null for whitespace or padded IPs', () => {
|
|
expect(validateHost(' ')).toBeNull();
|
|
expect(validateHost(' 127.0.0.1')).toBeNull();
|
|
expect(validateHost('127.0.0.1 ')).toBeNull();
|
|
});
|
|
});
|
|
|
|
// ─── MAX_BODY_SIZE ───────────────────────────────────────────────────
|
|
|
|
describe('MAX_BODY_SIZE', () => {
|
|
it('is 1MB', () => {
|
|
expect(MAX_BODY_SIZE).toBe(1024 * 1024);
|
|
});
|
|
});
|
|
|
|
// ─── formatQueryResult ───────────────────────────────────────────────
|
|
|
|
describe('formatQueryResult', () => {
|
|
it('returns error message for error input', () => {
|
|
expect(formatQueryResult({ error: 'something failed' })).toBe('Error: something failed');
|
|
});
|
|
|
|
it('returns no-match message for empty results', () => {
|
|
const result = formatQueryResult({ processes: [], definitions: [] });
|
|
expect(result).toContain('No matching execution flows');
|
|
});
|
|
|
|
it('formats processes with symbols', () => {
|
|
const result = formatQueryResult({
|
|
processes: [{ id: 'p1', summary: 'User Login Flow', step_count: 3, symbol_count: 2 }],
|
|
process_symbols: [
|
|
{
|
|
process_id: 'p1',
|
|
type: 'Function',
|
|
name: 'login',
|
|
filePath: 'src/auth.ts',
|
|
startLine: 10,
|
|
},
|
|
{
|
|
process_id: 'p1',
|
|
type: 'Function',
|
|
name: 'validate',
|
|
filePath: 'src/auth.ts',
|
|
startLine: 20,
|
|
},
|
|
],
|
|
definitions: [],
|
|
});
|
|
expect(result).toContain('1 execution flow');
|
|
expect(result).toContain('User Login Flow');
|
|
expect(result).toContain('login');
|
|
expect(result).toContain(':10');
|
|
});
|
|
|
|
it('truncates symbols per process at 6', () => {
|
|
const symbols = Array.from({ length: 10 }, (_, i) => ({
|
|
process_id: 'p1',
|
|
type: 'Function',
|
|
name: `fn${i}`,
|
|
filePath: 'src/test.ts',
|
|
}));
|
|
const result = formatQueryResult({
|
|
processes: [{ id: 'p1', summary: 'Flow', step_count: 10, symbol_count: 10 }],
|
|
process_symbols: symbols,
|
|
definitions: [],
|
|
});
|
|
expect(result).toContain('and 4 more');
|
|
});
|
|
|
|
it('formats standalone definitions', () => {
|
|
const result = formatQueryResult({
|
|
processes: [],
|
|
definitions: [{ type: 'Interface', name: 'Config', filePath: 'src/types.ts' }],
|
|
});
|
|
expect(result).toContain('Standalone definitions');
|
|
expect(result).toContain('Config');
|
|
});
|
|
|
|
it('truncates definitions at 8', () => {
|
|
const defs = Array.from({ length: 12 }, (_, i) => ({
|
|
type: 'Interface',
|
|
name: `Type${i}`,
|
|
filePath: 'src/types.ts',
|
|
}));
|
|
const result = formatQueryResult({ processes: [], definitions: defs });
|
|
expect(result).toContain('and 4 more');
|
|
});
|
|
});
|
|
|
|
// ─── formatContextResult ─────────────────────────────────────────────
|
|
|
|
describe('formatContextResult', () => {
|
|
it('returns error message for error input', () => {
|
|
expect(formatContextResult({ error: 'not found' })).toBe('Error: not found');
|
|
});
|
|
|
|
it('handles ambiguous results', () => {
|
|
const result = formatContextResult({
|
|
status: 'ambiguous',
|
|
candidates: [
|
|
{ name: 'foo', kind: 'Function', filePath: 'src/a.ts', line: 10, uid: 'uid1' },
|
|
{ name: 'foo', kind: 'Function', filePath: 'src/b.ts', line: 5, uid: 'uid2' },
|
|
],
|
|
});
|
|
expect(result).toContain('Multiple symbols');
|
|
expect(result).toContain('uid1');
|
|
expect(result).toContain('uid2');
|
|
});
|
|
|
|
it('returns "Symbol not found" when no symbol', () => {
|
|
expect(formatContextResult({})).toBe('Symbol not found.');
|
|
});
|
|
|
|
it('formats symbol with incoming/outgoing refs', () => {
|
|
const result = formatContextResult({
|
|
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts', startLine: 1, endLine: 10 },
|
|
incoming: {
|
|
CALLS: [{ kind: 'Function', name: 'bar', filePath: 'src/b.ts' }],
|
|
},
|
|
outgoing: {
|
|
IMPORTS: [{ kind: 'Module', name: 'utils', filePath: 'src/utils.ts' }],
|
|
},
|
|
processes: [],
|
|
});
|
|
expect(result).toContain('Function foo');
|
|
expect(result).toContain('Called/imported by (1)');
|
|
expect(result).toContain('Calls/imports (1)');
|
|
});
|
|
|
|
it('formats process participation', () => {
|
|
const result = formatContextResult({
|
|
symbol: { kind: 'Function', name: 'foo', filePath: 'src/a.ts' },
|
|
incoming: {},
|
|
outgoing: {},
|
|
processes: [{ name: 'Auth Flow', step_index: 2, step_count: 5 }],
|
|
});
|
|
expect(result).toContain('1 execution flow');
|
|
expect(result).toContain('Auth Flow');
|
|
});
|
|
});
|
|
|
|
// ─── formatImpactResult ──────────────────────────────────────────────
|
|
|
|
describe('formatImpactResult', () => {
|
|
it('returns error message for error input', () => {
|
|
expect(formatImpactResult({ error: 'bad request' })).toContain('Error: bad request');
|
|
});
|
|
|
|
it('returns error with suggestion when provided', () => {
|
|
const result = formatImpactResult({
|
|
error: 'Impact analysis failed',
|
|
suggestion: 'Try gitnexus context <symbol> as a fallback',
|
|
});
|
|
expect(result).toContain('Error: Impact analysis failed');
|
|
expect(result).toContain('Suggestion: Try gitnexus context');
|
|
});
|
|
|
|
it('shows partial warning when traversal was interrupted', () => {
|
|
const result = formatImpactResult({
|
|
target: { kind: 'Function', name: 'foo' },
|
|
direction: 'upstream',
|
|
impactedCount: 2,
|
|
partial: true,
|
|
byDepth: {
|
|
1: [
|
|
{
|
|
type: 'Function',
|
|
name: 'caller1',
|
|
filePath: 'src/a.ts',
|
|
relationType: 'CALLS',
|
|
confidence: 1,
|
|
},
|
|
{
|
|
type: 'Function',
|
|
name: 'caller2',
|
|
filePath: 'src/b.ts',
|
|
relationType: 'CALLS',
|
|
confidence: 1,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(result).toContain('Partial results');
|
|
expect(result).toContain('caller1');
|
|
expect(result).toContain('caller2');
|
|
});
|
|
|
|
it('handles zero impact', () => {
|
|
const result = formatImpactResult({
|
|
target: { name: 'foo' },
|
|
direction: 'upstream',
|
|
impactedCount: 0,
|
|
byDepth: {},
|
|
});
|
|
expect(result).toContain('No upstream dependencies');
|
|
});
|
|
|
|
it('formats impact by depth', () => {
|
|
const result = formatImpactResult({
|
|
target: { kind: 'Function', name: 'foo' },
|
|
direction: 'upstream',
|
|
impactedCount: 3,
|
|
byDepth: {
|
|
1: [
|
|
{
|
|
type: 'Function',
|
|
name: 'caller1',
|
|
filePath: 'src/a.ts',
|
|
relationType: 'CALLS',
|
|
confidence: 1,
|
|
},
|
|
{
|
|
type: 'Function',
|
|
name: 'caller2',
|
|
filePath: 'src/b.ts',
|
|
relationType: 'CALLS',
|
|
confidence: 0.8,
|
|
},
|
|
],
|
|
2: [
|
|
{
|
|
type: 'Class',
|
|
name: 'App',
|
|
filePath: 'src/app.ts',
|
|
relationType: 'IMPORTS',
|
|
confidence: 1,
|
|
},
|
|
],
|
|
},
|
|
});
|
|
expect(result).toContain('Blast radius');
|
|
expect(result).toContain('WILL BREAK');
|
|
expect(result).toContain('caller1');
|
|
expect(result).toContain('conf: 0.8');
|
|
expect(result).toContain('LIKELY AFFECTED');
|
|
});
|
|
|
|
it('truncates items per depth at 12', () => {
|
|
const items = Array.from({ length: 15 }, (_, i) => ({
|
|
type: 'Function',
|
|
name: `fn${i}`,
|
|
filePath: 'src/test.ts',
|
|
relationType: 'CALLS',
|
|
confidence: 1,
|
|
}));
|
|
const result = formatImpactResult({
|
|
target: { kind: 'Function', name: 'foo' },
|
|
direction: 'upstream',
|
|
impactedCount: 15,
|
|
byDepth: { 1: items },
|
|
});
|
|
expect(result).toContain('and 3 more');
|
|
});
|
|
});
|
|
|
|
// ─── formatCypherResult ──────────────────────────────────────────────
|
|
|
|
describe('formatCypherResult', () => {
|
|
it('returns error message for error input', () => {
|
|
expect(formatCypherResult({ error: 'syntax error' })).toBe('Error: syntax error');
|
|
});
|
|
|
|
it('handles empty array', () => {
|
|
expect(formatCypherResult([])).toBe('Query returned 0 rows.');
|
|
});
|
|
|
|
it('formats array of objects as table', () => {
|
|
const result = formatCypherResult([
|
|
{ name: 'foo', filePath: 'src/a.ts' },
|
|
{ name: 'bar', filePath: 'src/b.ts' },
|
|
]);
|
|
expect(result).toContain('2 row(s)');
|
|
expect(result).toContain('name: foo');
|
|
expect(result).toContain('name: bar');
|
|
});
|
|
|
|
it('truncates at 30 rows', () => {
|
|
const rows = Array.from({ length: 35 }, (_, i) => ({ id: i }));
|
|
const result = formatCypherResult(rows);
|
|
expect(result).toContain('5 more rows');
|
|
});
|
|
|
|
it('handles string result', () => {
|
|
expect(formatCypherResult('some text')).toBe('some text');
|
|
});
|
|
});
|
|
|
|
// ─── formatDetectChangesResult ───────────────────────────────────────
|
|
|
|
describe('formatDetectChangesResult', () => {
|
|
it('returns error message for error input', () => {
|
|
expect(formatDetectChangesResult({ error: 'git error' })).toBe('Error: git error');
|
|
});
|
|
|
|
it('handles no changes', () => {
|
|
const result = formatDetectChangesResult({ summary: { changed_count: 0 } });
|
|
expect(result).toBe('No changes detected.');
|
|
});
|
|
|
|
it('formats changes with affected processes', () => {
|
|
const result = formatDetectChangesResult({
|
|
summary: { changed_files: 2, changed_count: 3, affected_count: 1, risk_level: 'MEDIUM' },
|
|
changed_symbols: [{ type: 'Function', name: 'foo', filePath: 'src/a.ts' }],
|
|
affected_processes: [
|
|
{ name: 'Auth Flow', step_count: 5, changed_steps: [{ symbol: 'foo' }] },
|
|
],
|
|
});
|
|
expect(result).toContain('2 files');
|
|
expect(result).toContain('MEDIUM');
|
|
expect(result).toContain('Auth Flow');
|
|
});
|
|
|
|
it('truncates changed symbols at 15', () => {
|
|
const symbols = Array.from({ length: 20 }, (_, i) => ({
|
|
type: 'Function',
|
|
name: `fn${i}`,
|
|
filePath: 'src/test.ts',
|
|
}));
|
|
const result = formatDetectChangesResult({
|
|
summary: { changed_files: 1, changed_count: 20, affected_count: 0, risk_level: 'HIGH' },
|
|
changed_symbols: symbols,
|
|
affected_processes: [],
|
|
});
|
|
expect(result).toContain('and 5 more');
|
|
});
|
|
});
|
|
|
|
// ─── formatListReposResult ───────────────────────────────────────────
|
|
|
|
describe('formatListReposResult', () => {
|
|
it('handles empty/null input', () => {
|
|
expect(formatListReposResult([])).toBe('No indexed repositories.');
|
|
expect(formatListReposResult(null)).toBe('No indexed repositories.');
|
|
});
|
|
|
|
it('formats repo list', () => {
|
|
const result = formatListReposResult([
|
|
{
|
|
name: 'my-project',
|
|
path: '/home/user/my-project',
|
|
indexedAt: '2024-01-01',
|
|
stats: { nodes: 100, edges: 200, processes: 10 },
|
|
},
|
|
]);
|
|
expect(result).toContain('Indexed repositories');
|
|
expect(result).toContain('my-project');
|
|
expect(result).toContain('100 symbols');
|
|
});
|
|
});
|