Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
166 changes: 166 additions & 0 deletions apps/mcp-server/src/tui/__perf__/mcp-overhead.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,166 @@
import { describe, it, expect, vi, beforeEach } from 'vitest';
import { TuiInterceptor } from '../events/tui-interceptor';
import { TuiEventBus } from '../events/event-bus';
import { TUI_EVENTS } from '../events/types';

describe('비활성화 시 이벤트 발행 0회 (Zero Overhead)', () => {
let interceptor: TuiInterceptor;
let eventBus: TuiEventBus;
let emitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
eventBus = new TuiEventBus();
interceptor = new TuiInterceptor(eventBus);
emitSpy = vi.spyOn(eventBus, 'emit');
});

it('should not emit events for agent tool when disabled', async () => {
const result = await interceptor.intercept(
'get_agent_system_prompt',
{ agentName: 'test', context: { mode: 'EVAL' } },
async () => 'result',
);

expect(emitSpy).not.toHaveBeenCalled();
expect(result).toBe('result');
});

it('should not emit events for non-agent tool when disabled', async () => {
const result = await interceptor.intercept(
'search_rules',
{ query: 'test' },
async () => 'search-result',
);

expect(emitSpy).not.toHaveBeenCalled();
expect(result).toBe('search-result');
});

it('should passthrough result without modification', async () => {
const payload = { content: [{ type: 'text', text: 'ok' }] };
const result = await interceptor.intercept(
'get_agent_system_prompt',
{ agentName: 'test', context: { mode: 'EVAL' } },
async () => payload,
);

expect(result).toBe(payload);
});
});

describe('활성화 시 응답 지연 < 5ms', () => {
let interceptor: TuiInterceptor;
let eventBus: TuiEventBus;

beforeEach(() => {
eventBus = new TuiEventBus();
interceptor = new TuiInterceptor(eventBus);
interceptor.enable();
});

it('should add less than 5ms overhead per call', async () => {
const execute = async () => ({
content: [{ type: 'text', text: 'ok' }],
});

const directStart = performance.now();
await execute();
const directDuration = performance.now() - directStart;

const interceptedStart = performance.now();
await interceptor.intercept(
'get_agent_system_prompt',
{ agentName: 'test', context: { mode: 'EVAL' } },
execute,
);
const interceptedDuration = performance.now() - interceptedStart;

await new Promise(resolve => setImmediate(resolve));

const overhead = interceptedDuration - directDuration;
expect(overhead).toBeLessThan(5);
});

it('should have median overhead < 5ms over 10 iterations', async () => {
const execute = async () => ({
content: [{ type: 'text', text: 'ok' }],
});

const overheads: number[] = [];

for (let i = 0; i < 10; i++) {
const directStart = performance.now();
await execute();
const directDuration = performance.now() - directStart;

const interceptedStart = performance.now();
await interceptor.intercept(
'get_agent_system_prompt',
{ agentName: 'test', context: { mode: 'EVAL' } },
execute,
);
const interceptedDuration = performance.now() - interceptedStart;

await new Promise(resolve => setImmediate(resolve));

overheads.push(interceptedDuration - directDuration);
}

const sorted = [...overheads].sort((a, b) => a - b);
const median = sorted[Math.floor(sorted.length / 2)];

expect(median).toBeLessThan(5);
});
});

describe('setImmediate fire-and-forget 확인', () => {
let interceptor: TuiInterceptor;
let eventBus: TuiEventBus;
let emitSpy: ReturnType<typeof vi.spyOn>;

beforeEach(() => {
eventBus = new TuiEventBus();
interceptor = new TuiInterceptor(eventBus);
interceptor.enable();
emitSpy = vi.spyOn(eventBus, 'emit');
});

it('should not emit events synchronously during intercept', async () => {
await interceptor.intercept(
'get_agent_system_prompt',
{ agentName: 'test', context: { mode: 'EVAL' } },
async () => 'result',
);

// Before setImmediate flushes, events should NOT have been emitted
expect(emitSpy).not.toHaveBeenCalled();
});

it('should emit events after setImmediate flushes', async () => {
await interceptor.intercept(
'get_agent_system_prompt',
{ agentName: 'test', context: { mode: 'EVAL' } },
async () => 'result',
);

expect(emitSpy).not.toHaveBeenCalled();

// Flush setImmediate callbacks
await new Promise(resolve => setImmediate(resolve));

expect(emitSpy).toHaveBeenCalledWith(
TUI_EVENTS.AGENT_ACTIVATED,
expect.objectContaining({
agentId: 'test',
name: 'test',
}),
);
expect(emitSpy).toHaveBeenCalledWith(
TUI_EVENTS.AGENT_DEACTIVATED,
expect.objectContaining({
agentId: 'test',
reason: 'completed',
}),
);
});
});
130 changes: 130 additions & 0 deletions apps/mcp-server/src/tui/__perf__/memory-stability.spec.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,130 @@
import React from 'react';
import { describe, it, expect, vi } from 'vitest';
import { render } from 'ink-testing-library';
import { App } from '../app';
import { TuiEventBus, TUI_EVENTS } from '../events';
import type { TuiEventName } from '../events/types';

vi.mock('../utils/icons', async importOriginal => {
const actual = await importOriginal<typeof import('../utils/icons')>();
return {
...actual,
isNerdFontEnabled: () => false,
};
});

const tick = () => new Promise(resolve => setTimeout(resolve, 0));

describe('10,000 이벤트 후 메모리 증가 < 50MB (GC 없이)', () => {
it('should keep memory growth under 50MB after 10,000 events', async () => {
const eventBus = new TuiEventBus();
const { unmount } = render(<App eventBus={eventBus} />);

global.gc?.();
const heapBefore = process.memoryUsage().heapUsed;

for (let i = 0; i < 5000; i++) {
eventBus.emit(TUI_EVENTS.AGENT_ACTIVATED, {
agentId: `agent-${i}`,
name: `agent-${i}`,
role: 'specialist',
isPrimary: false,
});

eventBus.emit(TUI_EVENTS.AGENT_DEACTIVATED, {
agentId: `agent-${i}`,
reason: 'completed',
durationMs: 100,
});
}

await tick();

global.gc?.();
const heapAfter = process.memoryUsage().heapUsed;
const delta = heapAfter - heapBefore;

// 50MB threshold accounts for Node.js heap measurement variance
// without --expose-gc; the key invariant is no unbounded growth.
expect(delta).toBeLessThan(50 * 1024 * 1024);

unmount();
}, 30_000);
});

describe('리스너 누적 방지', () => {
it('should not accumulate listeners after mount/unmount cycles', async () => {
const eventBus = new TuiEventBus();

const initialCounts = new Map<TuiEventName, number>();
for (const event of Object.values(TUI_EVENTS)) {
const eventName = event as TuiEventName;
initialCounts.set(eventName, eventBus.listenerCount(eventName));
}

const { unmount } = render(<App eventBus={eventBus} />);

for (let i = 0; i < 100; i++) {
eventBus.emit(TUI_EVENTS.AGENT_ACTIVATED, {
agentId: `agent-${i}`,
name: `agent-${i}`,
role: 'specialist',
isPrimary: false,
});

eventBus.emit(TUI_EVENTS.AGENT_DEACTIVATED, {
agentId: `agent-${i}`,
reason: 'completed',
durationMs: 100,
});
}

await tick();
unmount();

for (const event of Object.values(TUI_EVENTS)) {
const eventName = event as TuiEventName;
expect(eventBus.listenerCount(eventName)).toBe(
initialCounts.get(eventName),
);
}
});
});

describe('비활성화 Agent 상태 정리 확인', () => {
it('should clean up deactivated agents and reflect correct active count', async () => {
const eventBus = new TuiEventBus();
const { lastFrame } = render(<App eventBus={eventBus} />);

for (let i = 0; i < 5; i++) {
eventBus.emit(TUI_EVENTS.AGENT_ACTIVATED, {
agentId: `agent-${i}`,
name: `agent-${i}`,
role: 'specialist',
isPrimary: false,
});
}
await tick();

for (let i = 0; i < 5; i++) {
eventBus.emit(TUI_EVENTS.AGENT_DEACTIVATED, {
agentId: `agent-${i}`,
reason: 'completed',
durationMs: 100,
});
}
await tick();

expect(lastFrame()).toContain('0 active');

eventBus.emit(TUI_EVENTS.AGENT_ACTIVATED, {
agentId: 'new-agent',
name: 'new-agent',
role: 'specialist',
isPrimary: false,
});
await tick();

expect(lastFrame()).toContain('1 active');
});
});
68 changes: 68 additions & 0 deletions apps/mcp-server/src/tui/__perf__/nerd-font-fallback.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, it, expect, beforeEach, afterAll } from 'vitest';
import { isNerdFontEnabled, getAgentIcon, AGENT_ICONS } from '../utils/icons';

const ALL_AGENT_NAMES = Object.keys(AGENT_ICONS);

describe('Nerd Font fallback – systematic icon rendering for all 29 agents', () => {
const originalEnv = process.env;

beforeEach(() => {
process.env = { ...originalEnv };
});

afterAll(() => {
process.env = originalEnv;
});

describe('TERM_NERD_FONT=true 일 때 모든 Agent 아이콘', () => {
beforeEach(() => {
process.env.TERM_NERD_FONT = 'true';
});

it.each(ALL_AGENT_NAMES)(
'%s should return its nerdFont icon',
agentName => {
const result = getAgentIcon(agentName);
expect(result).toBe(AGENT_ICONS[agentName].nerdFont);
},
);
});

describe('TERM_NERD_FONT=false 일 때 모든 Agent 폴백', () => {
beforeEach(() => {
process.env.TERM_NERD_FONT = 'false';
});

it.each(ALL_AGENT_NAMES)(
'%s should return its fallback icon',
agentName => {
const result = getAgentIcon(agentName);
expect(result).toBe(AGENT_ICONS[agentName].fallback);
expect(result).toMatch(/^\[.+\]$/);
},
);
});

describe('TERM_NERD_FONT 미설정 시 기본 폴백 동작', () => {
beforeEach(() => {
delete process.env.TERM_NERD_FONT;
});

it('isNerdFontEnabled() should return false', () => {
expect(isNerdFontEnabled()).toBe(false);
});

it.each(ALL_AGENT_NAMES)(
'%s should return its fallback when env is unset',
agentName => {
const result = getAgentIcon(agentName);
expect(result).toBe(AGENT_ICONS[agentName].fallback);
},
);

it('unknown agent should return default fallback "[?]"', () => {
const result = getAgentIcon('nonexistent');
expect(result).toBe('[?]');
});
});
});
Loading