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.
- package/.atl/skill-registry.md +30 -0
- package/.editorconfig +12 -0
- package/.eslintrc.json +1 -1
- package/.prettierignore +4 -0
- package/.prettierrc +8 -0
- package/CHANGELOG.md +29 -0
- package/SECURITY.md +5 -0
- package/__mocks__/ink.cjs +17 -0
- package/coverage/clover.xml +526 -436
- package/coverage/coverage-final.json +19 -15
- package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +376 -0
- package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
- package/coverage/lcov-report/components/SuggestionPanel.jsx.html +244 -0
- package/coverage/lcov-report/components/index.html +146 -0
- package/coverage/lcov-report/helpers/constants.js.html +80 -17
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +41 -8
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
- package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
- package/coverage/lcov-report/helpers/index.html +27 -12
- package/coverage/lcov-report/helpers/logger.js.html +19 -19
- package/coverage/lcov-report/helpers/safeCall.js.html +3 -3
- package/coverage/lcov-report/helpers/validationHelpers.js.html +28 -76
- package/coverage/lcov-report/hooks/creation/index.html +20 -20
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +416 -89
- package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
- package/coverage/lcov-report/hooks/debug/index.html +5 -5
- package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
- package/coverage/lcov-report/hooks/index.html +40 -40
- package/coverage/lcov-report/hooks/navigation/index.html +19 -19
- package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
- package/coverage/lcov-report/hooks/useControls.js.html +263 -122
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
- package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
- package/coverage/lcov-report/index.html +67 -52
- package/coverage/lcov.info +991 -805
- package/dist/App.js +11 -11
- package/dist/components/ContainerCreationPrompt.js +31 -10
- package/dist/components/ContainerList.js +3 -3
- package/dist/components/ContainerRow.js +20 -20
- package/dist/components/ContainerSection.js +3 -3
- package/dist/components/Footer.js +2 -2
- package/dist/components/Header.js +7 -7
- package/dist/components/LogViewer.js +3 -3
- package/dist/components/MessageFeedback.js +2 -2
- package/dist/components/PromptField.js +4 -4
- package/dist/components/StatsBar.js +5 -5
- package/dist/components/SuggestionPanel.js +44 -0
- package/dist/components/UsageMenu.js +3 -3
- package/dist/helpers/appInfo.js +7 -7
- package/dist/helpers/constants.js +114 -0
- package/dist/helpers/containerOptionsBuilder.js +3 -3
- package/dist/helpers/dockerService/dockerService.js +1 -1
- package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
- package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
- package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
- package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
- package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
- package/dist/helpers/exitWithMessage.js +10 -10
- package/dist/helpers/imageNameUtils.js +32 -0
- package/dist/helpers/logger.js +13 -13
- package/dist/helpers/safeCall.js +2 -2
- package/dist/helpers/validationHelpers.js +76 -21
- package/dist/hooks/creation/useContainerActions.js +9 -9
- package/dist/hooks/creation/useContainerCreation.js +138 -36
- package/dist/hooks/creation/useLogsViewer.js +2 -2
- package/dist/hooks/debug/useDebugLogs.js +4 -4
- package/dist/hooks/useContainerCommandRouter.js +13 -13
- package/dist/hooks/useContainers.js +3 -3
- package/dist/hooks/useControls.js +66 -35
- package/dist/hooks/useEraseConfirmation.js +3 -3
- package/dist/index.js +2 -2
- package/jest.config.cjs +7 -0
- package/package.json +7 -3
- package/src/App.jsx +33 -16
- package/src/components/ContainerCreationPrompt.jsx +43 -9
- package/src/components/ContainerList.jsx +3 -3
- package/src/components/ContainerRow.jsx +19 -16
- package/src/components/ContainerSection.jsx +3 -3
- package/src/components/Footer.jsx +2 -2
- package/src/components/Header.jsx +7 -7
- package/src/components/LogViewer.jsx +5 -3
- package/src/components/MessageFeedback.jsx +2 -2
- package/src/components/PromptField.jsx +4 -4
- package/src/components/StatsBar.jsx +11 -7
- package/src/components/SuggestionPanel.jsx +53 -0
- package/src/components/UsageMenu.jsx +18 -13
- package/src/helpers/appInfo.js +7 -7
- package/src/helpers/constants.js +30 -9
- package/src/helpers/containerOptionsBuilder.js +16 -5
- package/src/helpers/dockerService/dockerService.js +1 -1
- package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
- package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
- package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
- package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
- package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
- package/src/helpers/exitWithMessage.js +13 -7
- package/src/helpers/imageNameUtils.js +32 -0
- package/src/helpers/logger.js +15 -15
- package/src/helpers/safeCall.js +2 -2
- package/src/helpers/validationHelpers.js +13 -29
- package/src/hooks/creation/useContainerActions.js +19 -12
- package/src/hooks/creation/useContainerCreation.js +150 -41
- package/src/hooks/creation/useLogsViewer.js +2 -2
- package/src/hooks/debug/useDebugLogs.js +15 -10
- package/src/hooks/navigation/useContainerSelection.js +17 -14
- package/src/hooks/useContainerCommandRouter.js +71 -58
- package/src/hooks/useContainers.js +4 -4
- package/src/hooks/useControls.js +115 -68
- package/src/hooks/useEraseConfirmation.js +3 -3
- package/src/hooks/useExitHandler.js +29 -26
- package/src/index.js +4 -4
- package/test/ContainerCreationPrompt.dom.test.js +68 -0
- package/test/SuggestionPanel.dom.test.js +53 -0
- package/test/constants.test.js +34 -0
- package/test/useContainerCreation.dom.test.js +123 -0
- package/test/useControls.dom.test.js +198 -2
package/src/hooks/useControls.js
CHANGED
|
@@ -1,17 +1,17 @@
|
|
|
1
|
-
import React from
|
|
2
|
-
import { useInput } from
|
|
3
|
-
import { useContainerActions } from
|
|
4
|
-
import { useContainerCreation } from
|
|
5
|
-
import { useLogsViewer } from
|
|
6
|
-
import { useContainerSelection } from
|
|
7
|
-
import { useDebugLogs } from
|
|
8
|
-
import { useEraseConfirmation } from
|
|
9
|
-
import { useExitHandler } from
|
|
10
|
-
import { useContainerCommandRouter } from
|
|
11
|
-
import { getLogsStream } from
|
|
12
|
-
import { createContainer as svcCreateContainer } from
|
|
13
|
-
import { buildContainerOptions } from
|
|
14
|
-
import { DB_IMAGES } from
|
|
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({
|
|
32
|
+
const options = buildContainerOptions({
|
|
33
|
+
imageName,
|
|
34
|
+
containerName,
|
|
35
|
+
portInput,
|
|
36
|
+
envInput,
|
|
37
|
+
});
|
|
33
38
|
actions.setMessage(`Creating container ${imageName}...`);
|
|
34
|
-
actions.setMessageColor(
|
|
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 =
|
|
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(
|
|
51
|
+
actions.setMessageColor('green');
|
|
43
52
|
} catch (err) {
|
|
44
53
|
actions.setMessage(`Error creating container: ${err.message}`);
|
|
45
|
-
actions.setMessageColor(
|
|
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:
|
|
71
|
+
actionLabel: 'Erasing',
|
|
63
72
|
selected: selection.selected,
|
|
64
73
|
});
|
|
65
|
-
actions.setMessageColor(
|
|
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(
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
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(
|
|
99
|
-
|
|
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(
|
|
108
|
-
actions.setMessageColor(
|
|
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(
|
|
118
|
-
|
|
133
|
+
const processCreationInput = React.useCallback(
|
|
134
|
+
(input, key) => {
|
|
135
|
+
const step = creation.step;
|
|
119
136
|
|
|
120
|
-
|
|
121
|
-
|
|
137
|
+
const removeLastChar = (setter) =>
|
|
138
|
+
setter((value) => (value || '').slice(0, -1));
|
|
139
|
+
const appendChar = (setter, current, ch) => setter((current || '') + ch);
|
|
122
140
|
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
141
|
+
if (key.escape) {
|
|
142
|
+
creation.cancelCreation();
|
|
143
|
+
setCreatingContainer(false);
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
128
146
|
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
|
|
135
|
-
if (step === 0
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
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
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
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 ===
|
|
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
|
|
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
|
|
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 ===
|
|
23
|
+
if (input === 'y' || input === 'Y') {
|
|
24
24
|
onConfirm();
|
|
25
25
|
setConfirmErase(false);
|
|
26
26
|
return;
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
if (input ===
|
|
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(
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
33
|
+
const handleExitCommand = useCallback(
|
|
34
|
+
(input) => {
|
|
35
|
+
if (input !== 'q') {
|
|
36
|
+
return false;
|
|
37
|
+
}
|
|
37
38
|
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
39
|
+
if (typeof onBeforeExit === 'function') {
|
|
40
|
+
onBeforeExit();
|
|
41
|
+
}
|
|
41
42
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
-
}
|
|
50
|
-
// Ignore clear errors to avoid blocking exit.
|
|
51
|
-
}
|
|
52
|
-
};
|
|
53
|
+
};
|
|
53
54
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
63
|
-
|
|
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
|
|
12
|
-
import { render } from
|
|
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
|
+
});
|