Skip to content

Commit 2652c18

Browse files
committed
test: add TUI tests for Screen, FullScreenLogView, CredentialSourcePrompt
1 parent 5b3ccdf commit 2652c18

3 files changed

Lines changed: 356 additions & 0 deletions

File tree

Lines changed: 85 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,85 @@
1+
import { CredentialSourcePrompt } from '../CredentialSourcePrompt.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+
8+
afterEach(() => vi.restoreAllMocks());
9+
10+
const defaultProps = {
11+
missingCredentials: [
12+
{ providerName: 'OpenAI', envVarName: 'OPENAI_API_KEY' },
13+
{ providerName: 'Anthropic', envVarName: 'ANTHROPIC_API_KEY' },
14+
],
15+
onUseEnvLocal: vi.fn(),
16+
onManualEntry: vi.fn(),
17+
onSkip: vi.fn(),
18+
};
19+
20+
describe('CredentialSourcePrompt', () => {
21+
it('renders title', () => {
22+
const { lastFrame } = render(<CredentialSourcePrompt {...defaultProps} />);
23+
24+
expect(lastFrame()).toContain('Identity Provider Setup');
25+
});
26+
27+
it('renders provider names', () => {
28+
const { lastFrame } = render(<CredentialSourcePrompt {...defaultProps} />);
29+
const frame = lastFrame()!;
30+
31+
expect(frame).toContain('OpenAI');
32+
expect(frame).toContain('Anthropic');
33+
});
34+
35+
it('renders credential count', () => {
36+
const { lastFrame } = render(<CredentialSourcePrompt {...defaultProps} />);
37+
38+
expect(lastFrame()).toContain('2 identity providers');
39+
});
40+
41+
it('renders singular provider count', () => {
42+
const { lastFrame } = render(
43+
<CredentialSourcePrompt
44+
{...defaultProps}
45+
missingCredentials={[{ providerName: 'OpenAI', envVarName: 'OPENAI_API_KEY' }]}
46+
/>
47+
);
48+
49+
expect(lastFrame()).toContain('1 identity provider configured');
50+
});
51+
52+
it('renders source options', () => {
53+
const { lastFrame } = render(<CredentialSourcePrompt {...defaultProps} />);
54+
const frame = lastFrame()!;
55+
56+
expect(frame).toContain('.env.local');
57+
expect(frame).toContain('Enter credentials manually');
58+
expect(frame).toContain('Skip for now');
59+
});
60+
61+
it('calls onUseEnvLocal when first option selected', () => {
62+
const onUseEnvLocal = vi.fn();
63+
const { stdin } = render(
64+
<CredentialSourcePrompt {...defaultProps} onUseEnvLocal={onUseEnvLocal} />
65+
);
66+
67+
// First option is already selected
68+
stdin.write(ENTER);
69+
70+
expect(onUseEnvLocal).toHaveBeenCalledTimes(1);
71+
});
72+
73+
it('renders "Not saved to disk" description for manual entry option', () => {
74+
const { lastFrame } = render(<CredentialSourcePrompt {...defaultProps} />);
75+
76+
expect(lastFrame()).toContain('Not saved to disk');
77+
});
78+
79+
it('shows navigation help text', () => {
80+
const { lastFrame } = render(<CredentialSourcePrompt {...defaultProps} />);
81+
82+
expect(lastFrame()).toContain('navigate');
83+
expect(lastFrame()).toContain('Enter select');
84+
});
85+
});
Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
import type { LogEntry } from '../LogPanel.js';
2+
import { FullScreenLogView } from '../FullScreenLogView.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 ESCAPE = '\x1B';
8+
const UP = '\x1B[A';
9+
10+
function delay(ms = 50) {
11+
return new Promise(resolve => setTimeout(resolve, ms));
12+
}
13+
14+
afterEach(() => vi.restoreAllMocks());
15+
16+
function makeLogs(count: number): LogEntry[] {
17+
return Array.from({ length: count }, (_, i) => ({
18+
level: 'info' as const,
19+
message: `Log message ${i + 1}`,
20+
timestamp: new Date(2024, 0, 1, 0, 0, i),
21+
}));
22+
}
23+
24+
describe('FullScreenLogView', () => {
25+
it('renders log entries', () => {
26+
const logs: LogEntry[] = [
27+
{ level: 'info', message: 'Starting deploy', timestamp: new Date() },
28+
{ level: 'error', message: 'Deploy failed', timestamp: new Date() },
29+
];
30+
const { lastFrame } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
31+
const frame = lastFrame()!;
32+
33+
expect(frame).toContain('Starting deploy');
34+
expect(frame).toContain('Deploy failed');
35+
});
36+
37+
it('renders header with entry count', () => {
38+
const logs = makeLogs(5);
39+
const { lastFrame } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
40+
41+
expect(lastFrame()).toContain('5 entries');
42+
});
43+
44+
it('renders log file path when provided', () => {
45+
const logs = makeLogs(2);
46+
const { lastFrame } = render(
47+
<FullScreenLogView logs={logs} logFilePath="/tmp/deploy.log" onExit={vi.fn()} />
48+
);
49+
50+
expect(lastFrame()).toContain('/tmp/deploy.log');
51+
});
52+
53+
it('shows "No logs yet" when empty', () => {
54+
const { lastFrame } = render(<FullScreenLogView logs={[]} onExit={vi.fn()} />);
55+
56+
expect(lastFrame()).toContain('No logs yet');
57+
});
58+
59+
it('calls onExit on Escape key', () => {
60+
const onExit = vi.fn();
61+
const { stdin } = render(<FullScreenLogView logs={makeLogs(3)} onExit={onExit} />);
62+
63+
stdin.write(ESCAPE);
64+
65+
expect(onExit).toHaveBeenCalledTimes(1);
66+
});
67+
68+
it('calls onExit on q key', () => {
69+
const onExit = vi.fn();
70+
const { stdin } = render(<FullScreenLogView logs={makeLogs(3)} onExit={onExit} />);
71+
72+
stdin.write('\x11'); // Ctrl+Q
73+
74+
expect(onExit).toHaveBeenCalledTimes(1);
75+
});
76+
77+
it('calls onExit on l key', () => {
78+
const onExit = vi.fn();
79+
const { stdin } = render(<FullScreenLogView logs={makeLogs(3)} onExit={onExit} />);
80+
81+
stdin.write('l');
82+
83+
expect(onExit).toHaveBeenCalledTimes(1);
84+
});
85+
86+
it('renders error log with level label', () => {
87+
const logs: LogEntry[] = [{ level: 'error', message: 'Something broke', timestamp: new Date() }];
88+
const { lastFrame } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
89+
const frame = lastFrame()!;
90+
91+
expect(frame).toContain('ERROR');
92+
expect(frame).toContain('Something broke');
93+
});
94+
95+
it('renders response log with special formatting', () => {
96+
const logs: LogEntry[] = [{ level: 'response', message: 'Agent response text', timestamp: new Date() }];
97+
const { lastFrame } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
98+
const frame = lastFrame()!;
99+
100+
expect(frame).toContain('Response');
101+
expect(frame).toContain('Agent response text');
102+
});
103+
104+
it('renders footer with navigation hints', () => {
105+
const { lastFrame } = render(<FullScreenLogView logs={makeLogs(3)} onExit={vi.fn()} />);
106+
107+
expect(lastFrame()).toContain('Esc/q/l exit');
108+
});
109+
110+
it('shows scroll percentage', () => {
111+
const logs = makeLogs(3);
112+
const { lastFrame } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
113+
114+
// Should show some percentage
115+
expect(lastFrame()).toMatch(/\d+%/);
116+
});
117+
118+
it('scrolls with arrow keys', async () => {
119+
// Create enough logs to require scrolling
120+
const logs = makeLogs(50);
121+
const { lastFrame, stdin } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
122+
123+
await delay();
124+
stdin.write(UP);
125+
await delay();
126+
127+
// After scrolling up, the frame should change
128+
const frame = lastFrame()!;
129+
expect(frame).toMatch(/\d+%/);
130+
});
131+
132+
it('supports vim-style navigation with j/k', async () => {
133+
const logs = makeLogs(50);
134+
const { stdin } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
135+
136+
// These should not throw
137+
await delay();
138+
stdin.write('k'); // scroll up
139+
stdin.write('j'); // scroll down
140+
await delay();
141+
});
142+
143+
it('supports g/G for top/bottom navigation', async () => {
144+
const logs = makeLogs(50);
145+
const { lastFrame, stdin } = render(<FullScreenLogView logs={logs} onExit={vi.fn()} />);
146+
147+
await delay();
148+
stdin.write('g'); // go to top
149+
await delay();
150+
151+
// At top, should show first log
152+
expect(lastFrame()).toContain('Log message 1');
153+
154+
stdin.write('G'); // go to bottom
155+
await delay();
156+
157+
// At bottom, should show last log
158+
expect(lastFrame()).toContain('Log message 50');
159+
});
160+
});
Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
import { Screen } from '../Screen.js';
2+
import { Text } from 'ink';
3+
import { render } from 'ink-testing-library';
4+
import React from 'react';
5+
import { afterEach, describe, expect, it, vi } from 'vitest';
6+
7+
const ESCAPE = '\x1B';
8+
9+
afterEach(() => vi.restoreAllMocks());
10+
11+
describe('Screen', () => {
12+
it('renders title in the header', () => {
13+
const { lastFrame } = render(
14+
<Screen title="Deploy" onExit={vi.fn()}>
15+
<Text>Content</Text>
16+
</Screen>
17+
);
18+
19+
expect(lastFrame()).toContain('Deploy');
20+
});
21+
22+
it('renders children content', () => {
23+
const { lastFrame } = render(
24+
<Screen title="Test" onExit={vi.fn()}>
25+
<Text>Hello World</Text>
26+
</Screen>
27+
);
28+
29+
expect(lastFrame()).toContain('Hello World');
30+
});
31+
32+
it('renders default help text when none provided', () => {
33+
const { lastFrame } = render(
34+
<Screen title="Test" onExit={vi.fn()}>
35+
<Text>Content</Text>
36+
</Screen>
37+
);
38+
39+
expect(lastFrame()).toContain('Esc back');
40+
});
41+
42+
it('renders custom help text when provided', () => {
43+
const { lastFrame } = render(
44+
<Screen title="Test" onExit={vi.fn()} helpText="Press Enter to continue">
45+
<Text>Content</Text>
46+
</Screen>
47+
);
48+
49+
expect(lastFrame()).toContain('Press Enter to continue');
50+
});
51+
52+
it('calls onExit on Escape key', () => {
53+
const onExit = vi.fn();
54+
const { stdin } = render(
55+
<Screen title="Test" onExit={onExit}>
56+
<Text>Content</Text>
57+
</Screen>
58+
);
59+
60+
stdin.write(ESCAPE);
61+
62+
expect(onExit).toHaveBeenCalledTimes(1);
63+
});
64+
65+
it('calls onExit on Ctrl+Q', () => {
66+
const onExit = vi.fn();
67+
const { stdin } = render(
68+
<Screen title="Test" onExit={onExit}>
69+
<Text>Content</Text>
70+
</Screen>
71+
);
72+
73+
stdin.write('\x11'); // Ctrl+Q
74+
75+
expect(onExit).toHaveBeenCalledTimes(1);
76+
});
77+
78+
it('does not call onExit when exitEnabled is false', () => {
79+
const onExit = vi.fn();
80+
const { stdin } = render(
81+
<Screen title="Test" onExit={onExit} exitEnabled={false}>
82+
<Text>Content</Text>
83+
</Screen>
84+
);
85+
86+
stdin.write(ESCAPE);
87+
stdin.write('\x11');
88+
89+
expect(onExit).not.toHaveBeenCalled();
90+
});
91+
92+
it('renders header content when provided', () => {
93+
const { lastFrame } = render(
94+
<Screen title="Test" onExit={vi.fn()} headerContent={<Text>Status: Active</Text>}>
95+
<Text>Content</Text>
96+
</Screen>
97+
);
98+
99+
expect(lastFrame()).toContain('Status: Active');
100+
});
101+
102+
it('renders footer content when provided', () => {
103+
const { lastFrame } = render(
104+
<Screen title="Test" onExit={vi.fn()} footerContent={<Text>3 items selected</Text>}>
105+
<Text>Content</Text>
106+
</Screen>
107+
);
108+
109+
expect(lastFrame()).toContain('3 items selected');
110+
});
111+
});

0 commit comments

Comments
 (0)