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.
- package/README.es.md +60 -26
- package/README.md +60 -26
- package/dist/App.js +1 -0
- package/dist/components/ContainerCreationPrompt.js +14 -0
- package/dist/components/PromptField.js +13 -6
- package/dist/components/TextField.js +54 -0
- package/dist/components/UsageMenu.js +1 -1
- package/dist/helpers/appState.js +26 -0
- package/dist/helpers/dockerService/serviceComponents/containerExec.js +211 -0
- package/dist/helpers/textEditing.js +293 -0
- package/dist/hooks/creation/useContainerCreation.js +132 -15
- package/dist/hooks/useContainerCommandRouter.js +12 -2
- package/dist/hooks/useControls.js +21 -26
- package/dist/hooks/useShellMode.js +101 -0
- package/dist/index.js +3 -1
- package/package.json +4 -1
- package/.atl/skill-registry.md +0 -30
- package/.editorconfig +0 -12
- package/.eslintrc.json +0 -30
- package/.github/ISSUE_TEMPLATE/bug_report.md +0 -30
- package/.github/ISSUE_TEMPLATE/feature_request.md +0 -19
- package/.github/PULL_REQUEST_TEMPLATE/pull_request.md +0 -14
- package/.github/workflows/ci.yml +0 -32
- package/.github/workflows/docs.yml +0 -42
- package/.prettierignore +0 -4
- package/.prettierrc +0 -8
- package/.~lock.ROADMAP.md# +0 -1
- package/CHANGELOG.md +0 -216
- package/CODE_OF_CONDUCT.md +0 -12
- package/CONTRIBUTING.md +0 -35
- package/ROADMAP.md +0 -647
- package/SECURITY.md +0 -5
- package/__mocks__/ink.cjs +0 -17
- package/_config.yml +0 -13
- package/babel.config.cjs +0 -23
- package/coverage/clover.xml +0 -575
- package/coverage/coverage-final.json +0 -17
- package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +0 -400
- package/coverage/lcov-report/ControlsHUD.jsx.html +0 -256
- package/coverage/lcov-report/base.css +0 -224
- package/coverage/lcov-report/block-navigation.js +0 -87
- package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +0 -394
- package/coverage/lcov-report/components/PromptField.jsx.html +0 -229
- package/coverage/lcov-report/components/SuggestionPanel.jsx.html +0 -268
- package/coverage/lcov-report/components/index.html +0 -146
- package/coverage/lcov-report/favicon.png +0 -0
- package/coverage/lcov-report/helpers/constants.js.html +0 -352
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +0 -244
- package/coverage/lcov-report/helpers/dockerHubService.js.html +0 -310
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +0 -931
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +0 -307
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +0 -131
- package/coverage/lcov-report/helpers/imageNameUtils.js.html +0 -181
- package/coverage/lcov-report/helpers/index.html +0 -206
- package/coverage/lcov-report/helpers/logger.js.html +0 -424
- package/coverage/lcov-report/helpers/safeCall.js.html +0 -139
- package/coverage/lcov-report/helpers/validationHelpers.js.html +0 -346
- package/coverage/lcov-report/hooks/creation/index.html +0 -146
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +0 -313
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +0 -982
- package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +0 -238
- package/coverage/lcov-report/hooks/debug/index.html +0 -116
- package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +0 -187
- package/coverage/lcov-report/hooks/index.html +0 -161
- package/coverage/lcov-report/hooks/navigation/index.html +0 -116
- package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +0 -169
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +0 -454
- package/coverage/lcov-report/hooks/useControls.js.html +0 -868
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +0 -202
- package/coverage/lcov-report/hooks/useExitHandler.js.html +0 -292
- package/coverage/lcov-report/index.html +0 -176
- package/coverage/lcov-report/prettify.css +0 -1
- package/coverage/lcov-report/prettify.js +0 -2
- package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
- package/coverage/lcov-report/sorter.js +0 -210
- package/coverage/lcov.info +0 -1163
- package/fix-imports.cjs +0 -54
- package/jest.config.cjs +0 -14
- package/src/App.jsx +0 -102
- package/src/components/ContainerCreationPrompt.jsx +0 -107
- package/src/components/ContainerList.jsx +0 -32
- package/src/components/ContainerRow.jsx +0 -84
- package/src/components/ContainerSection.jsx +0 -24
- package/src/components/ControlsHUD.jsx +0 -66
- package/src/components/Footer.jsx +0 -15
- package/src/components/Header.jsx +0 -41
- package/src/components/LogViewer.jsx +0 -36
- package/src/components/MessageFeedback.jsx +0 -25
- package/src/components/PromptField.jsx +0 -48
- package/src/components/StatsBar.jsx +0 -70
- package/src/components/SuggestionPanel.jsx +0 -61
- package/src/components/UsageMenu.jsx +0 -52
- package/src/helpers/appInfo.js +0 -23
- package/src/helpers/constants.js +0 -89
- package/src/helpers/containerOptionsBuilder.js +0 -53
- package/src/helpers/dockerHubService.js +0 -75
- package/src/helpers/dockerService/dockerService.js +0 -28
- package/src/helpers/dockerService/serviceComponents/containerActions.js +0 -282
- package/src/helpers/dockerService/serviceComponents/containerList.js +0 -74
- package/src/helpers/dockerService/serviceComponents/containerLogs.js +0 -60
- package/src/helpers/dockerService/serviceComponents/containerStats.js +0 -69
- package/src/helpers/dockerService/serviceComponents/imageUtils.js +0 -32
- package/src/helpers/exitWithMessage.js +0 -32
- package/src/helpers/imageNameUtils.js +0 -32
- package/src/helpers/logger.js +0 -112
- package/src/helpers/safeCall.js +0 -18
- package/src/helpers/validationHelpers.js +0 -87
- package/src/hooks/creation/useContainerActions.js +0 -89
- package/src/hooks/creation/useContainerCreation.js +0 -377
- package/src/hooks/creation/useLogsViewer.js +0 -51
- package/src/hooks/debug/useDebugLogs.js +0 -34
- package/src/hooks/navigation/useContainerSelection.js +0 -28
- package/src/hooks/useContainerCommandRouter.js +0 -123
- package/src/hooks/useContainerStats.js +0 -44
- package/src/hooks/useContainers.js +0 -26
- package/src/hooks/useControls.js +0 -263
- package/src/hooks/useEraseConfirmation.js +0 -39
- package/src/hooks/useExitHandler.js +0 -69
- package/src/index.js +0 -20
- package/test/ContainerCreationPrompt.dom.test.js +0 -313
- package/test/ControlsHUD.dom.test.js +0 -125
- package/test/SuggestionPanel.dom.test.js +0 -83
- package/test/constants.test.js +0 -41
- package/test/containerActions.test.js +0 -229
- package/test/containerList.test.js +0 -79
- package/test/containerOptionsBuilder.test.js +0 -36
- package/test/dockerHubService.test.js +0 -213
- package/test/jest.setup.js +0 -7
- package/test/logger.test.js +0 -58
- package/test/resolveImageTag.test.js +0 -26
- package/test/safeCall.test.js +0 -25
- package/test/useContainerCreation.dom.test.js +0 -567
- package/test/useContainerCreationHub.dom.test.js +0 -184
- package/test/useControls.dom.test.js +0 -519
- package/test/validationHelpers.test.js +0 -111
|
@@ -1,519 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* @jest-environment jsdom
|
|
3
|
-
*/
|
|
4
|
-
import React, { useEffect } from 'react';
|
|
5
|
-
import { render, act } from '@testing-library/react';
|
|
6
|
-
import { jest } from '@jest/globals';
|
|
7
|
-
|
|
8
|
-
// Mock ink — capture the useInput handler so tests can simulate keypresses.
|
|
9
|
-
let _inputHandler = null;
|
|
10
|
-
const triggerInput = (input, key = {}) => {
|
|
11
|
-
if (_inputHandler) _inputHandler(input, key);
|
|
12
|
-
};
|
|
13
|
-
await jest.unstable_mockModule('ink', () => ({
|
|
14
|
-
Box: ({ children, ...props }) => React.createElement('div', props, children),
|
|
15
|
-
Text: ({ children }) => React.createElement('span', null, children),
|
|
16
|
-
useInput: (fn) => { _inputHandler = fn; },
|
|
17
|
-
useApp: () => ({ exit: () => {} }),
|
|
18
|
-
}));
|
|
19
|
-
|
|
20
|
-
// Mock containerActions — expose all named exports so dependents don't break
|
|
21
|
-
const mockSvcCreateContainer = jest.fn();
|
|
22
|
-
await jest.unstable_mockModule(
|
|
23
|
-
'../src/helpers/dockerService/serviceComponents/containerActions.js',
|
|
24
|
-
() => ({
|
|
25
|
-
createContainer: mockSvcCreateContainer,
|
|
26
|
-
removeContainer: jest.fn().mockResolvedValue(undefined),
|
|
27
|
-
startContainer: jest.fn().mockResolvedValue(undefined),
|
|
28
|
-
stopContainer: jest.fn().mockResolvedValue(undefined),
|
|
29
|
-
restartContainer: jest.fn().mockResolvedValue(undefined),
|
|
30
|
-
})
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
// Mock getLogsStream to avoid side effects
|
|
34
|
-
await jest.unstable_mockModule(
|
|
35
|
-
'../src/helpers/dockerService/serviceComponents/containerLogs.js',
|
|
36
|
-
() => ({ getLogsStream: () => {} })
|
|
37
|
-
);
|
|
38
|
-
|
|
39
|
-
const { useControls } = await import('../src/hooks/useControls.js');
|
|
40
|
-
|
|
41
|
-
function HookTester({ containers, expose, overrides }) {
|
|
42
|
-
const hook = useControls(containers, overrides);
|
|
43
|
-
useEffect(() => {
|
|
44
|
-
if (expose) expose.current = hook;
|
|
45
|
-
});
|
|
46
|
-
return null;
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
// Helper: advance creation wizard to the final step and trigger onCreate
|
|
50
|
-
async function completeCreationWizard(expose, imageName = 'nginx') {
|
|
51
|
-
act(() => { expose.current.creation.setImageName(imageName); });
|
|
52
|
-
act(() => { expose.current.creation.nextStep(); }); // step 0 → 1
|
|
53
|
-
act(() => { expose.current.creation.nextStep(); }); // step 1 → 2
|
|
54
|
-
act(() => { expose.current.creation.nextStep(); }); // step 2 → 3
|
|
55
|
-
// step 3: call nextStep to trigger onCreate (async)
|
|
56
|
-
await act(async () => { expose.current.creation.nextStep(); });
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
describe('useControls (FR6 — port mapping in success message)', () => {
|
|
60
|
-
beforeEach(() => {
|
|
61
|
-
mockSvcCreateContainer.mockReset();
|
|
62
|
-
});
|
|
63
|
-
|
|
64
|
-
test('success message includes port mapping after container creation', async () => {
|
|
65
|
-
const ports = [
|
|
66
|
-
{ containerPort: '3306', hostPort: '3306', protocol: 'tcp', source: 'auto' },
|
|
67
|
-
];
|
|
68
|
-
mockSvcCreateContainer.mockResolvedValue({ id: 'cid-abc', ports });
|
|
69
|
-
|
|
70
|
-
const expose = { current: null };
|
|
71
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
72
|
-
|
|
73
|
-
await completeCreationWizard(expose, 'nginx');
|
|
74
|
-
|
|
75
|
-
expect(expose.current.actions.message).toBe(
|
|
76
|
-
'Created container cid-abc | Ports: 3306→3306/tcp'
|
|
77
|
-
);
|
|
78
|
-
expect(expose.current.actions.messageColor).toBe('green');
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
test('success message has no port section when ports array is empty', async () => {
|
|
82
|
-
mockSvcCreateContainer.mockResolvedValue({ id: 'cid-xyz', ports: [] });
|
|
83
|
-
|
|
84
|
-
const expose = { current: null };
|
|
85
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
86
|
-
|
|
87
|
-
await completeCreationWizard(expose, 'alpine');
|
|
88
|
-
|
|
89
|
-
expect(expose.current.actions.message).toBe('Created container cid-xyz');
|
|
90
|
-
expect(expose.current.actions.messageColor).toBe('green');
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
test('success message includes multiple port mappings', async () => {
|
|
94
|
-
const ports = [
|
|
95
|
-
{ containerPort: '80', hostPort: '8080', protocol: 'tcp', source: 'user' },
|
|
96
|
-
{ containerPort: '443', hostPort: '8443', protocol: 'tcp', source: 'user' },
|
|
97
|
-
];
|
|
98
|
-
mockSvcCreateContainer.mockResolvedValue({ id: 'cid-multi', ports });
|
|
99
|
-
|
|
100
|
-
const expose = { current: null };
|
|
101
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
102
|
-
|
|
103
|
-
await completeCreationWizard(expose, 'nginx');
|
|
104
|
-
|
|
105
|
-
expect(expose.current.actions.message).toBe(
|
|
106
|
-
'Created container cid-multi | Ports: 8080→80/tcp, 8443→443/tcp'
|
|
107
|
-
);
|
|
108
|
-
});
|
|
109
|
-
|
|
110
|
-
test('error message is shown when svcCreateContainer rejects', async () => {
|
|
111
|
-
mockSvcCreateContainer.mockRejectedValue(new Error('pull access denied'));
|
|
112
|
-
|
|
113
|
-
const expose = { current: null };
|
|
114
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
115
|
-
|
|
116
|
-
await completeCreationWizard(expose, 'badimage');
|
|
117
|
-
|
|
118
|
-
expect(expose.current.actions.message).toBe(
|
|
119
|
-
'Error creating container: pull access denied'
|
|
120
|
-
);
|
|
121
|
-
expect(expose.current.actions.messageColor).toBe('red');
|
|
122
|
-
});
|
|
123
|
-
});
|
|
124
|
-
|
|
125
|
-
describe('useControls — step 0 keyboard routing integration', () => {
|
|
126
|
-
beforeEach(() => {
|
|
127
|
-
mockSvcCreateContainer.mockReset();
|
|
128
|
-
});
|
|
129
|
-
|
|
130
|
-
test('creation.updateImageInput() filters suggestions (routing readiness check)', () => {
|
|
131
|
-
const expose = { current: null };
|
|
132
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
133
|
-
|
|
134
|
-
// updateImageInput should be exposed through creation hook
|
|
135
|
-
expect(typeof expose.current.creation.updateImageInput).toBe('function');
|
|
136
|
-
|
|
137
|
-
act(() => { expose.current.creation.updateImageInput('ng'); });
|
|
138
|
-
|
|
139
|
-
expect(expose.current.creation.suggestions).toContain('nginx');
|
|
140
|
-
expect(expose.current.creation.selectedSuggestionIndex).toBe(-1);
|
|
141
|
-
});
|
|
142
|
-
|
|
143
|
-
test('moveSuggestionSelection(1) then applyFocusedSuggestion() fills imageName', () => {
|
|
144
|
-
const expose = { current: null };
|
|
145
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
146
|
-
|
|
147
|
-
act(() => { expose.current.creation.updateImageInput('ng'); });
|
|
148
|
-
act(() => { expose.current.creation.moveSuggestionSelection(1); });
|
|
149
|
-
act(() => { expose.current.creation.applyFocusedSuggestion(); });
|
|
150
|
-
|
|
151
|
-
expect(expose.current.creation.imageName).toBe('nginx:1.27-alpine');
|
|
152
|
-
expect(expose.current.creation.step).toBe(0);
|
|
153
|
-
expect(expose.current.creation.suggestions).toHaveLength(0);
|
|
154
|
-
});
|
|
155
|
-
|
|
156
|
-
test('applyFocusedSuggestion() does not advance step (Enter with focus uses suggestion not nextStep)', () => {
|
|
157
|
-
const expose = { current: null };
|
|
158
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
159
|
-
|
|
160
|
-
act(() => { expose.current.creation.updateImageInput('ng'); });
|
|
161
|
-
act(() => { expose.current.creation.moveSuggestionSelection(1); });
|
|
162
|
-
act(() => { expose.current.creation.applyFocusedSuggestion(); });
|
|
163
|
-
|
|
164
|
-
// Step must remain 0 — autocomplete applied, wizard did not advance
|
|
165
|
-
expect(expose.current.creation.step).toBe(0);
|
|
166
|
-
});
|
|
167
|
-
|
|
168
|
-
test('nextStep() with imageName set advances to step 1 (normal flow without suggestions)', () => {
|
|
169
|
-
const expose = { current: null };
|
|
170
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
171
|
-
|
|
172
|
-
act(() => { expose.current.creation.setImageName('nginx'); });
|
|
173
|
-
act(() => { expose.current.creation.nextStep(); });
|
|
174
|
-
|
|
175
|
-
expect(expose.current.creation.step).toBe(1);
|
|
176
|
-
});
|
|
177
|
-
});
|
|
178
|
-
|
|
179
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
180
|
-
// FR4 / FR5 / FR6 — Real keyboard routing via processCreationInput
|
|
181
|
-
// These tests simulate actual keypresses through the useInput callback.
|
|
182
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
183
|
-
describe('useControls — FR4/FR5/FR6 keyboard routing via processCreationInput', () => {
|
|
184
|
-
beforeEach(() => {
|
|
185
|
-
_inputHandler = null;
|
|
186
|
-
mockSvcCreateContainer.mockReset();
|
|
187
|
-
});
|
|
188
|
-
|
|
189
|
-
/** Helper: render hook and enter creation mode by simulating 'c' keypress. */
|
|
190
|
-
function renderAndStartCreation(containers = []) {
|
|
191
|
-
const expose = { current: null };
|
|
192
|
-
render(<HookTester containers={containers} expose={expose} />);
|
|
193
|
-
// Trigger creation mode ('c' command in handleDockerCommands)
|
|
194
|
-
act(() => { triggerInput('c', {}); });
|
|
195
|
-
return expose;
|
|
196
|
-
}
|
|
197
|
-
|
|
198
|
-
// ── FR4: ↑/↓ only intercepted on step 0 with suggestions ────────────────
|
|
199
|
-
|
|
200
|
-
test('FR4 — ↓ arrow on step 0 with suggestions moves selection down', () => {
|
|
201
|
-
const expose = renderAndStartCreation();
|
|
202
|
-
|
|
203
|
-
// Type 'n' to get suggestions (nginx, node, etc.)
|
|
204
|
-
act(() => { triggerInput('n', {}); });
|
|
205
|
-
const suggestionsAfterType = expose.current.creation.suggestions;
|
|
206
|
-
expect(suggestionsAfterType.length).toBeGreaterThan(0);
|
|
207
|
-
|
|
208
|
-
act(() => { triggerInput('', { downArrow: true }); });
|
|
209
|
-
expect(expose.current.creation.selectedSuggestionIndex).toBe(0);
|
|
210
|
-
});
|
|
211
|
-
|
|
212
|
-
test('FR4 — ↑ arrow on step 0 with suggestions moves selection up', () => {
|
|
213
|
-
const expose = renderAndStartCreation();
|
|
214
|
-
|
|
215
|
-
act(() => { triggerInput('n', {}); });
|
|
216
|
-
// Move down twice, then up once → index should be 0
|
|
217
|
-
act(() => { triggerInput('', { downArrow: true }); });
|
|
218
|
-
act(() => { triggerInput('', { downArrow: true }); });
|
|
219
|
-
act(() => { triggerInput('', { upArrow: true }); });
|
|
220
|
-
expect(expose.current.creation.selectedSuggestionIndex).toBe(0);
|
|
221
|
-
});
|
|
222
|
-
|
|
223
|
-
test('FR4 — ↑/↓ are ignored on step 0 when suggestions list is empty', () => {
|
|
224
|
-
const expose = renderAndStartCreation();
|
|
225
|
-
|
|
226
|
-
// Type something unlikely to match → no suggestions
|
|
227
|
-
act(() => { triggerInput('x', {}); });
|
|
228
|
-
act(() => { triggerInput('x', {}); });
|
|
229
|
-
act(() => { triggerInput('x', {}); });
|
|
230
|
-
expect(expose.current.creation.suggestions).toHaveLength(0);
|
|
231
|
-
|
|
232
|
-
act(() => { triggerInput('', { downArrow: true }); });
|
|
233
|
-
// selectedSuggestionIndex stays at -1 (ignored)
|
|
234
|
-
expect(expose.current.creation.selectedSuggestionIndex).toBe(-1);
|
|
235
|
-
});
|
|
236
|
-
|
|
237
|
-
test('FR4 — ↑/↓ are ignored when step > 0 (no suggestions navigation on other steps)', () => {
|
|
238
|
-
const expose = renderAndStartCreation();
|
|
239
|
-
|
|
240
|
-
// Advance to step 1 by typing an image name and pressing Enter
|
|
241
|
-
act(() => { triggerInput('n', {}); }); // type → imageName='n'
|
|
242
|
-
act(() => { triggerInput('g', {}); }); // type → imageName='ng', suggestions appear
|
|
243
|
-
// Clear suggestions by pressing Escape then re-enter (shortcut: directly set step)
|
|
244
|
-
// Instead, clear imageName and press Enter with no suggestions
|
|
245
|
-
// Set imageName via direct API, advance step
|
|
246
|
-
act(() => { expose.current.creation.setImageName('nginx'); });
|
|
247
|
-
// Simulate Enter (\r) with no focused suggestion → should advance step
|
|
248
|
-
act(() => { triggerInput('\r', {}); });
|
|
249
|
-
expect(expose.current.creation.step).toBe(1);
|
|
250
|
-
|
|
251
|
-
const prevSelection = expose.current.creation.selectedSuggestionIndex;
|
|
252
|
-
act(() => { triggerInput('', { downArrow: true }); });
|
|
253
|
-
// selection is unchanged — arrow ignored on step 1
|
|
254
|
-
expect(expose.current.creation.selectedSuggestionIndex).toBe(prevSelection);
|
|
255
|
-
});
|
|
256
|
-
|
|
257
|
-
// ── FR5: Enter with focused suggestion → apply, NOT nextStep ─────────────
|
|
258
|
-
|
|
259
|
-
test('FR5 — Enter with selectedSuggestionIndex >= 0 applies suggestion and stays on step 0', () => {
|
|
260
|
-
const expose = renderAndStartCreation();
|
|
261
|
-
|
|
262
|
-
act(() => { triggerInput('n', {}); }); // type to get suggestions
|
|
263
|
-
expect(expose.current.creation.suggestions.length).toBeGreaterThan(0);
|
|
264
|
-
|
|
265
|
-
act(() => { triggerInput('', { downArrow: true }); }); // select first suggestion
|
|
266
|
-
expect(expose.current.creation.selectedSuggestionIndex).toBe(0);
|
|
267
|
-
|
|
268
|
-
// Press Enter → should apply suggestion, NOT advance step
|
|
269
|
-
act(() => { triggerInput('\r', {}); });
|
|
270
|
-
|
|
271
|
-
expect(expose.current.creation.step).toBe(0);
|
|
272
|
-
expect(expose.current.creation.imageName).not.toBe('');
|
|
273
|
-
expect(expose.current.creation.imageName).not.toBe('n');
|
|
274
|
-
});
|
|
275
|
-
|
|
276
|
-
test('FR5 — Enter with focused suggestion does NOT call nextStep (step stays 0)', () => {
|
|
277
|
-
const expose = renderAndStartCreation();
|
|
278
|
-
|
|
279
|
-
act(() => { triggerInput('n', {}); });
|
|
280
|
-
act(() => { triggerInput('', { downArrow: true }); }); // focus first
|
|
281
|
-
|
|
282
|
-
act(() => { triggerInput('\r', {}); }); // Enter with focus
|
|
283
|
-
|
|
284
|
-
// FR5: step must be 0 (nextStep was NOT called)
|
|
285
|
-
expect(expose.current.creation.step).toBe(0);
|
|
286
|
-
});
|
|
287
|
-
|
|
288
|
-
// ── FR6: Enter without focused suggestion → nextStep ─────────────────────
|
|
289
|
-
|
|
290
|
-
test('FR6 — Enter with selectedSuggestionIndex === -1 advances step', () => {
|
|
291
|
-
const expose = renderAndStartCreation();
|
|
292
|
-
|
|
293
|
-
act(() => { expose.current.creation.setImageName('nginx'); });
|
|
294
|
-
// No suggestion focused (index -1)
|
|
295
|
-
expect(expose.current.creation.selectedSuggestionIndex).toBe(-1);
|
|
296
|
-
|
|
297
|
-
act(() => { triggerInput('\r', {}); }); // Enter without focus
|
|
298
|
-
expect(expose.current.creation.step).toBe(1);
|
|
299
|
-
});
|
|
300
|
-
|
|
301
|
-
test('FR6 — Enter without focus does NOT apply suggestion (imageName unchanged)', () => {
|
|
302
|
-
const expose = renderAndStartCreation();
|
|
303
|
-
|
|
304
|
-
act(() => { expose.current.creation.setImageName('nginx'); });
|
|
305
|
-
|
|
306
|
-
act(() => { triggerInput('\r', {}); }); // advance to step 1
|
|
307
|
-
|
|
308
|
-
// nextStep resolves the tag — nginx becomes nginx:1.27-alpine; step advances to 1
|
|
309
|
-
// applyFocusedSuggestion was NOT called (no suggestion was focused)
|
|
310
|
-
expect(expose.current.creation.imageName).toBe('nginx:1.27-alpine');
|
|
311
|
-
expect(expose.current.creation.step).toBe(1);
|
|
312
|
-
});
|
|
313
|
-
});
|
|
314
|
-
|
|
315
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
316
|
-
// Tab on step 3 → insertNextSuggestedEnv
|
|
317
|
-
// ─────────────────────────────────────────────────────────────────────────────
|
|
318
|
-
describe('useControls — Tab on step 3 calls insertNextSuggestedEnv', () => {
|
|
319
|
-
beforeEach(() => {
|
|
320
|
-
_inputHandler = null;
|
|
321
|
-
mockSvcCreateContainer.mockReset();
|
|
322
|
-
});
|
|
323
|
-
|
|
324
|
-
test('Tab on step 3 calls insertNextSuggestedEnv()', () => {
|
|
325
|
-
const mockInsertNextSuggestedEnv = jest.fn();
|
|
326
|
-
const expose = { current: null };
|
|
327
|
-
render(
|
|
328
|
-
<HookTester
|
|
329
|
-
containers={[]}
|
|
330
|
-
expose={expose}
|
|
331
|
-
overrides={{ insertNextSuggestedEnv: mockInsertNextSuggestedEnv }}
|
|
332
|
-
/>
|
|
333
|
-
);
|
|
334
|
-
// Enter creation mode
|
|
335
|
-
act(() => { triggerInput('c', {}); });
|
|
336
|
-
// Advance to step 3
|
|
337
|
-
act(() => { expose.current.creation.setImageName('postgres'); });
|
|
338
|
-
act(() => { triggerInput('\r', {}); }); // step 0 → 1
|
|
339
|
-
act(() => { triggerInput('\r', {}); }); // step 1 → 2
|
|
340
|
-
act(() => { triggerInput('\r', {}); }); // step 2 → 3
|
|
341
|
-
expect(expose.current.creation.step).toBe(3);
|
|
342
|
-
|
|
343
|
-
act(() => { triggerInput('', { tab: true }); });
|
|
344
|
-
|
|
345
|
-
expect(mockInsertNextSuggestedEnv).toHaveBeenCalledTimes(1);
|
|
346
|
-
});
|
|
347
|
-
|
|
348
|
-
test('Tab on step 3 when insertNextSuggestedEnv is undefined → no-op (safe guard)', () => {
|
|
349
|
-
const expose = { current: null };
|
|
350
|
-
render(
|
|
351
|
-
<HookTester
|
|
352
|
-
containers={[]}
|
|
353
|
-
expose={expose}
|
|
354
|
-
overrides={{ insertNextSuggestedEnv: undefined }}
|
|
355
|
-
/>
|
|
356
|
-
);
|
|
357
|
-
act(() => { triggerInput('c', {}); });
|
|
358
|
-
act(() => { expose.current.creation.setImageName('nginx'); });
|
|
359
|
-
act(() => { triggerInput('\r', {}); });
|
|
360
|
-
act(() => { triggerInput('\r', {}); });
|
|
361
|
-
act(() => { triggerInput('\r', {}); });
|
|
362
|
-
|
|
363
|
-
// Should not throw
|
|
364
|
-
expect(() => {
|
|
365
|
-
act(() => { triggerInput('', { tab: true }); });
|
|
366
|
-
}).not.toThrow();
|
|
367
|
-
});
|
|
368
|
-
});
|
|
369
|
-
describe('useControls — FR7 Tab triggers hub search on step 0', () => {
|
|
370
|
-
beforeEach(() => {
|
|
371
|
-
_inputHandler = null;
|
|
372
|
-
mockSvcCreateContainer.mockReset();
|
|
373
|
-
});
|
|
374
|
-
|
|
375
|
-
/** Helper: render in creation mode with a triggerHubSearch mock override */
|
|
376
|
-
function renderCreationWithSearch({ isSearchingHub = false, imageName: initialImageName = '' } = {}) {
|
|
377
|
-
const mockTriggerHubSearch = jest.fn();
|
|
378
|
-
const expose = { current: null };
|
|
379
|
-
|
|
380
|
-
render(
|
|
381
|
-
<HookTester
|
|
382
|
-
containers={[]}
|
|
383
|
-
expose={expose}
|
|
384
|
-
overrides={{ triggerHubSearch: mockTriggerHubSearch, isSearchingHub }}
|
|
385
|
-
/>
|
|
386
|
-
);
|
|
387
|
-
// Enter creation mode
|
|
388
|
-
act(() => { triggerInput('c', {}); });
|
|
389
|
-
// Set imageName if provided
|
|
390
|
-
if (initialImageName) {
|
|
391
|
-
act(() => { expose.current.creation.setImageName(initialImageName); });
|
|
392
|
-
}
|
|
393
|
-
return { expose, mockTriggerHubSearch };
|
|
394
|
-
}
|
|
395
|
-
|
|
396
|
-
test('FR7 — Tab on step 0 calls triggerHubSearch()', () => {
|
|
397
|
-
const { mockTriggerHubSearch } = renderCreationWithSearch({ imageName: 'nginx' });
|
|
398
|
-
|
|
399
|
-
act(() => { triggerInput('', { tab: true }); });
|
|
400
|
-
|
|
401
|
-
expect(mockTriggerHubSearch).toHaveBeenCalledTimes(1);
|
|
402
|
-
});
|
|
403
|
-
|
|
404
|
-
test('FR7 — Tab on step 0 when isSearchingHub=true does NOT call triggerHubSearch (guard)', () => {
|
|
405
|
-
const { mockTriggerHubSearch } = renderCreationWithSearch({
|
|
406
|
-
isSearchingHub: true,
|
|
407
|
-
imageName: 'nginx',
|
|
408
|
-
});
|
|
409
|
-
|
|
410
|
-
act(() => { triggerInput('', { tab: true }); });
|
|
411
|
-
|
|
412
|
-
expect(mockTriggerHubSearch).not.toHaveBeenCalled();
|
|
413
|
-
});
|
|
414
|
-
|
|
415
|
-
test('FR7 — Tab on step 0 when imageName is empty string does NOT call triggerHubSearch (guard)', () => {
|
|
416
|
-
const { mockTriggerHubSearch } = renderCreationWithSearch({ imageName: '' });
|
|
417
|
-
|
|
418
|
-
act(() => { triggerInput('', { tab: true }); });
|
|
419
|
-
|
|
420
|
-
expect(mockTriggerHubSearch).not.toHaveBeenCalled();
|
|
421
|
-
});
|
|
422
|
-
|
|
423
|
-
test('FR7 — Tab on step > 0 does NOT call triggerHubSearch (only active on step 0)', () => {
|
|
424
|
-
const { expose, mockTriggerHubSearch } = renderCreationWithSearch({ imageName: 'nginx' });
|
|
425
|
-
|
|
426
|
-
// Advance to step 1
|
|
427
|
-
act(() => { triggerInput('\r', {}); });
|
|
428
|
-
expect(expose.current.creation.step).toBe(1);
|
|
429
|
-
|
|
430
|
-
act(() => { triggerInput('', { tab: true }); });
|
|
431
|
-
|
|
432
|
-
expect(mockTriggerHubSearch).not.toHaveBeenCalled();
|
|
433
|
-
});
|
|
434
|
-
});
|
|
435
|
-
|
|
436
|
-
describe('useControls — success/error message clears after 4000ms', () => {
|
|
437
|
-
|
|
438
|
-
test('success message from container creation is scheduled to clear (setTimedMessage called)', async () => {
|
|
439
|
-
const timeoutSpy = jest.spyOn(global, 'setTimeout');
|
|
440
|
-
mockSvcCreateContainer.mockResolvedValue({ id: 'cid-timer', ports: [] });
|
|
441
|
-
|
|
442
|
-
const expose = { current: null };
|
|
443
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
444
|
-
|
|
445
|
-
await act(async () => {
|
|
446
|
-
await completeCreationWizard(expose, 'nginx');
|
|
447
|
-
});
|
|
448
|
-
|
|
449
|
-
// setTimedMessage must have scheduled a timeout to clear the message
|
|
450
|
-
const clearCallArgs = timeoutSpy.mock.calls.find(
|
|
451
|
-
([fn, delay]) => delay === 4000
|
|
452
|
-
);
|
|
453
|
-
expect(clearCallArgs).toBeDefined();
|
|
454
|
-
timeoutSpy.mockRestore();
|
|
455
|
-
});
|
|
456
|
-
|
|
457
|
-
test('error message from container creation is scheduled to clear (setTimedMessage called)', async () => {
|
|
458
|
-
const timeoutSpy = jest.spyOn(global, 'setTimeout');
|
|
459
|
-
mockSvcCreateContainer.mockRejectedValue(new Error('not found'));
|
|
460
|
-
|
|
461
|
-
const expose = { current: null };
|
|
462
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
463
|
-
|
|
464
|
-
await act(async () => {
|
|
465
|
-
await completeCreationWizard(expose, 'badimage');
|
|
466
|
-
});
|
|
467
|
-
|
|
468
|
-
const clearCallArgs = timeoutSpy.mock.calls.find(
|
|
469
|
-
([fn, delay]) => delay === 4000
|
|
470
|
-
);
|
|
471
|
-
expect(clearCallArgs).toBeDefined();
|
|
472
|
-
timeoutSpy.mockRestore();
|
|
473
|
-
});
|
|
474
|
-
});
|
|
475
|
-
|
|
476
|
-
describe('useControls — pressing C calls resetCreation BEFORE onStartCreate', () => {
|
|
477
|
-
beforeEach(() => {
|
|
478
|
-
_inputHandler = null;
|
|
479
|
-
mockSvcCreateContainer.mockReset();
|
|
480
|
-
});
|
|
481
|
-
|
|
482
|
-
test('pressing C resets creation state before entering creation mode', () => {
|
|
483
|
-
const callOrder = [];
|
|
484
|
-
const expose = { current: null };
|
|
485
|
-
|
|
486
|
-
// We render with no overrides, but we can spy on the internal calls
|
|
487
|
-
// by checking that imageName is reset (empty) when creatingContainer becomes true.
|
|
488
|
-
// Strategy: set imageName to something, then press C, verify state is reset in same render.
|
|
489
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
490
|
-
|
|
491
|
-
// Give the hook an existing state to verify it gets reset
|
|
492
|
-
act(() => { triggerInput('c', {}); }); // enter creation mode
|
|
493
|
-
act(() => { expose.current.creation.setImageName('dirty-state'); });
|
|
494
|
-
|
|
495
|
-
// Exit creation mode and re-enter via C to test reset order
|
|
496
|
-
act(() => {
|
|
497
|
-
// Press escape to cancel creation
|
|
498
|
-
if (_inputHandler) _inputHandler('', { escape: true });
|
|
499
|
-
});
|
|
500
|
-
|
|
501
|
-
// Now press C again — this should reset THEN show creation mode
|
|
502
|
-
act(() => { triggerInput('c', {}); });
|
|
503
|
-
|
|
504
|
-
// After pressing C: creatingContainer should be true AND imageName should be reset to ''
|
|
505
|
-
expect(expose.current.creatingContainer).toBe(true);
|
|
506
|
-
// imageName is '' (from resetCreation), NOT 'dirty-state'
|
|
507
|
-
expect(expose.current.creation.imageName).toBe('');
|
|
508
|
-
});
|
|
509
|
-
|
|
510
|
-
test('after pressing C, creation.message is set to the wizard prompt (resetCreation ran)', () => {
|
|
511
|
-
const expose = { current: null };
|
|
512
|
-
render(<HookTester containers={[]} expose={expose} />);
|
|
513
|
-
|
|
514
|
-
act(() => { triggerInput('c', {}); });
|
|
515
|
-
|
|
516
|
-
expect(expose.current.creatingContainer).toBe(true);
|
|
517
|
-
expect(expose.current.creation.message).toBe('Insert the name of the image to create: ');
|
|
518
|
-
});
|
|
519
|
-
});
|
|
@@ -1,111 +0,0 @@
|
|
|
1
|
-
let validatePorts;
|
|
2
|
-
let validateEnvVars;
|
|
3
|
-
let IMAGE_PROFILES;
|
|
4
|
-
|
|
5
|
-
beforeAll(async () => {
|
|
6
|
-
const mod = await import('../src/helpers/validationHelpers.js');
|
|
7
|
-
validatePorts = mod.validatePorts;
|
|
8
|
-
validateEnvVars = mod.validateEnvVars;
|
|
9
|
-
const constants = await import('../src/helpers/constants.js');
|
|
10
|
-
IMAGE_PROFILES = constants.IMAGE_PROFILES;
|
|
11
|
-
});
|
|
12
|
-
|
|
13
|
-
describe('validatePorts', () => {
|
|
14
|
-
test('valid single port mapping', () => {
|
|
15
|
-
expect(validatePorts('8080:80')).toBe(true);
|
|
16
|
-
});
|
|
17
|
-
|
|
18
|
-
test('valid multiple port mappings', () => {
|
|
19
|
-
expect(validatePorts('8080:80,443:443')).toBe(true);
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
test('invalid mapping missing host', () => {
|
|
23
|
-
expect(validatePorts(':80')).toBe(false);
|
|
24
|
-
});
|
|
25
|
-
|
|
26
|
-
test('invalid mapping non-numeric', () => {
|
|
27
|
-
expect(validatePorts('eighty:80')).toBe(false);
|
|
28
|
-
});
|
|
29
|
-
|
|
30
|
-
test('empty input is valid (ports are optional)', () => {
|
|
31
|
-
expect(validatePorts('')).toBe(true);
|
|
32
|
-
});
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
describe('validateEnvVars', () => {
|
|
36
|
-
test('empty input is valid', () => {
|
|
37
|
-
expect(validateEnvVars('')).toBe(true);
|
|
38
|
-
});
|
|
39
|
-
|
|
40
|
-
test('valid single env var', () => {
|
|
41
|
-
expect(validateEnvVars('NODE_ENV=production')).toBe(true);
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
test('valid multiple env vars', () => {
|
|
45
|
-
expect(validateEnvVars('NODE_ENV=production,PORT=3000')).toBe(true);
|
|
46
|
-
});
|
|
47
|
-
|
|
48
|
-
test('valid env var with underscores', () => {
|
|
49
|
-
expect(validateEnvVars('MY_VAR_NAME=value')).toBe(true);
|
|
50
|
-
});
|
|
51
|
-
|
|
52
|
-
test('invalid env var without equals sign', () => {
|
|
53
|
-
expect(validateEnvVars('NOEQUALS')).toBe(false);
|
|
54
|
-
});
|
|
55
|
-
|
|
56
|
-
test('invalid env var with invalid name', () => {
|
|
57
|
-
expect(validateEnvVars('123INVALID=value')).toBe(false);
|
|
58
|
-
});
|
|
59
|
-
|
|
60
|
-
test('invalid env var with special characters in name', () => {
|
|
61
|
-
expect(validateEnvVars('MY-VAR=value')).toBe(false);
|
|
62
|
-
});
|
|
63
|
-
});
|
|
64
|
-
|
|
65
|
-
describe('validateEnvVars — contextual validation with IMAGE_PROFILES', () => {
|
|
66
|
-
test('mysql:8 — missing MYSQL_ROOT_PASSWORD is invalid', () => {
|
|
67
|
-
const result = validateEnvVars('FOO=bar', 'mysql:8', IMAGE_PROFILES);
|
|
68
|
-
expect(result.valid).toBe(false);
|
|
69
|
-
expect(result.errors.some(e => e.includes('MYSQL_ROOT_PASSWORD'))).toBe(true);
|
|
70
|
-
});
|
|
71
|
-
|
|
72
|
-
test('mysql:8 — with MYSQL_ROOT_PASSWORD is valid', () => {
|
|
73
|
-
const result = validateEnvVars('MYSQL_ROOT_PASSWORD=secret', 'mysql:8', IMAGE_PROFILES);
|
|
74
|
-
expect(result.valid).toBe(true);
|
|
75
|
-
expect(result.errors).toHaveLength(0);
|
|
76
|
-
});
|
|
77
|
-
|
|
78
|
-
test('docker.io/library/postgres:16 — missing POSTGRES_PASSWORD is invalid', () => {
|
|
79
|
-
const result = validateEnvVars('', 'docker.io/library/postgres:16', IMAGE_PROFILES);
|
|
80
|
-
expect(result.valid).toBe(false);
|
|
81
|
-
expect(result.errors.some(e => e.includes('POSTGRES_PASSWORD'))).toBe(true);
|
|
82
|
-
});
|
|
83
|
-
|
|
84
|
-
test('docker.io/library/postgres:16 — with POSTGRES_PASSWORD is valid', () => {
|
|
85
|
-
const result = validateEnvVars('POSTGRES_PASSWORD=pass', 'docker.io/library/postgres:16', IMAGE_PROFILES);
|
|
86
|
-
expect(result.valid).toBe(true);
|
|
87
|
-
});
|
|
88
|
-
|
|
89
|
-
test('TOKEN=a=b is preserved (split on first = only)', () => {
|
|
90
|
-
const result = validateEnvVars('TOKEN=a=b', 'nginx', IMAGE_PROFILES);
|
|
91
|
-
expect(result.valid).toBe(true);
|
|
92
|
-
expect(result.parsedEnv['TOKEN']).toBe('a=b');
|
|
93
|
-
});
|
|
94
|
-
|
|
95
|
-
test('image not in profiles — only syntactic validation applies', () => {
|
|
96
|
-
const result = validateEnvVars('FOO=bar', 'nginx', IMAGE_PROFILES);
|
|
97
|
-
expect(result.valid).toBe(true);
|
|
98
|
-
expect(result.parsedEnv['FOO']).toBe('bar');
|
|
99
|
-
});
|
|
100
|
-
|
|
101
|
-
test('empty input for image not in profiles — valid', () => {
|
|
102
|
-
const result = validateEnvVars('', 'nginx', IMAGE_PROFILES);
|
|
103
|
-
expect(result.valid).toBe(true);
|
|
104
|
-
expect(result.parsedEnv).toEqual({});
|
|
105
|
-
});
|
|
106
|
-
|
|
107
|
-
test('syntactically invalid var is still rejected', () => {
|
|
108
|
-
const result = validateEnvVars('INVALID-VAR=x', 'nginx', IMAGE_PROFILES);
|
|
109
|
-
expect(result.valid).toBe(false);
|
|
110
|
-
});
|
|
111
|
-
});
|