cdd-cli 4.0.0 → 4.2.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 (135) hide show
  1. package/README.es.md +60 -26
  2. package/README.md +60 -26
  3. package/dist/App.js +1 -0
  4. package/dist/components/ContainerCreationPrompt.js +14 -0
  5. package/dist/components/PromptField.js +13 -6
  6. package/dist/components/TextField.js +54 -0
  7. package/dist/components/UsageMenu.js +1 -1
  8. package/dist/helpers/appState.js +26 -0
  9. package/dist/helpers/dockerService/serviceComponents/containerExec.js +211 -0
  10. package/dist/helpers/textEditing.js +293 -0
  11. package/dist/hooks/creation/useContainerCreation.js +132 -15
  12. package/dist/hooks/useContainerCommandRouter.js +12 -2
  13. package/dist/hooks/useControls.js +21 -26
  14. package/dist/hooks/useShellMode.js +101 -0
  15. package/dist/index.js +3 -1
  16. package/package.json +4 -1
  17. package/.atl/skill-registry.md +0 -30
  18. package/.editorconfig +0 -12
  19. package/.eslintrc.json +0 -30
  20. package/.github/ISSUE_TEMPLATE/bug_report.md +0 -30
  21. package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
  22. package/.github/PULL_REQUEST_TEMPLATE/pull_request.md +0 -14
  23. package/.github/workflows/ci.yml +0 -32
  24. package/.github/workflows/docs.yml +0 -42
  25. package/.prettierignore +0 -4
  26. package/.prettierrc +0 -8
  27. package/.~lock.ROADMAP.md# +0 -1
  28. package/CHANGELOG.md +0 -216
  29. package/CODE_OF_CONDUCT.md +0 -12
  30. package/CONTRIBUTING.md +0 -35
  31. package/ROADMAP.md +0 -647
  32. package/SECURITY.md +0 -5
  33. package/__mocks__/ink.cjs +0 -17
  34. package/_config.yml +0 -13
  35. package/babel.config.cjs +0 -23
  36. package/coverage/clover.xml +0 -575
  37. package/coverage/coverage-final.json +0 -17
  38. package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +0 -400
  39. package/coverage/lcov-report/ControlsHUD.jsx.html +0 -256
  40. package/coverage/lcov-report/base.css +0 -224
  41. package/coverage/lcov-report/block-navigation.js +0 -87
  42. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +0 -394
  43. package/coverage/lcov-report/components/PromptField.jsx.html +0 -229
  44. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +0 -268
  45. package/coverage/lcov-report/components/index.html +0 -146
  46. package/coverage/lcov-report/favicon.png +0 -0
  47. package/coverage/lcov-report/helpers/constants.js.html +0 -352
  48. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +0 -244
  49. package/coverage/lcov-report/helpers/dockerHubService.js.html +0 -310
  50. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +0 -931
  51. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +0 -307
  52. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +0 -131
  53. package/coverage/lcov-report/helpers/imageNameUtils.js.html +0 -181
  54. package/coverage/lcov-report/helpers/index.html +0 -206
  55. package/coverage/lcov-report/helpers/logger.js.html +0 -424
  56. package/coverage/lcov-report/helpers/safeCall.js.html +0 -139
  57. package/coverage/lcov-report/helpers/validationHelpers.js.html +0 -346
  58. package/coverage/lcov-report/hooks/creation/index.html +0 -146
  59. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +0 -313
  60. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +0 -982
  61. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +0 -238
  62. package/coverage/lcov-report/hooks/debug/index.html +0 -116
  63. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +0 -187
  64. package/coverage/lcov-report/hooks/index.html +0 -161
  65. package/coverage/lcov-report/hooks/navigation/index.html +0 -116
  66. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +0 -169
  67. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +0 -454
  68. package/coverage/lcov-report/hooks/useControls.js.html +0 -868
  69. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +0 -202
  70. package/coverage/lcov-report/hooks/useExitHandler.js.html +0 -292
  71. package/coverage/lcov-report/index.html +0 -176
  72. package/coverage/lcov-report/prettify.css +0 -1
  73. package/coverage/lcov-report/prettify.js +0 -2
  74. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  75. package/coverage/lcov-report/sorter.js +0 -210
  76. package/coverage/lcov.info +0 -1163
  77. package/fix-imports.cjs +0 -54
  78. package/jest.config.cjs +0 -14
  79. package/src/App.jsx +0 -102
  80. package/src/components/ContainerCreationPrompt.jsx +0 -107
  81. package/src/components/ContainerList.jsx +0 -32
  82. package/src/components/ContainerRow.jsx +0 -84
  83. package/src/components/ContainerSection.jsx +0 -24
  84. package/src/components/ControlsHUD.jsx +0 -66
  85. package/src/components/Footer.jsx +0 -15
  86. package/src/components/Header.jsx +0 -41
  87. package/src/components/LogViewer.jsx +0 -36
  88. package/src/components/MessageFeedback.jsx +0 -25
  89. package/src/components/PromptField.jsx +0 -48
  90. package/src/components/StatsBar.jsx +0 -70
  91. package/src/components/SuggestionPanel.jsx +0 -61
  92. package/src/components/UsageMenu.jsx +0 -52
  93. package/src/helpers/appInfo.js +0 -23
  94. package/src/helpers/constants.js +0 -89
  95. package/src/helpers/containerOptionsBuilder.js +0 -53
  96. package/src/helpers/dockerHubService.js +0 -75
  97. package/src/helpers/dockerService/dockerService.js +0 -28
  98. package/src/helpers/dockerService/serviceComponents/containerActions.js +0 -282
  99. package/src/helpers/dockerService/serviceComponents/containerList.js +0 -74
  100. package/src/helpers/dockerService/serviceComponents/containerLogs.js +0 -60
  101. package/src/helpers/dockerService/serviceComponents/containerStats.js +0 -69
  102. package/src/helpers/dockerService/serviceComponents/imageUtils.js +0 -32
  103. package/src/helpers/exitWithMessage.js +0 -32
  104. package/src/helpers/imageNameUtils.js +0 -32
  105. package/src/helpers/logger.js +0 -112
  106. package/src/helpers/safeCall.js +0 -18
  107. package/src/helpers/validationHelpers.js +0 -87
  108. package/src/hooks/creation/useContainerActions.js +0 -89
  109. package/src/hooks/creation/useContainerCreation.js +0 -377
  110. package/src/hooks/creation/useLogsViewer.js +0 -51
  111. package/src/hooks/debug/useDebugLogs.js +0 -34
  112. package/src/hooks/navigation/useContainerSelection.js +0 -28
  113. package/src/hooks/useContainerCommandRouter.js +0 -123
  114. package/src/hooks/useContainerStats.js +0 -44
  115. package/src/hooks/useContainers.js +0 -26
  116. package/src/hooks/useControls.js +0 -263
  117. package/src/hooks/useEraseConfirmation.js +0 -39
  118. package/src/hooks/useExitHandler.js +0 -69
  119. package/src/index.js +0 -20
  120. package/test/ContainerCreationPrompt.dom.test.js +0 -313
  121. package/test/ControlsHUD.dom.test.js +0 -125
  122. package/test/SuggestionPanel.dom.test.js +0 -83
  123. package/test/constants.test.js +0 -41
  124. package/test/containerActions.test.js +0 -229
  125. package/test/containerList.test.js +0 -79
  126. package/test/containerOptionsBuilder.test.js +0 -36
  127. package/test/dockerHubService.test.js +0 -213
  128. package/test/jest.setup.js +0 -7
  129. package/test/logger.test.js +0 -58
  130. package/test/resolveImageTag.test.js +0 -26
  131. package/test/safeCall.test.js +0 -25
  132. package/test/useContainerCreation.dom.test.js +0 -567
  133. package/test/useContainerCreationHub.dom.test.js +0 -184
  134. package/test/useControls.dom.test.js +0 -519
  135. package/test/validationHelpers.test.js +0 -111
@@ -1,567 +0,0 @@
1
- /**
2
- * @jest-environment jsdom
3
- */
4
- import React, { useEffect } from 'react';
5
- import { render, act } from '@testing-library/react';
6
- import { IMAGE_PROFILES } from '../src/helpers/constants.js';
7
- import { jest } from '@jest/globals';
8
-
9
- // Mock dockerHubService BEFORE any other import that might pull it in
10
- jest.unstable_mockModule('../src/helpers/dockerHubService.js', () => ({
11
- searchDockerHub: jest.fn(),
12
- formatHubResult: jest.fn((r) => r.name), // identity-like stub: returns name string
13
- }));
14
-
15
- // Dynamic import resolves AFTER jest.unstable_mockModule is registered
16
- const { useContainerCreation } = await import('../src/hooks/creation/useContainerCreation.js');
17
- const { searchDockerHub } = await import('../src/helpers/dockerHubService.js');
18
-
19
- function HookTester({ onCreate, onCancel, dbImages, imageProfiles, expose }) {
20
- const hook = useContainerCreation({ onCreate, onCancel, dbImages, imageProfiles });
21
- // keep exposing the latest hook values on every render
22
- useEffect(() => {
23
- if (expose) expose.current = hook;
24
- });
25
- return null;
26
- }
27
-
28
- describe('useContainerCreation (DOM render)', () => {
29
- test('image empty shows error and does not advance', () => {
30
- const expose = { current: null };
31
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} expose={expose} />);
32
-
33
- expect(expose.current.step).toBe(0);
34
-
35
- act(() => {
36
- expose.current.nextStep();
37
- });
38
-
39
- expect(expose.current.message).toBe('Image name cannot be empty.');
40
- expect(expose.current.step).toBe(0);
41
- });
42
-
43
- test('ports validation: invalid ports shows error', () => {
44
- const expose = { current: null };
45
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} expose={expose} />);
46
-
47
- act(() => {
48
- expose.current.setImageName('nginx');
49
- });
50
-
51
- act(() => {
52
- expose.current.nextStep(); // to name
53
- });
54
-
55
- act(() => {
56
- expose.current.nextStep(); // to ports
57
- });
58
-
59
- expect(expose.current.step).toBe(2);
60
-
61
- act(() => {
62
- expose.current.setPortInput('notaport');
63
- });
64
-
65
- act(() => {
66
- expose.current.nextStep();
67
- });
68
-
69
- expect(expose.current.message).toBe('Port format must be host:container and both must be numbers (e.g. 8080:80)');
70
- expect(expose.current.step).toBe(2);
71
- });
72
-
73
- test('complete flow calls onCreate with data', () => {
74
- const created = [];
75
- const onCreate = (data) => created.push(data);
76
- const expose = { current: null };
77
-
78
- render(<HookTester onCreate={onCreate} onCancel={() => {}} dbImages={[]} expose={expose} />);
79
-
80
- act(() => {
81
- expose.current.setImageName('redis');
82
- });
83
-
84
- act(() => {
85
- expose.current.nextStep(); // to name
86
- });
87
-
88
- act(() => {
89
- expose.current.nextStep(); // to ports
90
- });
91
-
92
- act(() => {
93
- expose.current.setPortInput('');
94
- });
95
-
96
- act(() => {
97
- expose.current.nextStep(); // to env
98
- });
99
-
100
- act(() => {
101
- expose.current.setEnvInput('FOO=bar');
102
- });
103
-
104
- act(() => {
105
- expose.current.nextStep(); // final create
106
- });
107
-
108
- expect(created.length).toBe(1);
109
- expect(created[0]).toEqual({ imageName: 'redis:7-alpine', containerName: '', portInput: '', envInput: 'FOO=bar' });
110
- });
111
-
112
- test('step 3 with mysql image: missing MYSQL_ROOT_PASSWORD blocks creation', () => {
113
- const created = [];
114
- const expose = { current: null };
115
-
116
- render(<HookTester onCreate={(d) => created.push(d)} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
117
-
118
- // advance to step 3 (env) with mysql image
119
- act(() => { expose.current.setImageName('mysql:8'); });
120
- act(() => { expose.current.nextStep(); }); // step 0 → 1
121
- act(() => { expose.current.nextStep(); }); // step 1 → 2
122
- act(() => { expose.current.nextStep(); }); // step 2 → 3
123
-
124
- expect(expose.current.step).toBe(3);
125
-
126
- // try to advance without required env var
127
- act(() => { expose.current.nextStep(); });
128
-
129
- expect(expose.current.step).toBe(3); // still blocked
130
- expect(expose.current.message).toMatch(/MYSQL_ROOT_PASSWORD/i);
131
- expect(created.length).toBe(0);
132
- });
133
-
134
- test('step 3 with mysql image: valid env advances and calls onCreate', () => {
135
- const created = [];
136
- const expose = { current: null };
137
-
138
- render(<HookTester onCreate={(d) => created.push(d)} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
139
-
140
- act(() => { expose.current.setImageName('mysql:8'); });
141
- act(() => { expose.current.nextStep(); });
142
- act(() => { expose.current.nextStep(); });
143
- act(() => { expose.current.nextStep(); }); // at step 3
144
-
145
- act(() => { expose.current.setEnvInput('MYSQL_ROOT_PASSWORD=secret'); });
146
- act(() => { expose.current.nextStep(); }); // should call onCreate
147
-
148
- expect(created.length).toBe(1);
149
- expect(created[0].imageName).toBe('mysql:8');
150
- });
151
- });
152
-
153
- describe('useContainerCreation — suggestions & updateImageInput()', () => {
154
- test('hook exposes suggestions, selectedSuggestionIndex, visibleOffset', () => {
155
- const expose = { current: null };
156
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
157
- expect(Array.isArray(expose.current.suggestions)).toBe(true);
158
- expect(typeof expose.current.selectedSuggestionIndex).toBe('number');
159
- expect(typeof expose.current.visibleOffset).toBe('number');
160
- });
161
-
162
- test('updateImageInput() with "ng" filters IMAGE_PROFILES keys containing "ng"', () => {
163
- const expose = { current: null };
164
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
165
-
166
- act(() => { expose.current.updateImageInput('ng'); });
167
-
168
- expect(expose.current.imageName).toBe('ng');
169
- expect(expose.current.suggestions).toContain('nginx');
170
- });
171
-
172
- test('updateImageInput() with empty string clears suggestions', () => {
173
- const expose = { current: null };
174
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
175
-
176
- act(() => { expose.current.updateImageInput('ng'); });
177
- act(() => { expose.current.updateImageInput(''); });
178
-
179
- expect(expose.current.suggestions).toHaveLength(0);
180
- });
181
-
182
- test('updateImageInput() resets selectedSuggestionIndex to -1', () => {
183
- const expose = { current: null };
184
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
185
-
186
- act(() => { expose.current.updateImageInput('ng'); });
187
-
188
- expect(expose.current.selectedSuggestionIndex).toBe(-1);
189
- });
190
-
191
- test('step-3 message includes suggestedEnv hints when profile has them', () => {
192
- const expose = { current: null };
193
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
194
-
195
- act(() => { expose.current.setImageName('postgres'); });
196
- act(() => { expose.current.nextStep(); }); // → step 1
197
- act(() => { expose.current.nextStep(); }); // → step 2
198
- act(() => { expose.current.nextStep(); }); // → step 3
199
-
200
- expect(expose.current.message).toMatch(/POSTGRES_PASSWORD/i);
201
- expect(expose.current.message).toMatch(/POSTGRES_USER/i);
202
- });
203
- });
204
-
205
- describe('useContainerCreation — moveSuggestionSelection() & applyFocusedSuggestion()', () => {
206
- test('moveSuggestionSelection(1) increases selectedSuggestionIndex', () => {
207
- const expose = { current: null };
208
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
209
-
210
- act(() => { expose.current.updateImageInput('m'); });
211
- // There should be several suggestions with "m"
212
- act(() => { expose.current.moveSuggestionSelection(1); });
213
-
214
- expect(expose.current.selectedSuggestionIndex).toBe(0);
215
- });
216
-
217
- test('moveSuggestionSelection() clamps at last item', () => {
218
- const expose = { current: null };
219
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
220
-
221
- act(() => { expose.current.updateImageInput('m'); });
222
- const count = expose.current.suggestions.length;
223
-
224
- // Move past end
225
- for (let i = 0; i <= count + 2; i++) {
226
- act(() => { expose.current.moveSuggestionSelection(1); });
227
- }
228
-
229
- expect(expose.current.selectedSuggestionIndex).toBe(count - 1);
230
- });
231
-
232
- test('moveSuggestionSelection(-1) clamps at -1 (no selection)', () => {
233
- const expose = { current: null };
234
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
235
-
236
- act(() => { expose.current.updateImageInput('m'); });
237
- act(() => { expose.current.moveSuggestionSelection(-1); });
238
-
239
- expect(expose.current.selectedSuggestionIndex).toBe(-1);
240
- });
241
-
242
- test('applyFocusedSuggestion() fills imageName with focused suggestion', () => {
243
- const expose = { current: null };
244
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
245
-
246
- act(() => { expose.current.updateImageInput('ng'); });
247
- act(() => { expose.current.moveSuggestionSelection(1); }); // select first
248
- act(() => { expose.current.applyFocusedSuggestion(); });
249
-
250
- expect(expose.current.imageName).toBe('nginx:1.27-alpine');
251
- });
252
-
253
- test('applyFocusedSuggestion() does NOT advance step', () => {
254
- const expose = { current: null };
255
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
256
-
257
- act(() => { expose.current.updateImageInput('ng'); });
258
- act(() => { expose.current.moveSuggestionSelection(1); });
259
- act(() => { expose.current.applyFocusedSuggestion(); });
260
-
261
- expect(expose.current.step).toBe(0);
262
- });
263
-
264
- test('applyFocusedSuggestion() clears suggestions after applying', () => {
265
- const expose = { current: null };
266
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
267
-
268
- act(() => { expose.current.updateImageInput('ng'); });
269
- act(() => { expose.current.moveSuggestionSelection(1); });
270
- act(() => { expose.current.applyFocusedSuggestion(); });
271
-
272
- expect(expose.current.suggestions).toHaveLength(0);
273
- });
274
-
275
- test('applyFocusedSuggestion() preserves explicit tag when suggestion already has one', () => {
276
- const expose = { current: null };
277
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
278
-
279
- // Manually set imageName with a tag and simulate a suggestion with tag
280
- act(() => { expose.current.updateImageInput('pg'); });
281
- // inject a suggestion with a tag by directly calling with known value
282
- // Instead: use updateImageInput then manually apply — but suggestions come from profiles (no tag)
283
- // So test that if imageName already has ':' when applyFocusedSuggestion runs, it is not double-tagged
284
- // The real scenario: user types 'postgres:15', selects suggestion — but suggestions list only contains 'postgres'
285
- // Better test: set imageName to 'postgres:15' via setImageName then call nextStep on step 0 → tag preserved
286
- act(() => { expose.current.setImageName('postgres:15'); });
287
- act(() => { expose.current.nextStep(); }); // step 0 → should advance with postgres:15 preserved
288
-
289
- expect(expose.current.imageName).toBe('postgres:15');
290
- expect(expose.current.step).toBe(1);
291
- });
292
- });
293
-
294
- describe('useContainerCreation — resolveImageTag on nextStep & applyFocusedSuggestion', () => {
295
- test('nextStep at step 0 with bare image name appends defaultTag', () => {
296
- const expose = { current: null };
297
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
298
-
299
- act(() => { expose.current.setImageName('postgres'); });
300
- act(() => { expose.current.nextStep(); });
301
-
302
- expect(expose.current.imageName).toBe('postgres:17-alpine');
303
- expect(expose.current.step).toBe(1);
304
- });
305
-
306
- test('nextStep at step 0 with explicit tag preserves it', () => {
307
- const expose = { current: null };
308
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
309
-
310
- act(() => { expose.current.setImageName('postgres:15'); });
311
- act(() => { expose.current.nextStep(); });
312
-
313
- expect(expose.current.imageName).toBe('postgres:15');
314
- expect(expose.current.step).toBe(1);
315
- });
316
- });
317
-
318
- describe('useContainerCreation — insertNextSuggestedEnv() & hasSuggestedEnv', () => {
319
- test('insertNextSuggestedEnv is exposed from hook', () => {
320
- const expose = { current: null };
321
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
322
- expect(typeof expose.current.insertNextSuggestedEnv).toBe('function');
323
- });
324
-
325
- test('hasSuggestedEnv is true when profile has suggestedEnv entries (postgres)', () => {
326
- const expose = { current: null };
327
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
328
- act(() => { expose.current.setImageName('postgres:17-alpine'); });
329
- expect(expose.current.hasSuggestedEnv).toBe(true);
330
- });
331
-
332
- test('hasSuggestedEnv is false when image has no suggestedEnv (nginx)', () => {
333
- const expose = { current: null };
334
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
335
- act(() => { expose.current.setImageName('nginx:1.27-alpine'); });
336
- expect(expose.current.hasSuggestedEnv).toBe(false);
337
- });
338
-
339
- test('calling insertNextSuggestedEnv with postgres image inserts first suggested env', () => {
340
- const expose = { current: null };
341
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
342
- // advance to step 3 with postgres
343
- act(() => { expose.current.setImageName('postgres'); });
344
- act(() => { expose.current.nextStep(); }); // → step 1 (resolves tag)
345
- act(() => { expose.current.nextStep(); }); // → step 2
346
- act(() => { expose.current.nextStep(); }); // → step 3
347
-
348
- act(() => { expose.current.insertNextSuggestedEnv(); });
349
- // envInput should contain the first suggestedEnv for postgres (required var first)
350
- expect(expose.current.envInput).toBe('POSTGRES_PASSWORD=secret');
351
- });
352
-
353
- test('calling insertNextSuggestedEnv again inserts the next pending suggestion', () => {
354
- const expose = { current: null };
355
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
356
- act(() => { expose.current.setImageName('postgres'); });
357
- act(() => { expose.current.nextStep(); });
358
- act(() => { expose.current.nextStep(); });
359
- act(() => { expose.current.nextStep(); });
360
-
361
- act(() => { expose.current.insertNextSuggestedEnv(); }); // inserts first
362
- act(() => { expose.current.insertNextSuggestedEnv(); }); // inserts second
363
- expect(expose.current.envInput).toBe('POSTGRES_PASSWORD=secret,POSTGRES_USER=postgres');
364
- });
365
-
366
- test('calling insertNextSuggestedEnv when all already added sets feedback message', () => {
367
- const expose = { current: null };
368
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
369
- act(() => { expose.current.setImageName('postgres'); });
370
- act(() => { expose.current.nextStep(); });
371
- act(() => { expose.current.nextStep(); });
372
- act(() => { expose.current.nextStep(); });
373
-
374
- // manually set all suggested envs
375
- const profile = IMAGE_PROFILES['postgres'];
376
- act(() => { expose.current.setEnvInput(profile.suggestedEnv.join(',')); });
377
-
378
- act(() => { expose.current.insertNextSuggestedEnv(); });
379
- expect(expose.current.message).toBe('All suggested env vars added');
380
- // envInput unchanged
381
- expect(expose.current.envInput).toBe(profile.suggestedEnv.join(','));
382
- });
383
-
384
- test('if user manually typed a key matching a suggestion, Tab skips that key', () => {
385
- const expose = { current: null };
386
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
387
- act(() => { expose.current.setImageName('postgres'); });
388
- act(() => { expose.current.nextStep(); });
389
- act(() => { expose.current.nextStep(); });
390
- act(() => { expose.current.nextStep(); });
391
-
392
- // user manually typed first suggestion key with different value
393
- act(() => { expose.current.setEnvInput('POSTGRES_PASSWORD=mypass'); });
394
-
395
- act(() => { expose.current.insertNextSuggestedEnv(); });
396
- // should skip POSTGRES_PASSWORD and insert the next one (POSTGRES_USER)
397
- expect(expose.current.envInput).toContain('POSTGRES_USER=postgres');
398
- expect(expose.current.envInput).not.toContain('POSTGRES_PASSWORD=secret');
399
- });
400
- });
401
-
402
- describe('useContainerCreation — triggerHubSearch()', () => {
403
- beforeEach(() => {
404
- searchDockerHub.mockReset();
405
- });
406
-
407
- test('triggerHubSearch is exposed from the hook', () => {
408
- const expose = { current: null };
409
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
410
-
411
- expect(typeof expose.current.triggerHubSearch).toBe('function');
412
- });
413
-
414
- test('calling triggerHubSearch() sets isSearchingHub=true while searching, then false after resolve', async () => {
415
- let resolveSearch;
416
- searchDockerHub.mockImplementation(
417
- () => new Promise((resolve) => { resolveSearch = resolve; })
418
- );
419
-
420
- const expose = { current: null };
421
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
422
-
423
- act(() => { expose.current.updateImageInput('nginx'); });
424
-
425
- // Trigger search (async, don't await yet)
426
- act(() => { expose.current.triggerHubSearch(); });
427
-
428
- expect(expose.current.isSearchingHub).toBe(true);
429
-
430
- // Resolve the mock search
431
- const results = [{ name: 'nginx', isOfficial: true, pullCount: 1000, description: 'nginx' }];
432
- await act(async () => { resolveSearch(results); });
433
-
434
- expect(expose.current.isSearchingHub).toBe(false);
435
- // hubResults is string[] after formatHubResult mapping (mock returns r.name)
436
- expect(expose.current.hubResults).toEqual(['nginx']);
437
- });
438
-
439
- test('after search resolves, hubResults is set and isSearchingHub is false', async () => {
440
- const results = [{ name: 'nginx', isOfficial: true, pullCount: 500, description: '' }];
441
- searchDockerHub.mockResolvedValue(results);
442
-
443
- const expose = { current: null };
444
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
445
-
446
- act(() => { expose.current.updateImageInput('nginx'); });
447
-
448
- await act(async () => { await expose.current.triggerHubSearch(); });
449
-
450
- // hubResults is string[] after formatHubResult mapping (mock returns r.name)
451
- expect(expose.current.hubResults).toEqual(['nginx']);
452
- expect(expose.current.isSearchingHub).toBe(false);
453
- });
454
-
455
- test('calling triggerHubSearch() when isSearchingHub===true is a no-op (concurrent guard)', async () => {
456
- let resolveFirst;
457
- searchDockerHub.mockImplementation(
458
- () => new Promise((resolve) => { resolveFirst = resolve; })
459
- );
460
-
461
- const expose = { current: null };
462
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
463
-
464
- act(() => { expose.current.updateImageInput('nginx'); });
465
- act(() => { expose.current.triggerHubSearch(); }); // first call
466
-
467
- expect(expose.current.isSearchingHub).toBe(true);
468
- expect(searchDockerHub).toHaveBeenCalledTimes(1);
469
-
470
- // Second call while still searching should be a no-op
471
- act(() => { expose.current.triggerHubSearch(); });
472
-
473
- expect(searchDockerHub).toHaveBeenCalledTimes(1); // still only once
474
-
475
- // Cleanup
476
- await act(async () => { resolveFirst([]); });
477
- });
478
-
479
- test('calling triggerHubSearch() with empty imageName is a no-op', async () => {
480
- const expose = { current: null };
481
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
482
-
483
- // imageName is empty by default
484
- await act(async () => { await expose.current.triggerHubSearch(); });
485
-
486
- expect(searchDockerHub).not.toHaveBeenCalled();
487
- expect(expose.current.isSearchingHub).toBe(false);
488
- });
489
-
490
- test('typing into imageName (updateImageInput) aborts ongoing search and clears hubResults', async () => {
491
- let resolveSearch;
492
- searchDockerHub.mockImplementation(
493
- () => new Promise((resolve) => { resolveSearch = resolve; })
494
- );
495
-
496
- const expose = { current: null };
497
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
498
-
499
- act(() => { expose.current.updateImageInput('nginx'); });
500
- act(() => { expose.current.triggerHubSearch(); }); // in-flight
501
-
502
- expect(expose.current.isSearchingHub).toBe(true);
503
-
504
- // User types more — should abort search and clear results
505
- await act(async () => {
506
- expose.current.updateImageInput('ngin');
507
- // Resolve the (now stale) promise — it should be ignored
508
- resolveSearch([{ name: 'nginx', isOfficial: true, pullCount: 100, description: '' }]);
509
- });
510
-
511
- expect(expose.current.isSearchingHub).toBe(false);
512
- expect(expose.current.hubResults).toBeNull();
513
- });
514
- });
515
-
516
- describe('useContainerCreation — message auto-clear after timeout', () => {
517
- beforeEach(() => {
518
- jest.useFakeTimers();
519
- });
520
-
521
- afterEach(() => {
522
- jest.runOnlyPendingTimers();
523
- jest.useRealTimers();
524
- });
525
-
526
- test('error message set on nextStep clears after 4000ms', () => {
527
- const expose = { current: null };
528
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} expose={expose} />);
529
-
530
- // Trigger validation error (empty imageName)
531
- act(() => {
532
- expose.current.nextStep();
533
- });
534
-
535
- expect(expose.current.message).toBe('Image name cannot be empty.');
536
-
537
- // Advance timers by 4000ms
538
- act(() => {
539
- jest.advanceTimersByTime(4000);
540
- });
541
-
542
- expect(expose.current.message).toBe('');
543
- });
544
-
545
- test('success message from insertNextSuggestedEnv clears after 4000ms', () => {
546
- const expose = { current: null };
547
- render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
548
-
549
- act(() => { expose.current.setImageName('postgres'); });
550
- act(() => { expose.current.nextStep(); }); // step 0 → 1
551
- act(() => { expose.current.nextStep(); }); // step 1 → 2
552
- act(() => { expose.current.nextStep(); }); // step 2 → 3
553
-
554
- // Exhaust all suggested envs then call insertNextSuggestedEnv once more to get 'All suggested' message
555
- const profile = IMAGE_PROFILES['postgres'];
556
- act(() => { expose.current.setEnvInput(profile.suggestedEnv.join(',')); });
557
- act(() => { expose.current.insertNextSuggestedEnv(); });
558
-
559
- expect(expose.current.message).toBe('All suggested env vars added');
560
-
561
- act(() => {
562
- jest.advanceTimersByTime(4000);
563
- });
564
-
565
- expect(expose.current.message).toBe('');
566
- });
567
- });