Skip to content

Commit 8d5062a

Browse files
committed
test: add TUI component tests for Panel, TwoColumn, LogPanel, Cursor, NextSteps, AwsTargetConfigUI
1 parent 654aa66 commit 8d5062a

File tree

6 files changed

+562
-0
lines changed

6 files changed

+562
-0
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import { getAwsConfigHelpText } from '../AwsTargetConfigUI.js';
2+
import { describe, expect, it } from 'vitest';
3+
4+
describe('getAwsConfigHelpText', () => {
5+
it('returns exact help text for choice phase', () => {
6+
expect(getAwsConfigHelpText('choice')).toBe('↑↓ navigate · Enter select · Esc exit');
7+
});
8+
9+
it('returns same help text for token-expired as choice', () => {
10+
expect(getAwsConfigHelpText('token-expired')).toBe(getAwsConfigHelpText('choice'));
11+
});
12+
13+
it('returns exact help text for select-target phase', () => {
14+
expect(getAwsConfigHelpText('select-target')).toBe('↑↓ navigate · Space toggle · Enter deploy · Esc exit');
15+
});
16+
17+
it('returns exact help text for manual-account phase', () => {
18+
expect(getAwsConfigHelpText('manual-account')).toBe('12-digit account ID · Esc back');
19+
});
20+
21+
it('returns exact help text for manual-region phase', () => {
22+
expect(getAwsConfigHelpText('manual-region')).toBe('Type to filter · ↑↓ navigate · Enter select · Esc back');
23+
});
24+
25+
it('returns undefined for loading phases', () => {
26+
expect(getAwsConfigHelpText('checking')).toBeUndefined();
27+
expect(getAwsConfigHelpText('detecting')).toBeUndefined();
28+
expect(getAwsConfigHelpText('saving')).toBeUndefined();
29+
});
30+
31+
it('returns undefined for terminal phases', () => {
32+
expect(getAwsConfigHelpText('configured')).toBeUndefined();
33+
expect(getAwsConfigHelpText('error')).toBeUndefined();
34+
});
35+
});
Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
import { Cursor } from '../Cursor.js';
2+
import { render } from 'ink-testing-library';
3+
import React from 'react';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
afterEach(() => vi.restoreAllMocks());
7+
8+
describe('Cursor', () => {
9+
it('renders the provided character on initial mount', () => {
10+
const { lastFrame } = render(<Cursor char="X" />);
11+
expect(lastFrame()).toContain('X');
12+
});
13+
14+
it('sets up a blink interval using setInterval', () => {
15+
const spy = vi.spyOn(globalThis, 'setInterval');
16+
render(<Cursor char="A" interval={500} />);
17+
// Cursor uses setInterval with the provided interval for blinking
18+
expect(spy).toHaveBeenCalledWith(expect.any(Function), 500);
19+
});
20+
21+
it('uses custom interval value for the blink timer', () => {
22+
const spy = vi.spyOn(globalThis, 'setInterval');
23+
render(<Cursor char="B" interval={200} />);
24+
expect(spy).toHaveBeenCalledWith(expect.any(Function), 200);
25+
});
26+
27+
it('cleans up interval timer on unmount', () => {
28+
const spy = vi.spyOn(globalThis, 'clearInterval');
29+
const { unmount } = render(<Cursor char="C" interval={200} />);
30+
unmount();
31+
// clearInterval should be called during cleanup
32+
expect(spy).toHaveBeenCalled();
33+
});
34+
});
Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import type { LogEntry } from '../LogPanel.js';
2+
import { LogPanel } from '../LogPanel.js';
3+
import { render } from 'ink-testing-library';
4+
import React from 'react';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
7+
const UP_ARROW = '\x1B[A';
8+
const DOWN_ARROW = '\x1B[B';
9+
10+
afterEach(() => vi.restoreAllMocks());
11+
12+
const makeLogs = (count: number, level: LogEntry['level'] = 'system'): LogEntry[] =>
13+
Array.from({ length: count }, (_, i) => ({
14+
level,
15+
message: `Log message ${i + 1}`,
16+
}));
17+
18+
describe('LogPanel', () => {
19+
describe('empty state', () => {
20+
it('renders "No output yet" with no other content', () => {
21+
const { lastFrame } = render(<LogPanel logs={[]} />);
22+
expect(lastFrame()).toBe('No output yet');
23+
});
24+
});
25+
26+
describe('rendering', () => {
27+
it('renders system log messages without level label', () => {
28+
const logs: LogEntry[] = [{ level: 'system', message: 'Agent started' }];
29+
const { lastFrame } = render(<LogPanel logs={logs} />);
30+
const frame = lastFrame()!;
31+
expect(frame).toContain('Agent started');
32+
// System logs don't show the level label prefix
33+
expect(frame).not.toContain('SYSTEM');
34+
});
35+
36+
it('renders response logs with "Response" separator and message', () => {
37+
const logs: LogEntry[] = [{ level: 'response', message: 'Hello from agent' }];
38+
const { lastFrame } = render(<LogPanel logs={logs} />);
39+
const frame = lastFrame()!;
40+
expect(frame).toContain('─── Response ───');
41+
expect(frame).toContain('Hello from agent');
42+
});
43+
44+
it('renders error logs with ERROR level prefix', () => {
45+
const logs: LogEntry[] = [{ level: 'error', message: 'Something broke' }];
46+
const { lastFrame } = render(<LogPanel logs={logs} />);
47+
const frame = lastFrame()!;
48+
// ERROR label is padded to 6 chars
49+
expect(frame).toMatch(/ERROR\s+Something broke/);
50+
});
51+
52+
it('renders warn logs with WARN level prefix', () => {
53+
const logs: LogEntry[] = [{ level: 'warn', message: 'Slow response' }];
54+
const { lastFrame } = render(<LogPanel logs={logs} />);
55+
expect(lastFrame()).toMatch(/WARN\s+Slow response/);
56+
});
57+
});
58+
59+
describe('minimal filtering', () => {
60+
it('hides info-level logs in minimal mode (default)', () => {
61+
const logs: LogEntry[] = [
62+
{ level: 'info', message: 'Debug info' },
63+
{ level: 'system', message: 'Visible system log' },
64+
];
65+
const { lastFrame } = render(<LogPanel logs={logs} />);
66+
expect(lastFrame()).not.toContain('Debug info');
67+
expect(lastFrame()).toContain('Visible system log');
68+
});
69+
70+
it('hides logs containing JSON debug markers like "timestamp" or "level"', () => {
71+
const logs: LogEntry[] = [
72+
{ level: 'error', message: '{"timestamp": "2024-01-01", "level": "ERROR"}' },
73+
{ level: 'system', message: 'Visible log' },
74+
];
75+
const { lastFrame } = render(<LogPanel logs={logs} />);
76+
expect(lastFrame()).not.toContain('timestamp');
77+
expect(lastFrame()).toContain('Visible log');
78+
});
79+
80+
it('hides warn/error logs starting with [ or { as JSON debug', () => {
81+
const logs: LogEntry[] = [
82+
{ level: 'warn', message: '[{"key": "value"}]' },
83+
{ level: 'error', message: '{"error": "details"}' },
84+
{ level: 'system', message: 'Keep this' },
85+
];
86+
const { lastFrame } = render(<LogPanel logs={logs} />);
87+
expect(lastFrame()).not.toContain('key');
88+
expect(lastFrame()).not.toContain('details');
89+
expect(lastFrame()).toContain('Keep this');
90+
});
91+
92+
it('always shows response and system logs even with JSON-like content', () => {
93+
const logs: LogEntry[] = [
94+
{ level: 'response', message: '{"data": "json response"}' },
95+
{ level: 'system', message: '{"internal": true}' },
96+
];
97+
const { lastFrame } = render(<LogPanel logs={logs} />);
98+
expect(lastFrame()).toContain('json response');
99+
expect(lastFrame()).toContain('internal');
100+
});
101+
102+
it('shows plain error/warn messages that are not JSON', () => {
103+
const logs: LogEntry[] = [
104+
{ level: 'error', message: 'Connection timeout' },
105+
{ level: 'warn', message: 'Retrying in 5s' },
106+
];
107+
const { lastFrame } = render(<LogPanel logs={logs} />);
108+
expect(lastFrame()).toContain('Connection timeout');
109+
expect(lastFrame()).toContain('Retrying in 5s');
110+
});
111+
112+
it('shows all logs including info when minimal is false', () => {
113+
const logs: LogEntry[] = [
114+
{ level: 'info', message: 'Debug info visible' },
115+
{ level: 'system', message: 'System log' },
116+
];
117+
const { lastFrame } = render(<LogPanel logs={logs} minimal={false} />);
118+
expect(lastFrame()).toContain('Debug info visible');
119+
expect(lastFrame()).toContain('System log');
120+
});
121+
});
122+
123+
describe('scrolling', () => {
124+
it('shows "↑↓ scroll" indicator when logs exceed maxLines', () => {
125+
const logs = makeLogs(20);
126+
const { lastFrame } = render(<LogPanel logs={logs} maxLines={5} minimal={false} />);
127+
expect(lastFrame()).toContain('↑↓ scroll');
128+
});
129+
130+
it('does not show scroll indicator when all logs fit in maxLines', () => {
131+
const logs = makeLogs(3);
132+
const { lastFrame } = render(<LogPanel logs={logs} maxLines={10} minimal={false} />);
133+
expect(lastFrame()).not.toContain('↑↓ scroll');
134+
});
135+
136+
it('auto-scrolls to bottom showing latest logs', () => {
137+
const logs = makeLogs(20);
138+
const { lastFrame } = render(<LogPanel logs={logs} maxLines={5} minimal={false} />);
139+
const frame = lastFrame()!;
140+
// Should show the last 5 logs (16-20) and "more above"
141+
expect(frame).toContain('Log message 20');
142+
expect(frame).toContain('Log message 16');
143+
// 'Log message 1' would match 'Log message 16' etc, so use regex for exact match
144+
expect(frame).not.toMatch(/Log message 1\b/);
145+
expect(frame).toContain('more above');
146+
});
147+
148+
it('switches to manual scroll on up arrow, showing earliest logs', async () => {
149+
const logs = makeLogs(20);
150+
const { lastFrame, stdin } = render(<LogPanel logs={logs} maxLines={5} minimal={false} />);
151+
152+
// Initially auto-scrolled to bottom
153+
expect(lastFrame()).toContain('Log message 20');
154+
155+
// Up arrow sets userScrolled=true and scrollOffset stays at 0 (initial state),
156+
// so we jump to the top of the log showing messages 1-5
157+
await new Promise(resolve => setTimeout(resolve, 50));
158+
stdin.write(UP_ARROW);
159+
await new Promise(resolve => setTimeout(resolve, 50));
160+
161+
const frame = lastFrame()!;
162+
expect(frame).toContain('Log message 1');
163+
expect(frame).not.toContain('Log message 20');
164+
expect(frame).toContain('more below');
165+
});
166+
167+
it('scrolls back down to bottom after scrolling up', async () => {
168+
const logs = makeLogs(20);
169+
const { lastFrame, stdin } = render(<LogPanel logs={logs} maxLines={5} minimal={false} />);
170+
171+
// Scroll up to top
172+
await new Promise(resolve => setTimeout(resolve, 50));
173+
stdin.write(UP_ARROW);
174+
await new Promise(resolve => setTimeout(resolve, 50));
175+
expect(lastFrame()).toContain('Log message 1');
176+
177+
// Scroll down past maxScroll (15) to reach the bottom
178+
for (let i = 0; i < 15; i++) {
179+
await new Promise(resolve => setTimeout(resolve, 20));
180+
stdin.write(DOWN_ARROW);
181+
}
182+
await new Promise(resolve => setTimeout(resolve, 50));
183+
184+
expect(lastFrame()).toContain('Log message 20');
185+
});
186+
187+
it('supports vim-style j/k keys for scrolling', async () => {
188+
const logs = makeLogs(20);
189+
const { lastFrame, stdin } = render(<LogPanel logs={logs} maxLines={5} minimal={false} />);
190+
191+
// k scrolls up (same as up arrow)
192+
await new Promise(resolve => setTimeout(resolve, 50));
193+
stdin.write('k');
194+
await new Promise(resolve => setTimeout(resolve, 50));
195+
196+
expect(lastFrame()).toContain('Log message 1');
197+
198+
// j scrolls down
199+
stdin.write('j');
200+
await new Promise(resolve => setTimeout(resolve, 50));
201+
202+
expect(lastFrame()).toContain('Log message 2');
203+
});
204+
});
205+
});
Lines changed: 106 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,106 @@
1+
import { NextSteps } from '../NextSteps.js';
2+
import { render } from 'ink-testing-library';
3+
import React from 'react';
4+
import { afterEach, describe, expect, it, vi } from 'vitest';
5+
6+
const ENTER = '\r';
7+
const ESCAPE = '\x1B';
8+
const DOWN_ARROW = '\x1B[B';
9+
10+
afterEach(() => vi.restoreAllMocks());
11+
12+
const singleStep = [{ command: 'deploy', label: 'Deploy your agent' }];
13+
const multipleSteps = [
14+
{ command: 'deploy', label: 'Deploy your agent' },
15+
{ command: 'invoke', label: 'Test your agent' },
16+
];
17+
18+
describe('NextSteps non-interactive', () => {
19+
it('renders command hint for a single step', () => {
20+
const { lastFrame } = render(<NextSteps steps={singleStep} isInteractive={false} />);
21+
22+
expect(lastFrame()).toContain('agentcore deploy');
23+
expect(lastFrame()).toContain('deploy your agent');
24+
});
25+
26+
it('renders all commands for multiple steps', () => {
27+
const { lastFrame } = render(<NextSteps steps={multipleSteps} isInteractive={false} />);
28+
29+
expect(lastFrame()).toContain('agentcore deploy');
30+
expect(lastFrame()).toContain('agentcore invoke');
31+
expect(lastFrame()).toContain('or');
32+
});
33+
34+
it('returns null for empty steps', () => {
35+
const { lastFrame } = render(<NextSteps steps={[]} isInteractive={false} />);
36+
37+
// null render produces empty frame
38+
expect(lastFrame()).toBe('');
39+
});
40+
});
41+
42+
describe('NextSteps interactive', () => {
43+
it('renders Next steps header and selectable items', () => {
44+
const { lastFrame } = render(<NextSteps steps={singleStep} isInteractive={true} />);
45+
46+
expect(lastFrame()).toContain('Next steps:');
47+
expect(lastFrame()).toContain('deploy');
48+
expect(lastFrame()).toContain('return');
49+
});
50+
51+
it('includes return to main menu option', () => {
52+
const { lastFrame } = render(<NextSteps steps={singleStep} isInteractive={true} />);
53+
54+
expect(lastFrame()).toContain('Return to main menu');
55+
});
56+
57+
it('calls onSelect with correct step on Enter', async () => {
58+
const onSelect = vi.fn();
59+
const { stdin } = render(<NextSteps steps={multipleSteps} isInteractive={true} onSelect={onSelect} />);
60+
61+
// First item is 'deploy', press Enter
62+
await new Promise(resolve => setTimeout(resolve, 50));
63+
stdin.write(ENTER);
64+
await new Promise(resolve => setTimeout(resolve, 50));
65+
66+
expect(onSelect).toHaveBeenCalledWith({ command: 'deploy', label: 'Deploy your agent' });
67+
});
68+
69+
it('calls onSelect with second step after navigating down', async () => {
70+
const onSelect = vi.fn();
71+
const { stdin } = render(<NextSteps steps={multipleSteps} isInteractive={true} onSelect={onSelect} />);
72+
73+
await new Promise(resolve => setTimeout(resolve, 50));
74+
stdin.write(DOWN_ARROW);
75+
await new Promise(resolve => setTimeout(resolve, 50));
76+
stdin.write(ENTER);
77+
await new Promise(resolve => setTimeout(resolve, 50));
78+
79+
expect(onSelect).toHaveBeenCalledWith({ command: 'invoke', label: 'Test your agent' });
80+
});
81+
82+
it('calls onBack when return option is selected', async () => {
83+
const onBack = vi.fn();
84+
const { stdin } = render(<NextSteps steps={singleStep} isInteractive={true} onBack={onBack} />);
85+
86+
// Navigate down past the single step to the "return" option
87+
await new Promise(resolve => setTimeout(resolve, 50));
88+
stdin.write(DOWN_ARROW);
89+
await new Promise(resolve => setTimeout(resolve, 50));
90+
stdin.write(ENTER);
91+
await new Promise(resolve => setTimeout(resolve, 50));
92+
93+
expect(onBack).toHaveBeenCalledTimes(1);
94+
});
95+
96+
it('calls onBack on Escape', async () => {
97+
const onBack = vi.fn();
98+
const { stdin } = render(<NextSteps steps={singleStep} isInteractive={true} onBack={onBack} />);
99+
100+
await new Promise(resolve => setTimeout(resolve, 50));
101+
stdin.write(ESCAPE);
102+
await new Promise(resolve => setTimeout(resolve, 50));
103+
104+
expect(onBack).toHaveBeenCalledTimes(1);
105+
});
106+
});

0 commit comments

Comments
 (0)