Skip to content

Commit c593579

Browse files
committed
test: add useTextInput shortcut tests, fix guards mock, strengthen StepProgress
1 parent 00541fb commit c593579

2 files changed

Lines changed: 209 additions & 47 deletions

File tree

src/cli/tui/guards/__tests__/project.test.tsx

Lines changed: 33 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -3,16 +3,18 @@ import { render } from 'ink-testing-library';
33
import React from 'react';
44
import { afterEach, describe, expect, it, vi } from 'vitest';
55

6-
const { mockFindConfigRoot } = vi.hoisted(() => ({
6+
const { mockFindConfigRoot, mockGetWorkingDirectory } = vi.hoisted(() => ({
77
mockFindConfigRoot: vi.fn(),
8+
mockGetWorkingDirectory: vi.fn(() => '/project'),
89
}));
910

1011
vi.mock('../../../../lib/index.js', () => ({
1112
findConfigRoot: mockFindConfigRoot,
12-
getWorkingDirectory: () => '/project',
13-
NoProjectError: class extends Error {
14-
constructor() {
15-
super('No agentcore project found');
13+
getWorkingDirectory: mockGetWorkingDirectory,
14+
NoProjectError: class NoProjectError extends Error {
15+
constructor(message = 'No agentcore project found') {
16+
super(message);
17+
this.name = 'NoProjectError';
1618
}
1719
},
1820
}));
@@ -39,6 +41,14 @@ describe('projectExists', () => {
3941

4042
expect(mockFindConfigRoot).toHaveBeenCalledWith('/project');
4143
});
44+
45+
it('passes baseDir to findConfigRoot when provided', () => {
46+
mockFindConfigRoot.mockReturnValue(null);
47+
48+
projectExists('/custom/path');
49+
50+
expect(mockFindConfigRoot).toHaveBeenCalledWith('/custom/path');
51+
});
4252
});
4353

4454
describe('getProjectRootMismatch', () => {
@@ -66,27 +76,34 @@ describe('getProjectRootMismatch', () => {
6676
});
6777

6878
describe('MissingProjectMessage', () => {
69-
it('renders with agentcore create for CLI mode', () => {
79+
it('renders error message and "agentcore create" for CLI mode', () => {
7080
const { lastFrame } = render(<MissingProjectMessage />);
81+
const frame = lastFrame()!;
7182

72-
expect(lastFrame()).toContain('No agentcore project found');
73-
expect(lastFrame()).toContain('agentcore create');
83+
expect(frame).toContain('No agentcore project found');
84+
expect(frame).toContain('agentcore create');
7485
});
7586

76-
it('renders with create for TUI mode', () => {
87+
it('renders "create" without "agentcore" prefix for TUI mode', () => {
7788
const { lastFrame } = render(<MissingProjectMessage inTui />);
78-
79-
expect(lastFrame()).toContain('No agentcore project found');
80-
expect(lastFrame()).toContain('create');
89+
const frame = lastFrame()!;
90+
91+
expect(frame).toContain('No agentcore project found');
92+
expect(frame).toContain('create');
93+
// In TUI mode, should NOT show the full CLI command
94+
const lines = frame.split('\n');
95+
const createLine = lines.find(l => l.includes('create'))!;
96+
expect(createLine).not.toContain('agentcore create');
8197
});
8298
});
8399

84100
describe('WrongDirectoryMessage', () => {
85-
it('renders project root path', () => {
101+
it('renders project root path with cd suggestion', () => {
86102
const { lastFrame } = render(<WrongDirectoryMessage projectRoot="/home/user/my-project" />);
103+
const frame = lastFrame()!;
87104

88-
expect(lastFrame()).toContain('project root directory');
89-
expect(lastFrame()).toContain('/home/user/my-project');
90-
expect(lastFrame()).toContain('cd /home/user/my-project');
105+
expect(frame).toContain('project root directory');
106+
expect(frame).toContain('/home/user/my-project');
107+
expect(frame).toContain('cd /home/user/my-project');
91108
});
92109
});

src/cli/tui/hooks/__tests__/useTextInput.test.tsx

Lines changed: 176 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,8 @@ import { afterEach, describe, expect, it, vi } from 'vitest';
77
const ENTER = '\r';
88
const ESCAPE = '\x1B';
99
const BACKSPACE = '\x7f';
10+
const LEFT = '\x1B[D';
11+
const RIGHT = '\x1B[C';
1012

1113
afterEach(() => vi.restoreAllMocks());
1214

@@ -84,20 +86,38 @@ function TextInputHarness({
8486
onSubmit,
8587
onCancel,
8688
onChange,
89+
onUpArrow,
90+
onDownArrow,
91+
isActive,
8792
}: {
8893
initialValue?: string;
8994
onSubmit?: (value: string) => void;
9095
onCancel?: () => void;
9196
onChange?: (value: string) => void;
97+
onUpArrow?: () => void;
98+
onDownArrow?: () => void;
99+
isActive?: boolean;
92100
}) {
93-
const { value, cursor } = useTextInput({ initialValue, onSubmit, onCancel, onChange });
101+
const { value, cursor } = useTextInput({
102+
initialValue,
103+
onSubmit,
104+
onCancel,
105+
onChange,
106+
onUpArrow,
107+
onDownArrow,
108+
isActive,
109+
});
94110
return (
95111
<Text>
96112
val:[{value}] cur:{cursor}
97113
</Text>
98114
);
99115
}
100116

117+
function delay(ms = 50) {
118+
return new Promise(resolve => setTimeout(resolve, ms));
119+
}
120+
101121
describe('useTextInput hook', () => {
102122
it('starts with initial value and cursor at end', () => {
103123
const { lastFrame } = render(<TextInputHarness initialValue="hello" />);
@@ -116,9 +136,9 @@ describe('useTextInput hook', () => {
116136
it('accepts character input', async () => {
117137
const { lastFrame, stdin } = render(<TextInputHarness />);
118138

119-
await new Promise(resolve => setTimeout(resolve, 50));
139+
await delay();
120140
stdin.write('a');
121-
await new Promise(resolve => setTimeout(resolve, 50));
141+
await delay();
122142

123143
expect(lastFrame()).toContain('val:[a]');
124144
expect(lastFrame()).toContain('cur:1');
@@ -127,10 +147,10 @@ describe('useTextInput hook', () => {
127147
it('accepts multiple characters', async () => {
128148
const { lastFrame, stdin } = render(<TextInputHarness />);
129149

130-
await new Promise(resolve => setTimeout(resolve, 50));
150+
await delay();
131151
stdin.write('h');
132152
stdin.write('i');
133-
await new Promise(resolve => setTimeout(resolve, 50));
153+
await delay();
134154

135155
expect(lastFrame()).toContain('val:[hi]');
136156
expect(lastFrame()).toContain('cur:2');
@@ -139,21 +159,32 @@ describe('useTextInput hook', () => {
139159
it('handles backspace', async () => {
140160
const { lastFrame, stdin } = render(<TextInputHarness initialValue="abc" />);
141161

142-
await new Promise(resolve => setTimeout(resolve, 50));
162+
await delay();
143163
stdin.write(BACKSPACE);
144-
await new Promise(resolve => setTimeout(resolve, 50));
164+
await delay();
145165

146166
expect(lastFrame()).toContain('val:[ab]');
147167
expect(lastFrame()).toContain('cur:2');
148168
});
149169

150-
it('calls onSubmit on Enter', async () => {
170+
it('backspace at start does nothing', async () => {
171+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="" />);
172+
173+
await delay();
174+
stdin.write(BACKSPACE);
175+
await delay();
176+
177+
expect(lastFrame()).toContain('val:[]');
178+
expect(lastFrame()).toContain('cur:0');
179+
});
180+
181+
it('calls onSubmit on Enter with current text', async () => {
151182
const onSubmit = vi.fn();
152183
const { stdin } = render(<TextInputHarness initialValue="test" onSubmit={onSubmit} />);
153184

154-
await new Promise(resolve => setTimeout(resolve, 50));
185+
await delay();
155186
stdin.write(ENTER);
156-
await new Promise(resolve => setTimeout(resolve, 50));
187+
await delay();
157188

158189
expect(onSubmit).toHaveBeenCalledWith('test');
159190
});
@@ -162,19 +193,19 @@ describe('useTextInput hook', () => {
162193
const onCancel = vi.fn();
163194
const { stdin } = render(<TextInputHarness onCancel={onCancel} />);
164195

165-
await new Promise(resolve => setTimeout(resolve, 50));
196+
await delay();
166197
stdin.write(ESCAPE);
167-
await new Promise(resolve => setTimeout(resolve, 50));
198+
await delay();
168199

169200
expect(onCancel).toHaveBeenCalledTimes(1);
170201
});
171202

172203
it('moves cursor left with arrow key', async () => {
173204
const { lastFrame, stdin } = render(<TextInputHarness initialValue="abc" />);
174205

175-
await new Promise(resolve => setTimeout(resolve, 50));
176-
stdin.write('\x1B[D'); // left arrow
177-
await new Promise(resolve => setTimeout(resolve, 50));
206+
await delay();
207+
stdin.write(LEFT);
208+
await delay();
178209

179210
expect(lastFrame()).toContain('val:[abc]');
180211
expect(lastFrame()).toContain('cur:2');
@@ -183,29 +214,47 @@ describe('useTextInput hook', () => {
183214
it('moves cursor right with arrow key', async () => {
184215
const { lastFrame, stdin } = render(<TextInputHarness initialValue="abc" />);
185216

186-
// Move left first, then right
187-
await new Promise(resolve => setTimeout(resolve, 50));
188-
stdin.write('\x1B[D'); // left
189-
stdin.write('\x1B[D'); // left
190-
await new Promise(resolve => setTimeout(resolve, 50));
191-
217+
await delay();
218+
stdin.write(LEFT);
219+
stdin.write(LEFT);
220+
await delay();
192221
expect(lastFrame()).toContain('cur:1');
193222

194-
stdin.write('\x1B[C'); // right arrow
195-
await new Promise(resolve => setTimeout(resolve, 50));
223+
stdin.write(RIGHT);
224+
await delay();
225+
expect(lastFrame()).toContain('cur:2');
226+
});
227+
228+
it('cursor does not go below 0', async () => {
229+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="ab" />);
230+
231+
await delay();
232+
stdin.write(LEFT);
233+
stdin.write(LEFT);
234+
stdin.write(LEFT); // try to go past 0
235+
await delay();
236+
237+
expect(lastFrame()).toContain('cur:0');
238+
});
239+
240+
it('cursor does not go past text length', async () => {
241+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="ab" />);
242+
243+
await delay();
244+
stdin.write(RIGHT); // already at end (2)
245+
await delay();
196246

197247
expect(lastFrame()).toContain('cur:2');
198248
});
199249

200-
it('inserts character at cursor position', async () => {
250+
it('inserts character at cursor position (middle of text)', async () => {
201251
const { lastFrame, stdin } = render(<TextInputHarness initialValue="ac" />);
202252

203-
// Move cursor left once (between a and c), then insert b
204-
await new Promise(resolve => setTimeout(resolve, 50));
205-
stdin.write('\x1B[D'); // left, cursor now at 1
206-
await new Promise(resolve => setTimeout(resolve, 50));
253+
await delay();
254+
stdin.write(LEFT); // cursor at 1
255+
await delay();
207256
stdin.write('b');
208-
await new Promise(resolve => setTimeout(resolve, 50));
257+
await delay();
209258

210259
expect(lastFrame()).toContain('val:[abc]');
211260
expect(lastFrame()).toContain('cur:2');
@@ -215,10 +264,106 @@ describe('useTextInput hook', () => {
215264
const onChange = vi.fn();
216265
const { stdin } = render(<TextInputHarness onChange={onChange} />);
217266

218-
await new Promise(resolve => setTimeout(resolve, 50));
267+
await delay();
219268
stdin.write('x');
220-
await new Promise(resolve => setTimeout(resolve, 100));
269+
await delay(100);
221270

222271
expect(onChange).toHaveBeenCalledWith('x');
223272
});
273+
274+
it('calls onUpArrow on up arrow key', async () => {
275+
const onUpArrow = vi.fn();
276+
const { stdin } = render(<TextInputHarness onUpArrow={onUpArrow} />);
277+
278+
await delay();
279+
stdin.write('\x1B[A'); // up arrow
280+
await delay();
281+
282+
expect(onUpArrow).toHaveBeenCalledTimes(1);
283+
});
284+
285+
it('calls onDownArrow on down arrow key', async () => {
286+
const onDownArrow = vi.fn();
287+
const { stdin } = render(<TextInputHarness onDownArrow={onDownArrow} />);
288+
289+
await delay();
290+
stdin.write('\x1B[B'); // down arrow
291+
await delay();
292+
293+
expect(onDownArrow).toHaveBeenCalledTimes(1);
294+
});
295+
});
296+
297+
describe('useTextInput keyboard shortcuts', () => {
298+
it('Ctrl+A moves cursor to start', async () => {
299+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="hello world" />);
300+
301+
await delay();
302+
stdin.write('\x01'); // Ctrl+A
303+
await delay();
304+
305+
expect(lastFrame()).toContain('val:[hello world]');
306+
expect(lastFrame()).toContain('cur:0');
307+
});
308+
309+
it('Ctrl+E moves cursor to end', async () => {
310+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="hello world" />);
311+
312+
// Move to start first, then Ctrl+E
313+
await delay();
314+
stdin.write('\x01'); // Ctrl+A → cursor:0
315+
await delay();
316+
stdin.write('\x05'); // Ctrl+E
317+
await delay();
318+
319+
expect(lastFrame()).toContain('cur:11');
320+
});
321+
322+
it('Ctrl+W deletes previous word', async () => {
323+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="hello world" />);
324+
325+
await delay();
326+
stdin.write('\x17'); // Ctrl+W
327+
await delay();
328+
329+
expect(lastFrame()).toContain('val:[hello ]');
330+
expect(lastFrame()).toContain('cur:6');
331+
});
332+
333+
it('Ctrl+U deletes from cursor to start', async () => {
334+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="hello world" />);
335+
336+
// Move cursor to middle first
337+
await delay();
338+
stdin.write(LEFT);
339+
stdin.write(LEFT);
340+
stdin.write(LEFT);
341+
stdin.write(LEFT);
342+
stdin.write(LEFT); // cursor at 6
343+
await delay();
344+
stdin.write('\x15'); // Ctrl+U
345+
await delay();
346+
347+
expect(lastFrame()).toContain('val:[world]');
348+
expect(lastFrame()).toContain('cur:0');
349+
});
350+
351+
it('Ctrl+K deletes from cursor to end', async () => {
352+
const { lastFrame, stdin } = render(<TextInputHarness initialValue="hello world" />);
353+
354+
// Move cursor to position 5
355+
await delay();
356+
stdin.write(LEFT);
357+
stdin.write(LEFT);
358+
stdin.write(LEFT);
359+
stdin.write(LEFT);
360+
stdin.write(LEFT);
361+
stdin.write(LEFT); // cursor at 5
362+
await delay();
363+
stdin.write('\x0B'); // Ctrl+K
364+
await delay();
365+
366+
expect(lastFrame()).toContain('val:[hello]');
367+
expect(lastFrame()).toContain('cur:5');
368+
});
224369
});

0 commit comments

Comments
 (0)