cdd-cli 3.2.0 → 3.2.2

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 (120) 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/CHANGELOG.md +29 -0
  7. package/SECURITY.md +5 -0
  8. package/__mocks__/ink.cjs +17 -0
  9. package/coverage/clover.xml +526 -436
  10. package/coverage/coverage-final.json +19 -15
  11. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +376 -0
  12. package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
  13. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +244 -0
  14. package/coverage/lcov-report/components/index.html +146 -0
  15. package/coverage/lcov-report/helpers/constants.js.html +80 -17
  16. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +41 -8
  17. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
  18. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
  19. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
  20. package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
  21. package/coverage/lcov-report/helpers/index.html +27 -12
  22. package/coverage/lcov-report/helpers/logger.js.html +19 -19
  23. package/coverage/lcov-report/helpers/safeCall.js.html +3 -3
  24. package/coverage/lcov-report/helpers/validationHelpers.js.html +28 -76
  25. package/coverage/lcov-report/hooks/creation/index.html +20 -20
  26. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
  27. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +416 -89
  28. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
  29. package/coverage/lcov-report/hooks/debug/index.html +5 -5
  30. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
  31. package/coverage/lcov-report/hooks/index.html +40 -40
  32. package/coverage/lcov-report/hooks/navigation/index.html +19 -19
  33. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
  34. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
  35. package/coverage/lcov-report/hooks/useControls.js.html +263 -122
  36. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
  37. package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
  38. package/coverage/lcov-report/index.html +67 -52
  39. package/coverage/lcov.info +991 -805
  40. package/dist/App.js +11 -11
  41. package/dist/components/ContainerCreationPrompt.js +31 -10
  42. package/dist/components/ContainerList.js +3 -3
  43. package/dist/components/ContainerRow.js +20 -20
  44. package/dist/components/ContainerSection.js +3 -3
  45. package/dist/components/Footer.js +2 -2
  46. package/dist/components/Header.js +7 -7
  47. package/dist/components/LogViewer.js +3 -3
  48. package/dist/components/MessageFeedback.js +2 -2
  49. package/dist/components/PromptField.js +4 -4
  50. package/dist/components/StatsBar.js +5 -5
  51. package/dist/components/SuggestionPanel.js +44 -0
  52. package/dist/components/UsageMenu.js +3 -3
  53. package/dist/helpers/appInfo.js +7 -7
  54. package/dist/helpers/constants.js +114 -0
  55. package/dist/helpers/containerOptionsBuilder.js +3 -3
  56. package/dist/helpers/dockerService/dockerService.js +1 -1
  57. package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
  58. package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
  59. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
  60. package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
  61. package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
  62. package/dist/helpers/exitWithMessage.js +10 -10
  63. package/dist/helpers/imageNameUtils.js +32 -0
  64. package/dist/helpers/logger.js +13 -13
  65. package/dist/helpers/safeCall.js +2 -2
  66. package/dist/helpers/validationHelpers.js +76 -21
  67. package/dist/hooks/creation/useContainerActions.js +9 -9
  68. package/dist/hooks/creation/useContainerCreation.js +138 -36
  69. package/dist/hooks/creation/useLogsViewer.js +2 -2
  70. package/dist/hooks/debug/useDebugLogs.js +4 -4
  71. package/dist/hooks/useContainerCommandRouter.js +13 -13
  72. package/dist/hooks/useContainers.js +3 -3
  73. package/dist/hooks/useControls.js +66 -35
  74. package/dist/hooks/useEraseConfirmation.js +3 -3
  75. package/dist/index.js +2 -2
  76. package/jest.config.cjs +7 -0
  77. package/package.json +7 -3
  78. package/src/App.jsx +33 -16
  79. package/src/components/ContainerCreationPrompt.jsx +43 -9
  80. package/src/components/ContainerList.jsx +3 -3
  81. package/src/components/ContainerRow.jsx +19 -16
  82. package/src/components/ContainerSection.jsx +3 -3
  83. package/src/components/Footer.jsx +2 -2
  84. package/src/components/Header.jsx +7 -7
  85. package/src/components/LogViewer.jsx +5 -3
  86. package/src/components/MessageFeedback.jsx +2 -2
  87. package/src/components/PromptField.jsx +4 -4
  88. package/src/components/StatsBar.jsx +11 -7
  89. package/src/components/SuggestionPanel.jsx +53 -0
  90. package/src/components/UsageMenu.jsx +18 -13
  91. package/src/helpers/appInfo.js +7 -7
  92. package/src/helpers/constants.js +30 -9
  93. package/src/helpers/containerOptionsBuilder.js +16 -5
  94. package/src/helpers/dockerService/dockerService.js +1 -1
  95. package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
  96. package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
  97. package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
  98. package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
  99. package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
  100. package/src/helpers/exitWithMessage.js +13 -7
  101. package/src/helpers/imageNameUtils.js +32 -0
  102. package/src/helpers/logger.js +15 -15
  103. package/src/helpers/safeCall.js +2 -2
  104. package/src/helpers/validationHelpers.js +13 -29
  105. package/src/hooks/creation/useContainerActions.js +19 -12
  106. package/src/hooks/creation/useContainerCreation.js +150 -41
  107. package/src/hooks/creation/useLogsViewer.js +2 -2
  108. package/src/hooks/debug/useDebugLogs.js +15 -10
  109. package/src/hooks/navigation/useContainerSelection.js +17 -14
  110. package/src/hooks/useContainerCommandRouter.js +71 -58
  111. package/src/hooks/useContainers.js +4 -4
  112. package/src/hooks/useControls.js +115 -68
  113. package/src/hooks/useEraseConfirmation.js +3 -3
  114. package/src/hooks/useExitHandler.js +29 -26
  115. package/src/index.js +4 -4
  116. package/test/ContainerCreationPrompt.dom.test.js +68 -0
  117. package/test/SuggestionPanel.dom.test.js +53 -0
  118. package/test/constants.test.js +34 -0
  119. package/test/useContainerCreation.dom.test.js +123 -0
  120. package/test/useControls.dom.test.js +198 -2
@@ -1,17 +1,17 @@
1
- import React from "react";
2
- import { useInput } from "ink";
3
- import { useContainerActions } from "./creation/useContainerActions";
4
- import { useContainerCreation } from "./creation/useContainerCreation";
5
- import { useLogsViewer } from "./creation/useLogsViewer";
6
- import { useContainerSelection } from "./navigation/useContainerSelection";
7
- import { useDebugLogs } from "./debug/useDebugLogs";
8
- import { useEraseConfirmation } from "./useEraseConfirmation";
9
- import { useExitHandler } from "./useExitHandler";
10
- import { useContainerCommandRouter } from "./useContainerCommandRouter";
11
- import { getLogsStream } from "../helpers/dockerService/serviceComponents/containerLogs.js";
12
- import { createContainer as svcCreateContainer } from "../helpers/dockerService/serviceComponents/containerActions.js";
13
- import { buildContainerOptions } from "../helpers/containerOptionsBuilder.js";
14
- import { DB_IMAGES } from "../helpers/constants.js";
1
+ import React from 'react';
2
+ import { useInput } from 'ink';
3
+ import { useContainerActions } from './creation/useContainerActions';
4
+ import { useContainerCreation } from './creation/useContainerCreation';
5
+ import { useLogsViewer } from './creation/useLogsViewer';
6
+ import { useContainerSelection } from './navigation/useContainerSelection';
7
+ import { useDebugLogs } from './debug/useDebugLogs';
8
+ import { useEraseConfirmation } from './useEraseConfirmation';
9
+ import { useExitHandler } from './useExitHandler';
10
+ import { useContainerCommandRouter } from './useContainerCommandRouter';
11
+ import { getLogsStream } from '../helpers/dockerService/serviceComponents/containerLogs.js';
12
+ import { createContainer as svcCreateContainer } from '../helpers/dockerService/serviceComponents/containerActions.js';
13
+ import { buildContainerOptions } from '../helpers/containerOptionsBuilder.js';
14
+ import { DB_IMAGES } from '../helpers/constants.js';
15
15
 
16
16
  // Principal hook to manage user inputs and control the app state
17
17
  /**
@@ -29,20 +29,29 @@ export function useControls(containers = []) {
29
29
 
30
30
  const creation = useContainerCreation({
31
31
  onCreate: async ({ imageName, containerName, portInput, envInput }) => {
32
- const options = buildContainerOptions({ imageName, containerName, portInput, envInput });
32
+ const options = buildContainerOptions({
33
+ imageName,
34
+ containerName,
35
+ portInput,
36
+ envInput,
37
+ });
33
38
  actions.setMessage(`Creating container ${imageName}...`);
34
- actions.setMessageColor("yellow");
39
+ actions.setMessageColor('yellow');
35
40
  try {
36
41
  const { id, ports } = await svcCreateContainer(imageName, options);
37
- let portMsg = "";
42
+ let portMsg = '';
38
43
  if (ports && ports.length) {
39
- portMsg = " | Ports: " + ports.map(p => `${p.hostPort}→${p.containerPort}/${p.protocol}`).join(", ");
44
+ portMsg =
45
+ ' | Ports: ' +
46
+ ports
47
+ .map((p) => `${p.hostPort}→${p.containerPort}/${p.protocol}`)
48
+ .join(', ');
40
49
  }
41
50
  actions.setMessage(`Created container ${id}${portMsg}`);
42
- actions.setMessageColor("green");
51
+ actions.setMessageColor('green');
43
52
  } catch (err) {
44
53
  actions.setMessage(`Error creating container: ${err.message}`);
45
- actions.setMessageColor("red");
54
+ actions.setMessageColor('red');
46
55
  } finally {
47
56
  setCreatingContainer(false);
48
57
  }
@@ -59,14 +68,14 @@ export function useControls(containers = []) {
59
68
  onConfirm: () => {
60
69
  actions.handleAction({
61
70
  actionFn: async (id) => await actions.removeContainer(id),
62
- actionLabel: "Erasing",
71
+ actionLabel: 'Erasing',
63
72
  selected: selection.selected,
64
73
  });
65
- actions.setMessageColor("yellow");
74
+ actions.setMessageColor('yellow');
66
75
  },
67
76
  onCancel: () => {
68
- actions.setMessage("");
69
- actions.setMessageColor("");
77
+ actions.setMessage('');
78
+ actions.setMessageColor('');
70
79
  },
71
80
  });
72
81
 
@@ -74,17 +83,22 @@ export function useControls(containers = []) {
74
83
  * Pipe container logs into the viewer while applying a hard limit.
75
84
  * @param {string} containerId - Container identifier used by Docker.
76
85
  */
77
- const startLogsStream = React.useCallback((containerId) => {
78
- getLogsStream(
79
- containerId,
80
- (data) => logsViewer.setLogs((prev) => {
81
- const newLogs = [...prev, ...data.split("\n").filter(Boolean)];
82
- return newLogs.slice(-1000);
83
- }),
84
- () => {},
85
- (err) => logsViewer.setLogs((prev) => [...prev, `Error: ${err.message}`])
86
- );
87
- }, [logsViewer]);
86
+ const startLogsStream = React.useCallback(
87
+ (containerId) => {
88
+ getLogsStream(
89
+ containerId,
90
+ (data) =>
91
+ logsViewer.setLogs((prev) => {
92
+ const newLogs = [...prev, ...data.split('\n').filter(Boolean)];
93
+ return newLogs.slice(-1000);
94
+ }),
95
+ () => {},
96
+ (err) =>
97
+ logsViewer.setLogs((prev) => [...prev, `Error: ${err.message}`])
98
+ );
99
+ },
100
+ [logsViewer]
101
+ );
88
102
 
89
103
  const commandRouter = useContainerCommandRouter({
90
104
  actions,
@@ -95,8 +109,10 @@ export function useControls(containers = []) {
95
109
  startLogsStream,
96
110
  onStartErase: () => {
97
111
  eraseConfirmation.startErase();
98
- actions.setMessage("Are you sure you want to erase this container? (y/n)");
99
- actions.setMessageColor("yellow");
112
+ actions.setMessage(
113
+ 'Are you sure you want to erase this container? (y/n)'
114
+ );
115
+ actions.setMessageColor('yellow');
100
116
  },
101
117
  onToggleDebug: () => debugLogs.setShowDebugLogs((prev) => !prev),
102
118
  onStartCreate: () => setCreatingContainer(true),
@@ -104,8 +120,8 @@ export function useControls(containers = []) {
104
120
 
105
121
  const exitHandler = useExitHandler({
106
122
  onBeforeExit: () => {
107
- actions.setMessage("Exiting...");
108
- actions.setMessageColor("yellow");
123
+ actions.setMessage('Exiting...');
124
+ actions.setMessageColor('yellow');
109
125
  logsViewer.closeLogs();
110
126
  debugLogs.setShowDebugLogs(false);
111
127
  },
@@ -114,38 +130,67 @@ export function useControls(containers = []) {
114
130
  /**
115
131
  * Route keystrokes to the container creation wizard.
116
132
  */
117
- const processCreationInput = React.useCallback((input, key) => {
118
- const step = creation.step;
133
+ const processCreationInput = React.useCallback(
134
+ (input, key) => {
135
+ const step = creation.step;
119
136
 
120
- const removeLastChar = (setter) => setter((value) => (value || "").slice(0, -1));
121
- const appendChar = (setter, current, ch) => setter((current || "") + ch);
137
+ const removeLastChar = (setter) =>
138
+ setter((value) => (value || '').slice(0, -1));
139
+ const appendChar = (setter, current, ch) => setter((current || '') + ch);
122
140
 
123
- if (key.escape) {
124
- creation.cancelCreation();
125
- setCreatingContainer(false);
126
- return;
127
- }
141
+ if (key.escape) {
142
+ creation.cancelCreation();
143
+ setCreatingContainer(false);
144
+ return;
145
+ }
128
146
 
129
- if (input === "\r" || input === "\n") {
130
- creation.nextStep();
131
- return;
132
- }
147
+ if (input === '\r' || input === '\n') {
148
+ // If on step 0 with a focused suggestion, apply it instead of advancing
149
+ if (step === 0 && creation.selectedSuggestionIndex >= 0) {
150
+ creation.applyFocusedSuggestion();
151
+ return;
152
+ }
153
+ creation.nextStep();
154
+ return;
155
+ }
133
156
 
134
- if (key.backspace || key.delete) {
135
- if (step === 0) removeLastChar(creation.setImageName);
136
- if (step === 1) removeLastChar(creation.setContainerName);
137
- if (step === 2) removeLastChar(creation.setPortInput);
138
- if (step === 3) removeLastChar(creation.setEnvInput);
139
- return;
140
- }
157
+ // Arrow keys on step 0: navigate suggestion list
158
+ if (step === 0 && creation.suggestions.length > 0) {
159
+ if (key.upArrow) {
160
+ creation.moveSuggestionSelection(-1);
161
+ return;
162
+ }
163
+ if (key.downArrow) {
164
+ creation.moveSuggestionSelection(1);
165
+ return;
166
+ }
167
+ }
141
168
 
142
- if (input && input.length === 1 && !key.ctrl && !key.meta) {
143
- if (step === 0) appendChar(creation.setImageName, creation.imageName, input);
144
- if (step === 1) appendChar(creation.setContainerName, creation.containerName, input);
145
- if (step === 2) appendChar(creation.setPortInput, creation.portInput, input);
146
- if (step === 3) appendChar(creation.setEnvInput, creation.envInput, input);
147
- }
148
- }, [creation, setCreatingContainer]);
169
+ if (key.backspace || key.delete) {
170
+ if (step === 0) {
171
+ const newValue = (creation.imageName || '').slice(0, -1);
172
+ creation.updateImageInput(newValue);
173
+ }
174
+ if (step === 1) removeLastChar(creation.setContainerName);
175
+ if (step === 2) removeLastChar(creation.setPortInput);
176
+ if (step === 3) removeLastChar(creation.setEnvInput);
177
+ return;
178
+ }
179
+
180
+ if (input && input.length === 1 && !key.ctrl && !key.meta) {
181
+ if (step === 0) {
182
+ creation.updateImageInput((creation.imageName || '') + input);
183
+ }
184
+ if (step === 1)
185
+ appendChar(creation.setContainerName, creation.containerName, input);
186
+ if (step === 2)
187
+ appendChar(creation.setPortInput, creation.portInput, input);
188
+ if (step === 3)
189
+ appendChar(creation.setEnvInput, creation.envInput, input);
190
+ }
191
+ },
192
+ [creation, setCreatingContainer]
193
+ );
149
194
 
150
195
  // Single keyboard entry point
151
196
  useInput((input, key) => {
@@ -155,7 +200,7 @@ export function useControls(containers = []) {
155
200
  }
156
201
 
157
202
  if (logsViewer.showLogs) {
158
- if (input === "q" || key.escape) {
203
+ if (input === 'q' || key.escape) {
159
204
  logsViewer.closeLogs();
160
205
  }
161
206
  return;
@@ -180,7 +225,9 @@ export function useControls(containers = []) {
180
225
  selected: selection.selected,
181
226
  setSelected: selection.setSelected,
182
227
  message: creatingContainer ? creation.message : actions.message,
183
- messageColor: creatingContainer ? creation.messageColor : actions.messageColor,
228
+ messageColor: creatingContainer
229
+ ? creation.messageColor
230
+ : actions.messageColor,
184
231
  showLogs: logsViewer.showLogs,
185
232
  logs: logsViewer.logs,
186
233
  exitLogs: logsViewer.closeLogs,
@@ -1,4 +1,4 @@
1
- import { useState, useCallback } from "react";
1
+ import { useState, useCallback } from 'react';
2
2
 
3
3
  /**
4
4
  * Hook to manage the erase confirmation dialog flow.
@@ -20,13 +20,13 @@ export function useEraseConfirmation({ onConfirm, onCancel }) {
20
20
 
21
21
  const processEraseConfirmation = useCallback(
22
22
  (input, key) => {
23
- if (input === "y" || input === "Y") {
23
+ if (input === 'y' || input === 'Y') {
24
24
  onConfirm();
25
25
  setConfirmErase(false);
26
26
  return;
27
27
  }
28
28
 
29
- if (input === "n" || input === "N" || key.escape) {
29
+ if (input === 'n' || input === 'N' || key.escape) {
30
30
  setConfirmErase(false);
31
31
  onCancel();
32
32
  return;
@@ -30,37 +30,40 @@ export function useExitHandler({ onBeforeExit } = {}) {
30
30
  * @param {string} input - Raw character input from Ink's useInput
31
31
  * @returns {boolean} True when the input was consumed, false otherwise
32
32
  */
33
- const handleExitCommand = useCallback((input) => {
34
- if (input !== 'q') {
35
- return false;
36
- }
33
+ const handleExitCommand = useCallback(
34
+ (input) => {
35
+ if (input !== 'q') {
36
+ return false;
37
+ }
37
38
 
38
- if (typeof onBeforeExit === 'function') {
39
- onBeforeExit();
40
- }
39
+ if (typeof onBeforeExit === 'function') {
40
+ onBeforeExit();
41
+ }
41
42
 
42
- const clearTerminal = () => {
43
- try {
44
- if (process.stdout && process.stdout.isTTY) {
45
- process.stdout.write('\u001Bc');
46
- } else {
47
- console.clear();
43
+ const clearTerminal = () => {
44
+ try {
45
+ if (process.stdout && process.stdout.isTTY) {
46
+ process.stdout.write('\u001Bc');
47
+ } else {
48
+ console.clear();
49
+ }
50
+ } catch (err) {
51
+ // Ignore clear errors to avoid blocking exit.
48
52
  }
49
- } catch (err) {
50
- // Ignore clear errors to avoid blocking exit.
51
- }
52
- };
53
+ };
53
54
 
54
- setTimeout(() => {
55
- clearTerminal();
56
- exit();
57
- if (process.env.NODE_ENV !== 'test') {
58
- setTimeout(() => process.exit(0), 50);
59
- }
60
- }, EXIT_DELAY);
55
+ setTimeout(() => {
56
+ clearTerminal();
57
+ exit();
58
+ if (process.env.NODE_ENV !== 'test') {
59
+ setTimeout(() => process.exit(0), 50);
60
+ }
61
+ }, EXIT_DELAY);
61
62
 
62
- return true;
63
- }, [exit, onBeforeExit]);
63
+ return true;
64
+ },
65
+ [exit, onBeforeExit]
66
+ );
64
67
 
65
68
  return { handleExitCommand };
66
69
  }
package/src/index.js CHANGED
@@ -8,9 +8,9 @@
8
8
  * node index.js
9
9
  */
10
10
 
11
- import React from "react";
12
- import { render } from "ink";
13
- import App from './App';
11
+ import React from 'react';
12
+ import { render } from 'ink';
13
+ import App from './App.jsx';
14
14
 
15
15
  console.clear();
16
- render(<App />);
16
+ render(<App />);
@@ -0,0 +1,68 @@
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
+ });
@@ -0,0 +1,53 @@
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
+ });
@@ -0,0 +1,34 @@
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
+ });
@@ -139,3 +139,126 @@ describe('useContainerCreation (DOM render)', () => {
139
139
  expect(created[0].imageName).toBe('mysql:8');
140
140
  });
141
141
  });
142
+
143
+ describe('useContainerCreation — suggestions & updateImageInput()', () => {
144
+ test('hook exposes suggestions, selectedSuggestionIndex, visibleOffset', () => {
145
+ const expose = { current: null };
146
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
147
+ expect(Array.isArray(expose.current.suggestions)).toBe(true);
148
+ expect(typeof expose.current.selectedSuggestionIndex).toBe('number');
149
+ expect(typeof expose.current.visibleOffset).toBe('number');
150
+ });
151
+
152
+ test('updateImageInput() with "ng" filters IMAGE_PROFILES keys containing "ng"', () => {
153
+ const expose = { current: null };
154
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
155
+
156
+ act(() => { expose.current.updateImageInput('ng'); });
157
+
158
+ expect(expose.current.imageName).toBe('ng');
159
+ expect(expose.current.suggestions).toContain('nginx');
160
+ });
161
+
162
+ test('updateImageInput() with empty string clears suggestions', () => {
163
+ const expose = { current: null };
164
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
165
+
166
+ act(() => { expose.current.updateImageInput('ng'); });
167
+ act(() => { expose.current.updateImageInput(''); });
168
+
169
+ expect(expose.current.suggestions).toHaveLength(0);
170
+ });
171
+
172
+ test('updateImageInput() resets selectedSuggestionIndex to -1', () => {
173
+ const expose = { current: null };
174
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
175
+
176
+ act(() => { expose.current.updateImageInput('ng'); });
177
+
178
+ expect(expose.current.selectedSuggestionIndex).toBe(-1);
179
+ });
180
+
181
+ test('step-3 message includes suggestedEnv hints when profile has them', () => {
182
+ const expose = { current: null };
183
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
184
+
185
+ act(() => { expose.current.setImageName('postgres'); });
186
+ act(() => { expose.current.nextStep(); }); // → step 1
187
+ act(() => { expose.current.nextStep(); }); // → step 2
188
+ act(() => { expose.current.nextStep(); }); // → step 3
189
+
190
+ expect(expose.current.message).toMatch(/POSTGRES_PASSWORD/i);
191
+ expect(expose.current.message).toMatch(/POSTGRES_USER/i);
192
+ });
193
+ });
194
+
195
+ describe('useContainerCreation — moveSuggestionSelection() & applyFocusedSuggestion()', () => {
196
+ test('moveSuggestionSelection(1) increases selectedSuggestionIndex', () => {
197
+ const expose = { current: null };
198
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
199
+
200
+ act(() => { expose.current.updateImageInput('m'); });
201
+ // There should be several suggestions with "m"
202
+ act(() => { expose.current.moveSuggestionSelection(1); });
203
+
204
+ expect(expose.current.selectedSuggestionIndex).toBe(0);
205
+ });
206
+
207
+ test('moveSuggestionSelection() clamps at last item', () => {
208
+ const expose = { current: null };
209
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
210
+
211
+ act(() => { expose.current.updateImageInput('m'); });
212
+ const count = expose.current.suggestions.length;
213
+
214
+ // Move past end
215
+ for (let i = 0; i <= count + 2; i++) {
216
+ act(() => { expose.current.moveSuggestionSelection(1); });
217
+ }
218
+
219
+ expect(expose.current.selectedSuggestionIndex).toBe(count - 1);
220
+ });
221
+
222
+ test('moveSuggestionSelection(-1) clamps at -1 (no selection)', () => {
223
+ const expose = { current: null };
224
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
225
+
226
+ act(() => { expose.current.updateImageInput('m'); });
227
+ act(() => { expose.current.moveSuggestionSelection(-1); });
228
+
229
+ expect(expose.current.selectedSuggestionIndex).toBe(-1);
230
+ });
231
+
232
+ test('applyFocusedSuggestion() fills imageName with focused suggestion', () => {
233
+ const expose = { current: null };
234
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
235
+
236
+ act(() => { expose.current.updateImageInput('ng'); });
237
+ act(() => { expose.current.moveSuggestionSelection(1); }); // select first
238
+ act(() => { expose.current.applyFocusedSuggestion(); });
239
+
240
+ expect(expose.current.imageName).toBe('nginx');
241
+ });
242
+
243
+ test('applyFocusedSuggestion() does NOT advance step', () => {
244
+ const expose = { current: null };
245
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
246
+
247
+ act(() => { expose.current.updateImageInput('ng'); });
248
+ act(() => { expose.current.moveSuggestionSelection(1); });
249
+ act(() => { expose.current.applyFocusedSuggestion(); });
250
+
251
+ expect(expose.current.step).toBe(0);
252
+ });
253
+
254
+ test('applyFocusedSuggestion() clears suggestions after applying', () => {
255
+ const expose = { current: null };
256
+ render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
257
+
258
+ act(() => { expose.current.updateImageInput('ng'); });
259
+ act(() => { expose.current.moveSuggestionSelection(1); });
260
+ act(() => { expose.current.applyFocusedSuggestion(); });
261
+
262
+ expect(expose.current.suggestions).toHaveLength(0);
263
+ });
264
+ });