cdd-cli 3.2.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. package/.atl/skill-registry.md +30 -0
  2. package/.editorconfig +12 -0
  3. package/.eslintrc.json +1 -1
  4. package/.prettierignore +4 -0
  5. package/.prettierrc +8 -0
  6. package/.~lock.ROADMAP.md# +1 -0
  7. package/CHANGELOG.md +80 -0
  8. package/README.es.md +198 -0
  9. package/README.md +81 -23
  10. package/ROADMAP.md +647 -0
  11. package/SECURITY.md +5 -0
  12. package/__mocks__/ink.cjs +17 -0
  13. package/coverage/clover.xml +471 -500
  14. package/coverage/coverage-final.json +16 -16
  15. package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
  16. package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
  17. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +394 -0
  18. package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
  19. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +268 -0
  20. package/coverage/lcov-report/components/index.html +146 -0
  21. package/coverage/lcov-report/helpers/constants.js.html +139 -22
  22. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +75 -42
  23. package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
  24. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
  25. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
  26. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
  27. package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
  28. package/coverage/lcov-report/helpers/index.html +84 -54
  29. package/coverage/lcov-report/helpers/logger.js.html +68 -68
  30. package/coverage/lcov-report/helpers/safeCall.js.html +15 -15
  31. package/coverage/lcov-report/helpers/validationHelpers.js.html +66 -114
  32. package/coverage/lcov-report/hooks/creation/index.html +20 -20
  33. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
  34. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +593 -89
  35. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
  36. package/coverage/lcov-report/hooks/debug/index.html +5 -5
  37. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
  38. package/coverage/lcov-report/hooks/index.html +40 -40
  39. package/coverage/lcov-report/hooks/navigation/index.html +19 -19
  40. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
  41. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
  42. package/coverage/lcov-report/hooks/useControls.js.html +306 -123
  43. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
  44. package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
  45. package/coverage/lcov-report/index.html +52 -67
  46. package/coverage/lcov.info +899 -952
  47. package/dist/App.js +18 -12
  48. package/dist/components/ContainerCreationPrompt.js +50 -13
  49. package/dist/components/ContainerList.js +3 -3
  50. package/dist/components/ContainerRow.js +20 -20
  51. package/dist/components/ContainerSection.js +3 -3
  52. package/dist/components/ControlsHUD.js +106 -0
  53. package/dist/components/Footer.js +2 -2
  54. package/dist/components/Header.js +7 -7
  55. package/dist/components/LogViewer.js +3 -3
  56. package/dist/components/MessageFeedback.js +2 -2
  57. package/dist/components/PromptField.js +4 -4
  58. package/dist/components/StatsBar.js +5 -5
  59. package/dist/components/SuggestionPanel.js +53 -0
  60. package/dist/components/UsageMenu.js +3 -3
  61. package/dist/helpers/appInfo.js +7 -7
  62. package/dist/helpers/constants.js +154 -1
  63. package/dist/helpers/containerOptionsBuilder.js +3 -3
  64. package/dist/helpers/dockerHubService.js +111 -0
  65. package/dist/helpers/dockerService/dockerService.js +1 -1
  66. package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
  67. package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
  68. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
  69. package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
  70. package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
  71. package/dist/helpers/exitWithMessage.js +10 -10
  72. package/dist/helpers/imageNameUtils.js +32 -0
  73. package/dist/helpers/logger.js +13 -13
  74. package/dist/helpers/safeCall.js +2 -2
  75. package/dist/helpers/validationHelpers.js +76 -21
  76. package/dist/hooks/creation/useContainerActions.js +28 -11
  77. package/dist/hooks/creation/useContainerCreation.js +452 -62
  78. package/dist/hooks/creation/useLogsViewer.js +2 -2
  79. package/dist/hooks/debug/useDebugLogs.js +4 -4
  80. package/dist/hooks/useContainerCommandRouter.js +14 -14
  81. package/dist/hooks/useContainers.js +3 -3
  82. package/dist/hooks/useControls.js +85 -38
  83. package/dist/hooks/useEraseConfirmation.js +3 -3
  84. package/dist/index.js +9 -2
  85. package/jest.config.cjs +7 -0
  86. package/package.json +7 -3
  87. package/src/App.jsx +39 -16
  88. package/src/components/ContainerCreationPrompt.jsx +54 -10
  89. package/src/components/ContainerList.jsx +3 -3
  90. package/src/components/ContainerRow.jsx +19 -16
  91. package/src/components/ContainerSection.jsx +3 -3
  92. package/src/components/ControlsHUD.jsx +66 -0
  93. package/src/components/Footer.jsx +2 -2
  94. package/src/components/Header.jsx +7 -7
  95. package/src/components/LogViewer.jsx +5 -3
  96. package/src/components/MessageFeedback.jsx +2 -2
  97. package/src/components/PromptField.jsx +4 -4
  98. package/src/components/StatsBar.jsx +11 -7
  99. package/src/components/SuggestionPanel.jsx +61 -0
  100. package/src/components/UsageMenu.jsx +18 -13
  101. package/src/helpers/appInfo.js +7 -7
  102. package/src/helpers/constants.js +48 -9
  103. package/src/helpers/containerOptionsBuilder.js +16 -5
  104. package/src/helpers/dockerHubService.js +75 -0
  105. package/src/helpers/dockerService/dockerService.js +1 -1
  106. package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
  107. package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
  108. package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
  109. package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
  110. package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
  111. package/src/helpers/exitWithMessage.js +13 -7
  112. package/src/helpers/imageNameUtils.js +32 -0
  113. package/src/helpers/logger.js +15 -16
  114. package/src/helpers/safeCall.js +2 -2
  115. package/src/helpers/validationHelpers.js +13 -29
  116. package/src/hooks/creation/useContainerActions.js +32 -12
  117. package/src/hooks/creation/useContainerCreation.js +293 -47
  118. package/src/hooks/creation/useLogsViewer.js +2 -2
  119. package/src/hooks/debug/useDebugLogs.js +15 -10
  120. package/src/hooks/navigation/useContainerSelection.js +17 -14
  121. package/src/hooks/useContainerCommandRouter.js +71 -58
  122. package/src/hooks/useContainers.js +4 -4
  123. package/src/hooks/useControls.js +135 -72
  124. package/src/hooks/useEraseConfirmation.js +3 -3
  125. package/src/hooks/useExitHandler.js +29 -26
  126. package/src/index.js +8 -4
  127. package/test/ContainerCreationPrompt.dom.test.js +313 -0
  128. package/test/ControlsHUD.dom.test.js +125 -0
  129. package/test/SuggestionPanel.dom.test.js +83 -0
  130. package/test/constants.test.js +41 -0
  131. package/test/dockerHubService.test.js +213 -0
  132. package/test/logger.test.js +58 -0
  133. package/test/resolveImageTag.test.js +26 -0
  134. package/test/useContainerCreation.dom.test.js +428 -2
  135. package/test/useContainerCreationHub.dom.test.js +184 -0
  136. package/test/useControls.dom.test.js +406 -4
@@ -0,0 +1,313 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react';
5
+ import { render } from '@testing-library/react';
6
+ import { ContainerCreationPrompt } from '../src/components/ContainerCreationPrompt.jsx';
7
+
8
+ describe('ContainerCreationPrompt — SuggestionPanel wiring', () => {
9
+ test('does NOT render SuggestionPanel when step > 0', () => {
10
+ const { queryByText } = render(
11
+ <ContainerCreationPrompt
12
+ step={1}
13
+ imageName="nginx"
14
+ containerName=""
15
+ portInput=""
16
+ envInput=""
17
+ message=""
18
+ messageColor="yellow"
19
+ suggestions={['nginx', 'node']}
20
+ selectedSuggestionIndex={-1}
21
+ visibleOffset={0}
22
+ />
23
+ );
24
+ // On step 1, the imageName field is not active — no suggestion panel
25
+ expect(queryByText(/^nginx$/)).toBeNull();
26
+ });
27
+
28
+ test('does NOT render SuggestionPanel on step 0 when suggestions is empty', () => {
29
+ const { queryByTestId } = render(
30
+ <ContainerCreationPrompt
31
+ step={0}
32
+ imageName="ng"
33
+ containerName=""
34
+ portInput=""
35
+ envInput=""
36
+ message=""
37
+ messageColor="yellow"
38
+ suggestions={[]}
39
+ selectedSuggestionIndex={-1}
40
+ visibleOffset={0}
41
+ />
42
+ );
43
+ // No suggestion panel should render when suggestions array is empty
44
+ expect(queryByTestId('suggestion-panel')).toBeNull();
45
+ });
46
+
47
+ test('renders SuggestionPanel items on step 0 when suggestions exist', () => {
48
+ const { getAllByText, getByText } = render(
49
+ <ContainerCreationPrompt
50
+ step={0}
51
+ imageName="ng"
52
+ containerName=""
53
+ portInput=""
54
+ envInput=""
55
+ message=""
56
+ messageColor="yellow"
57
+ suggestions={['nginx', 'golang']}
58
+ selectedSuggestionIndex={-1}
59
+ visibleOffset={0}
60
+ />
61
+ );
62
+ // golang is not in any label — appears only in suggestion panel
63
+ expect(getByText(/golang/)).toBeTruthy();
64
+ // nginx appears in suggestion panel (may also appear in label text, but it does appear)
65
+ const nginxEls = getAllByText(/nginx/);
66
+ expect(nginxEls.length).toBeGreaterThan(0);
67
+ });
68
+
69
+ // FR8 — hubResults wiring
70
+ test('when hubResults is provided, activeItems = hubResults (replaces suggestions)', () => {
71
+ const { getByText, queryByText } = render(
72
+ <ContainerCreationPrompt
73
+ step={0}
74
+ imageName="ng"
75
+ containerName=""
76
+ portInput=""
77
+ envInput=""
78
+ message=""
79
+ messageColor="yellow"
80
+ suggestions={['nginx', 'golang']}
81
+ hubResults={['hub-nginx', 'hub-node']}
82
+ selectedSuggestionIndex={-1}
83
+ visibleOffset={0}
84
+ />
85
+ );
86
+ expect(getByText(/hub-nginx/)).toBeTruthy();
87
+ expect(queryByText(/golang/)).toBeNull();
88
+ });
89
+
90
+ test('when hubResults is null, falls back to suggestions', () => {
91
+ const { getByText } = render(
92
+ <ContainerCreationPrompt
93
+ step={0}
94
+ imageName="ng"
95
+ containerName=""
96
+ portInput=""
97
+ envInput=""
98
+ message=""
99
+ messageColor="yellow"
100
+ suggestions={['nginx', 'golang']}
101
+ hubResults={null}
102
+ selectedSuggestionIndex={-1}
103
+ visibleOffset={0}
104
+ />
105
+ );
106
+ expect(getByText(/golang/)).toBeTruthy();
107
+ });
108
+
109
+ test('when step===0 and isSearchingHub=true, SuggestionPanel is shown even if activeItems is empty', () => {
110
+ const { getByText } = render(
111
+ <ContainerCreationPrompt
112
+ step={0}
113
+ imageName="ng"
114
+ containerName=""
115
+ portInput=""
116
+ envInput=""
117
+ message=""
118
+ messageColor="yellow"
119
+ suggestions={[]}
120
+ hubResults={null}
121
+ isSearchingHub={true}
122
+ selectedSuggestionIndex={-1}
123
+ visibleOffset={0}
124
+ />
125
+ );
126
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
127
+ });
128
+
129
+ test('when step===0 and isSearchingHub=false and activeItems is empty, SuggestionPanel is NOT shown', () => {
130
+ const { queryByTestId } = render(
131
+ <ContainerCreationPrompt
132
+ step={0}
133
+ imageName="ng"
134
+ containerName=""
135
+ portInput=""
136
+ envInput=""
137
+ message=""
138
+ messageColor="yellow"
139
+ suggestions={[]}
140
+ hubResults={null}
141
+ isSearchingHub={false}
142
+ selectedSuggestionIndex={-1}
143
+ visibleOffset={0}
144
+ />
145
+ );
146
+ expect(queryByTestId('suggestion-panel')).toBeNull();
147
+ });
148
+
149
+ test('isLoading prop is passed down to SuggestionPanel when isSearchingHub=true', () => {
150
+ const { getByText } = render(
151
+ <ContainerCreationPrompt
152
+ step={0}
153
+ imageName="ng"
154
+ containerName=""
155
+ portInput=""
156
+ envInput=""
157
+ message=""
158
+ messageColor="yellow"
159
+ suggestions={['nginx']}
160
+ hubResults={null}
161
+ isSearchingHub={true}
162
+ selectedSuggestionIndex={-1}
163
+ visibleOffset={0}
164
+ />
165
+ );
166
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
167
+ });
168
+ });
169
+
170
+ describe('ContainerCreationPrompt — ControlsHUD wiring', () => {
171
+ test('step=0, no suggestions: renders ControlsHUD with [Tab] and Search Hub', () => {
172
+ const { getByText } = render(
173
+ <ContainerCreationPrompt
174
+ step={0}
175
+ imageName=""
176
+ containerName=""
177
+ portInput=""
178
+ envInput=""
179
+ message=""
180
+ messageColor="yellow"
181
+ suggestions={[]}
182
+ hubResults={null}
183
+ isSearchingHub={false}
184
+ selectedSuggestionIndex={-1}
185
+ visibleOffset={0}
186
+ />
187
+ );
188
+ expect(getByText('[Tab]')).toBeTruthy();
189
+ expect(getByText(/Search Hub/)).toBeTruthy();
190
+ });
191
+
192
+ test('does NOT render old static text "Press Enter to continue, Escape to cancel"', () => {
193
+ const { queryByText } = render(
194
+ <ContainerCreationPrompt
195
+ step={0}
196
+ imageName=""
197
+ containerName=""
198
+ portInput=""
199
+ envInput=""
200
+ message=""
201
+ messageColor="yellow"
202
+ suggestions={[]}
203
+ hubResults={null}
204
+ isSearchingHub={false}
205
+ selectedSuggestionIndex={-1}
206
+ visibleOffset={0}
207
+ />
208
+ );
209
+ expect(queryByText(/Press Enter to continue, Escape to cancel/)).toBeNull();
210
+ });
211
+
212
+ test('step=0, hasSuggestions=true: renders [↑↓] not [Tab]', () => {
213
+ const { getByText, queryByText } = render(
214
+ <ContainerCreationPrompt
215
+ step={0}
216
+ imageName="ng"
217
+ containerName=""
218
+ portInput=""
219
+ envInput=""
220
+ message=""
221
+ messageColor="yellow"
222
+ suggestions={['nginx', 'golang']}
223
+ hubResults={null}
224
+ isSearchingHub={false}
225
+ selectedSuggestionIndex={-1}
226
+ visibleOffset={0}
227
+ />
228
+ );
229
+ expect(getByText('[↑↓]')).toBeTruthy();
230
+ expect(queryByText('[Tab]')).toBeNull();
231
+ });
232
+
233
+ test('step=0, isSearchingHub=true, no suggestions: ControlsHUD does NOT render [Tab] or Navigate, DOES render [Enter] and [Esc]', () => {
234
+ const { queryByText, getByText } = render(
235
+ <ContainerCreationPrompt
236
+ step={0}
237
+ imageName="ng"
238
+ containerName=""
239
+ portInput=""
240
+ envInput=""
241
+ message=""
242
+ messageColor="yellow"
243
+ suggestions={[]}
244
+ hubResults={null}
245
+ isSearchingHub={true}
246
+ selectedSuggestionIndex={-1}
247
+ visibleOffset={0}
248
+ />
249
+ );
250
+ expect(queryByText('[Tab]')).toBeNull();
251
+ expect(queryByText(/Navigate/)).toBeNull();
252
+ expect(getByText('[Enter]')).toBeTruthy();
253
+ expect(getByText('[Esc]')).toBeTruthy();
254
+ });
255
+
256
+ test('step=1: renders [Enter] and [Esc]', () => {
257
+ const { getByText } = render(
258
+ <ContainerCreationPrompt
259
+ step={1}
260
+ imageName="nginx"
261
+ containerName=""
262
+ portInput=""
263
+ envInput=""
264
+ message=""
265
+ messageColor="yellow"
266
+ suggestions={[]}
267
+ selectedSuggestionIndex={-1}
268
+ visibleOffset={0}
269
+ />
270
+ );
271
+ expect(getByText('[Enter]')).toBeTruthy();
272
+ expect(getByText('[Esc]')).toBeTruthy();
273
+ });
274
+
275
+ test('step=3, hasSuggestedEnv=true: renders [Tab] in HUD', () => {
276
+ const { getByText } = render(
277
+ <ContainerCreationPrompt
278
+ step={3}
279
+ imageName="postgres:17-alpine"
280
+ containerName=""
281
+ portInput=""
282
+ envInput=""
283
+ message=""
284
+ messageColor="yellow"
285
+ suggestions={[]}
286
+ selectedSuggestionIndex={-1}
287
+ visibleOffset={0}
288
+ hasSuggestedEnv={true}
289
+ />
290
+ );
291
+ expect(getByText('[Tab]')).toBeTruthy();
292
+ expect(getByText(/Insert next env/)).toBeTruthy();
293
+ });
294
+
295
+ test('step=3, hasSuggestedEnv=false: does NOT render [Tab] in HUD', () => {
296
+ const { queryByText } = render(
297
+ <ContainerCreationPrompt
298
+ step={3}
299
+ imageName="nginx:1.27-alpine"
300
+ containerName=""
301
+ portInput=""
302
+ envInput=""
303
+ message=""
304
+ messageColor="yellow"
305
+ suggestions={[]}
306
+ selectedSuggestionIndex={-1}
307
+ visibleOffset={0}
308
+ hasSuggestedEnv={false}
309
+ />
310
+ );
311
+ expect(queryByText('[Tab]')).toBeNull();
312
+ });
313
+ });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react';
5
+ import { render } from '@testing-library/react';
6
+ import { getHints, ControlsHUD } from '../src/components/ControlsHUD.jsx';
7
+
8
+ describe('getHints — pure function', () => {
9
+ test('step=0, no suggestions, not searching: includes Tab=Search Hub', () => {
10
+ const hints = getHints(0, false, false);
11
+ const keys = hints.map(h => h.key);
12
+ expect(keys).toContain('Tab');
13
+ const tabHint = hints.find(h => h.key === 'Tab');
14
+ expect(tabHint.label).toBe('Search Hub');
15
+ });
16
+
17
+ test('step=0, hasSuggestions=true: includes ↑↓ Navigate, does NOT include Tab', () => {
18
+ const hints = getHints(0, true, false);
19
+ const keys = hints.map(h => h.key);
20
+ expect(keys).toContain('↑↓');
21
+ expect(keys).not.toContain('Tab');
22
+ const navHint = hints.find(h => h.key === '↑↓');
23
+ expect(navHint.label).toBe('Navigate');
24
+ });
25
+
26
+ test('step=0, isSearchingHub=true: does NOT include Tab, includes Enter and Esc', () => {
27
+ const hints = getHints(0, false, true);
28
+ const keys = hints.map(h => h.key);
29
+ expect(keys).not.toContain('Tab');
30
+ expect(keys).toContain('Enter');
31
+ expect(keys).toContain('Esc');
32
+ });
33
+
34
+ test('step=1: includes Enter=Continue and Esc=Cancel, no Tab, no ↑↓', () => {
35
+ const hints = getHints(1, false, false);
36
+ const keys = hints.map(h => h.key);
37
+ expect(keys).toContain('Enter');
38
+ expect(keys).toContain('Esc');
39
+ expect(keys).not.toContain('Tab');
40
+ expect(keys).not.toContain('↑↓');
41
+ const enterHint = hints.find(h => h.key === 'Enter');
42
+ expect(enterHint.label).toBe('Continue');
43
+ });
44
+
45
+ test('step=2: same as step=1', () => {
46
+ const hints = getHints(2, false, false);
47
+ const keys = hints.map(h => h.key);
48
+ expect(keys).toContain('Enter');
49
+ expect(keys).toContain('Esc');
50
+ expect(keys).not.toContain('Tab');
51
+ expect(keys).not.toContain('↑↓');
52
+ });
53
+
54
+ test('step=3: same as step=1', () => {
55
+ const hints = getHints(3, false, false);
56
+ const keys = hints.map(h => h.key);
57
+ expect(keys).toContain('Enter');
58
+ expect(keys).toContain('Esc');
59
+ expect(keys).not.toContain('Tab');
60
+ expect(keys).not.toContain('↑↓');
61
+ });
62
+
63
+ test('getHints(3, false, false, true) includes Tab=Insert next env', () => {
64
+ const hints = getHints(3, false, false, true);
65
+ const keys = hints.map(h => h.key);
66
+ expect(keys).toContain('Tab');
67
+ const tabHint = hints.find(h => h.key === 'Tab');
68
+ expect(tabHint.label).toBe('Insert next env');
69
+ });
70
+
71
+ test('getHints(3, false, false, false) does NOT include Tab hint', () => {
72
+ const hints = getHints(3, false, false, false);
73
+ const keys = hints.map(h => h.key);
74
+ expect(keys).not.toContain('Tab');
75
+ });
76
+
77
+ test('getHints(3, false, false, true) also includes Enter and Esc', () => {
78
+ const hints = getHints(3, false, false, true);
79
+ const keys = hints.map(h => h.key);
80
+ expect(keys).toContain('Enter');
81
+ expect(keys).toContain('Esc');
82
+ });
83
+ });
84
+
85
+ describe('ControlsHUD — component rendering', () => {
86
+ test('step=0, no suggestions: renders [Tab] and "Search Hub" text', () => {
87
+ const { getByText } = render(
88
+ <ControlsHUD step={0} hasSuggestions={false} isSearchingHub={false} />
89
+ );
90
+ expect(getByText('[Tab]')).toBeTruthy();
91
+ expect(getByText(/Search Hub/)).toBeTruthy();
92
+ });
93
+
94
+ test('step=0, hasSuggestions=true: renders [↑↓], does NOT render [Tab]', () => {
95
+ const { getByText, queryByText } = render(
96
+ <ControlsHUD step={0} hasSuggestions={true} isSearchingHub={false} />
97
+ );
98
+ expect(getByText('[↑↓]')).toBeTruthy();
99
+ expect(queryByText('[Tab]')).toBeNull();
100
+ });
101
+
102
+ test('step=1: renders [Enter] and [Esc], does NOT render [Tab]', () => {
103
+ const { getByText, queryByText } = render(
104
+ <ControlsHUD step={1} hasSuggestions={false} isSearchingHub={false} />
105
+ );
106
+ expect(getByText('[Enter]')).toBeTruthy();
107
+ expect(getByText('[Esc]')).toBeTruthy();
108
+ expect(queryByText('[Tab]')).toBeNull();
109
+ });
110
+
111
+ test('step=3, hasSuggestedEnv=true: renders [Tab] and "Insert next env"', () => {
112
+ const { getByText } = render(
113
+ <ControlsHUD step={3} hasSuggestions={false} isSearchingHub={false} hasSuggestedEnv={true} />
114
+ );
115
+ expect(getByText('[Tab]')).toBeTruthy();
116
+ expect(getByText(/Insert next env/)).toBeTruthy();
117
+ });
118
+
119
+ test('step=3, hasSuggestedEnv=false: does NOT render [Tab]', () => {
120
+ const { queryByText } = render(
121
+ <ControlsHUD step={3} hasSuggestions={false} isSearchingHub={false} hasSuggestedEnv={false} />
122
+ );
123
+ expect(queryByText('[Tab]')).toBeNull();
124
+ });
125
+ });
@@ -0,0 +1,83 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react';
5
+ import { render } from '@testing-library/react';
6
+ import { SuggestionPanel } from '../src/components/SuggestionPanel.jsx';
7
+
8
+ describe('SuggestionPanel', () => {
9
+ const items = ['caddy', 'elasticsearch', 'golang', 'httpd', 'kafka', 'mariadb', 'memcached', 'minio'];
10
+
11
+ test('renders nothing when items is empty', () => {
12
+ const { container } = render(
13
+ <SuggestionPanel items={[]} selectedIndex={-1} visibleOffset={0} maxVisible={5} />
14
+ );
15
+ expect(container.firstChild).toBeNull();
16
+ });
17
+
18
+ test('renders only maxVisible items from visibleOffset', () => {
19
+ const { getByText, queryByText } = render(
20
+ <SuggestionPanel items={items} selectedIndex={-1} visibleOffset={0} maxVisible={5} />
21
+ );
22
+ expect(getByText(/caddy/)).toBeTruthy();
23
+ expect(getByText(/elasticsearch/)).toBeTruthy();
24
+ expect(getByText(/kafka/)).toBeTruthy();
25
+ expect(queryByText(/mariadb/)).toBeNull();
26
+ });
27
+
28
+ test('respects visibleOffset by skipping early items', () => {
29
+ const { queryByText, getByText } = render(
30
+ <SuggestionPanel items={items} selectedIndex={-1} visibleOffset={2} maxVisible={3} />
31
+ );
32
+ expect(getByText(/golang/)).toBeTruthy();
33
+ expect(getByText(/httpd/)).toBeTruthy();
34
+ expect(getByText(/kafka/)).toBeTruthy();
35
+ expect(queryByText(/caddy/)).toBeNull();
36
+ });
37
+
38
+ test('focused row has › prefix marker', () => {
39
+ const { getByText } = render(
40
+ <SuggestionPanel items={items} selectedIndex={1} visibleOffset={0} maxVisible={5} />
41
+ );
42
+ const el = getByText(/› elasticsearch/);
43
+ expect(el).toBeTruthy();
44
+ });
45
+
46
+ test('non-focused rows do NOT have › prefix', () => {
47
+ const { getByText } = render(
48
+ <SuggestionPanel items={items} selectedIndex={1} visibleOffset={0} maxVisible={5} />
49
+ );
50
+ const el = getByText(/caddy/);
51
+ expect(el.textContent).not.toMatch(/›/);
52
+ });
53
+
54
+ // FR8 — isLoading prop
55
+ test('renders [searching Docker Hub...] row when isLoading=true', () => {
56
+ const { getByText } = render(
57
+ <SuggestionPanel items={[]} selectedIndex={-1} visibleOffset={0} isLoading={true} />
58
+ );
59
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
60
+ });
61
+
62
+ test('loading row is NOT selectable — no › prefix on loading row', () => {
63
+ const { getByText } = render(
64
+ <SuggestionPanel items={items} selectedIndex={-1} visibleOffset={0} maxVisible={5} isLoading={true} />
65
+ );
66
+ const loadingEl = getByText(/searching Docker Hub/);
67
+ expect(loadingEl.textContent).not.toMatch(/›/);
68
+ });
69
+
70
+ test('does NOT render loading row when isLoading=false', () => {
71
+ const { queryByText } = render(
72
+ <SuggestionPanel items={items} selectedIndex={-1} visibleOffset={0} maxVisible={5} isLoading={false} />
73
+ );
74
+ expect(queryByText(/searching Docker Hub/)).toBeNull();
75
+ });
76
+
77
+ test('renders panel even when items is empty but isLoading=true', () => {
78
+ const { getByText } = render(
79
+ <SuggestionPanel items={[]} selectedIndex={-1} visibleOffset={0} isLoading={true} />
80
+ );
81
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
82
+ });
83
+ });
@@ -0,0 +1,41 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ import { IMAGE_PROFILES } from '../src/helpers/constants.js';
5
+
6
+ describe('IMAGE_PROFILES', () => {
7
+ test('has ~20 entries', () => {
8
+ const count = Object.keys(IMAGE_PROFILES).length;
9
+ expect(count).toBeGreaterThanOrEqual(20);
10
+ });
11
+
12
+ test('every entry has requiredEnv (array), defaultPort (string), and suggestedEnv (array)', () => {
13
+ for (const [key, profile] of Object.entries(IMAGE_PROFILES)) {
14
+ expect(Array.isArray(profile.requiredEnv)).toBe(true);
15
+ expect(typeof profile.defaultPort).toBe('string');
16
+ expect(Array.isArray(profile.suggestedEnv)).toBe(true);
17
+ }
18
+ });
19
+
20
+ test('postgres has POSTGRES_PASSWORD in requiredEnv and suggestedEnv hints', () => {
21
+ expect(IMAGE_PROFILES.postgres.requiredEnv).toContain('POSTGRES_PASSWORD');
22
+ expect(IMAGE_PROFILES.postgres.suggestedEnv.length).toBeGreaterThan(0);
23
+ });
24
+
25
+ test('nginx has empty requiredEnv, defaultPort 80, and empty suggestedEnv', () => {
26
+ expect(IMAGE_PROFILES.nginx.requiredEnv).toEqual([]);
27
+ expect(IMAGE_PROFILES.nginx.defaultPort).toBe('80');
28
+ expect(IMAGE_PROFILES.nginx.suggestedEnv).toEqual([]);
29
+ });
30
+
31
+ test('node has suggestedEnv with NODE_ENV hint', () => {
32
+ expect(IMAGE_PROFILES.node.suggestedEnv).toContain('NODE_ENV=production');
33
+ });
34
+
35
+ test('every entry has defaultTag as a non-empty string', () => {
36
+ for (const [key, profile] of Object.entries(IMAGE_PROFILES)) {
37
+ expect(typeof profile.defaultTag).toBe('string');
38
+ expect(profile.defaultTag.length).toBeGreaterThan(0);
39
+ }
40
+ });
41
+ });