cdd-cli 3.1.9 → 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 (130) 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 +46 -0
  7. package/SECURITY.md +5 -0
  8. package/__mocks__/ink.cjs +17 -0
  9. package/coverage/clover.xml +694 -0
  10. package/coverage/coverage-final.json +21 -0
  11. package/coverage/lcov-report/base.css +224 -0
  12. package/coverage/lcov-report/block-navigation.js +87 -0
  13. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +376 -0
  14. package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
  15. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +244 -0
  16. package/coverage/lcov-report/components/index.html +146 -0
  17. package/coverage/lcov-report/favicon.png +0 -0
  18. package/coverage/lcov-report/helpers/constants.js.html +298 -0
  19. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +244 -0
  20. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +931 -0
  21. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +307 -0
  22. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +131 -0
  23. package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
  24. package/coverage/lcov-report/helpers/index.html +191 -0
  25. package/coverage/lcov-report/helpers/logger.js.html +424 -0
  26. package/coverage/lcov-report/helpers/safeCall.js.html +139 -0
  27. package/coverage/lcov-report/helpers/validationHelpers.js.html +346 -0
  28. package/coverage/lcov-report/hooks/creation/index.html +146 -0
  29. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +313 -0
  30. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +805 -0
  31. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +238 -0
  32. package/coverage/lcov-report/hooks/debug/index.html +116 -0
  33. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +187 -0
  34. package/coverage/lcov-report/hooks/index.html +161 -0
  35. package/coverage/lcov-report/hooks/navigation/index.html +116 -0
  36. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +169 -0
  37. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +454 -0
  38. package/coverage/lcov-report/hooks/useControls.js.html +826 -0
  39. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +202 -0
  40. package/coverage/lcov-report/hooks/useExitHandler.js.html +292 -0
  41. package/coverage/lcov-report/index.html +206 -0
  42. package/coverage/lcov-report/prettify.css +1 -0
  43. package/coverage/lcov-report/prettify.js +2 -0
  44. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  45. package/coverage/lcov-report/sorter.js +210 -0
  46. package/coverage/lcov.info +1402 -0
  47. package/dist/App.js +11 -11
  48. package/dist/components/ContainerCreationPrompt.js +31 -10
  49. package/dist/components/ContainerList.js +3 -3
  50. package/dist/components/ContainerRow.js +20 -20
  51. package/dist/components/ContainerSection.js +3 -3
  52. package/dist/components/Footer.js +2 -2
  53. package/dist/components/Header.js +7 -7
  54. package/dist/components/LogViewer.js +3 -3
  55. package/dist/components/MessageFeedback.js +2 -2
  56. package/dist/components/PromptField.js +4 -4
  57. package/dist/components/StatsBar.js +5 -5
  58. package/dist/components/SuggestionPanel.js +44 -0
  59. package/dist/components/UsageMenu.js +3 -3
  60. package/dist/helpers/appInfo.js +7 -7
  61. package/dist/helpers/constants.js +114 -0
  62. package/dist/helpers/containerOptionsBuilder.js +3 -3
  63. package/dist/helpers/dockerService/dockerService.js +1 -1
  64. package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
  65. package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
  66. package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
  67. package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
  68. package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
  69. package/dist/helpers/exitWithMessage.js +10 -10
  70. package/dist/helpers/imageNameUtils.js +32 -0
  71. package/dist/helpers/logger.js +13 -13
  72. package/dist/helpers/safeCall.js +2 -2
  73. package/dist/helpers/validationHelpers.js +76 -21
  74. package/dist/hooks/creation/useContainerActions.js +9 -9
  75. package/dist/hooks/creation/useContainerCreation.js +138 -36
  76. package/dist/hooks/creation/useLogsViewer.js +2 -2
  77. package/dist/hooks/debug/useDebugLogs.js +4 -4
  78. package/dist/hooks/useContainerCommandRouter.js +13 -13
  79. package/dist/hooks/useContainers.js +3 -3
  80. package/dist/hooks/useControls.js +66 -35
  81. package/dist/hooks/useEraseConfirmation.js +3 -3
  82. package/dist/index.js +2 -2
  83. package/jest.config.cjs +7 -0
  84. package/package.json +7 -3
  85. package/src/App.jsx +33 -16
  86. package/src/components/ContainerCreationPrompt.jsx +43 -9
  87. package/src/components/ContainerList.jsx +3 -3
  88. package/src/components/ContainerRow.jsx +19 -16
  89. package/src/components/ContainerSection.jsx +3 -3
  90. package/src/components/Footer.jsx +2 -2
  91. package/src/components/Header.jsx +7 -7
  92. package/src/components/LogViewer.jsx +5 -3
  93. package/src/components/MessageFeedback.jsx +2 -2
  94. package/src/components/PromptField.jsx +4 -4
  95. package/src/components/StatsBar.jsx +11 -7
  96. package/src/components/SuggestionPanel.jsx +53 -0
  97. package/src/components/UsageMenu.jsx +18 -13
  98. package/src/helpers/appInfo.js +7 -7
  99. package/src/helpers/constants.js +41 -0
  100. package/src/helpers/containerOptionsBuilder.js +16 -5
  101. package/src/helpers/dockerService/dockerService.js +1 -1
  102. package/src/helpers/dockerService/serviceComponents/containerActions.js +116 -43
  103. package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
  104. package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
  105. package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
  106. package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
  107. package/src/helpers/exitWithMessage.js +13 -7
  108. package/src/helpers/imageNameUtils.js +32 -0
  109. package/src/helpers/logger.js +15 -15
  110. package/src/helpers/safeCall.js +2 -2
  111. package/src/helpers/validationHelpers.js +62 -21
  112. package/src/hooks/creation/useContainerActions.js +19 -12
  113. package/src/hooks/creation/useContainerCreation.js +161 -37
  114. package/src/hooks/creation/useLogsViewer.js +2 -2
  115. package/src/hooks/debug/useDebugLogs.js +15 -10
  116. package/src/hooks/navigation/useContainerSelection.js +17 -14
  117. package/src/hooks/useContainerCommandRouter.js +71 -58
  118. package/src/hooks/useContainers.js +4 -4
  119. package/src/hooks/useControls.js +120 -68
  120. package/src/hooks/useEraseConfirmation.js +3 -3
  121. package/src/hooks/useExitHandler.js +29 -26
  122. package/src/index.js +4 -4
  123. package/test/ContainerCreationPrompt.dom.test.js +68 -0
  124. package/test/SuggestionPanel.dom.test.js +53 -0
  125. package/test/constants.test.js +34 -0
  126. package/test/containerActions.test.js +104 -13
  127. package/test/containerOptionsBuilder.test.js +36 -0
  128. package/test/useContainerCreation.dom.test.js +166 -2
  129. package/test/useControls.dom.test.js +313 -0
  130. package/test/validationHelpers.test.js +51 -0
@@ -3,14 +3,13 @@ import { jest } from '@jest/globals';
3
3
  describe('containerActions service functions (mocked ESM imports)', () => {
4
4
  afterEach(() => jest.resetModules());
5
5
 
6
- test('createContainer returns id when image exists', async () => {
7
- // mock imageUtils and dockerService before importing the module under test
6
+ test('createContainer returns { id, ports } when image exists', async () => {
8
7
  const imageUtilsMock = {
9
8
  imageExists: jest.fn().mockResolvedValue(true),
10
9
  pullImage: jest.fn()
11
10
  };
12
11
 
13
- const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
12
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: { '3306/tcp': {} } } });
14
13
  const dockerMock = {
15
14
  createContainer: jest.fn().mockResolvedValue({ id: 'cid-123' }),
16
15
  getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
@@ -27,16 +26,107 @@ describe('containerActions service functions (mocked ESM imports)', () => {
27
26
 
28
27
  const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
29
28
 
30
- const id = await mod.createContainer('nginx:latest', { Name: 'test' });
31
- expect(id).toBe('cid-123');
32
- const imageUtils = await import('../src/helpers/dockerService/serviceComponents/imageUtils.js');
33
- expect(imageUtils.imageExists).toHaveBeenCalledWith('nginx:latest');
34
- const ds = await import('../src/helpers/dockerService/dockerService.js');
35
- expect(ds.docker.createContainer).toHaveBeenCalled();
36
- expect(ds.docker.getImage).toHaveBeenCalledWith('nginx:latest');
29
+ const result = await mod.createContainer('mysql:8', { Name: 'test' });
30
+ expect(result).toHaveProperty('id', 'cid-123');
31
+ expect(result).toHaveProperty('ports');
32
+ expect(Array.isArray(result.ports)).toBe(true);
33
+ });
34
+
35
+ test('createContainer ports contain containerPort, hostPort, protocol, source fields', async () => {
36
+ const imageUtilsMock = {
37
+ imageExists: jest.fn().mockResolvedValue(true),
38
+ pullImage: jest.fn()
39
+ };
40
+
41
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: { '80/tcp': {} } } });
42
+ const dockerMock = {
43
+ createContainer: jest.fn().mockResolvedValue({ id: 'cid-ports' }),
44
+ getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
45
+ listContainers: jest.fn().mockResolvedValue([])
46
+ };
47
+
48
+ await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
49
+ ...imageUtilsMock
50
+ }));
51
+
52
+ await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
53
+ docker: dockerMock
54
+ }));
55
+
56
+ const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
57
+ const result = await mod.createContainer('nginx:latest', {});
58
+ expect(result.ports.length).toBeGreaterThan(0);
59
+ const port = result.ports[0];
60
+ expect(port).toHaveProperty('containerPort');
61
+ expect(port).toHaveProperty('hostPort');
62
+ expect(port).toHaveProperty('protocol');
63
+ expect(port).toHaveProperty('source');
64
+ });
65
+
66
+ test('createContainer uses IMAGE_PROFILES defaultPort as fallback when ExposedPorts empty', async () => {
67
+ const imageUtilsMock = {
68
+ imageExists: jest.fn().mockResolvedValue(true),
69
+ pullImage: jest.fn()
70
+ };
71
+
72
+ // inspect returns empty ExposedPorts
73
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
74
+ const dockerMock = {
75
+ createContainer: jest.fn().mockResolvedValue({ id: 'cid-fallback' }),
76
+ getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
77
+ listContainers: jest.fn().mockResolvedValue([])
78
+ };
79
+
80
+ await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
81
+ ...imageUtilsMock
82
+ }));
83
+
84
+ await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
85
+ docker: dockerMock
86
+ }));
87
+
88
+ const { IMAGE_PROFILES } = await import('../src/helpers/constants.js');
89
+ const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
90
+ const result = await mod.createContainer('mysql:8', {}, IMAGE_PROFILES);
91
+
92
+ expect(result.id).toBe('cid-fallback');
93
+ // Should have auto port from defaultPort fallback
94
+ const mysqlDefault = IMAGE_PROFILES.mysql.defaultPort;
95
+ const portEntry = result.ports.find(p => p.containerPort === mysqlDefault);
96
+ expect(portEntry).toBeDefined();
97
+ expect(portEntry.source).toBe('auto');
98
+ });
99
+
100
+ test('createContainer returns { id, ports: [] } when no ports and image not in profiles', async () => {
101
+ const imageUtilsMock = {
102
+ imageExists: jest.fn().mockResolvedValue(true),
103
+ pullImage: jest.fn()
104
+ };
105
+
106
+ const inspectMock = jest.fn().mockResolvedValue({ Config: { ExposedPorts: {} } });
107
+ const dockerMock = {
108
+ createContainer: jest.fn().mockResolvedValue({ id: 'cid-noport' }),
109
+ getImage: jest.fn().mockReturnValue({ inspect: inspectMock }),
110
+ listContainers: jest.fn().mockResolvedValue([])
111
+ };
112
+
113
+ await jest.unstable_mockModule('../src/helpers/dockerService/serviceComponents/imageUtils.js', () => ({
114
+ ...imageUtilsMock
115
+ }));
116
+
117
+ await jest.unstable_mockModule('../src/helpers/dockerService/dockerService.js', () => ({
118
+ docker: dockerMock
119
+ }));
120
+
121
+ const { IMAGE_PROFILES } = await import('../src/helpers/constants.js');
122
+ const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
123
+ const result = await mod.createContainer('unknownimage:latest', {}, IMAGE_PROFILES);
124
+
125
+ expect(result.id).toBe('cid-noport');
126
+ expect(result.ports).toEqual([]);
37
127
  });
38
128
 
39
- test('createContainer pulls image when not present and returns Id', async () => {
129
+ test('createContainer pulls image when not present and returns { id, ports }', async () => {
40
130
  const imageUtilsMock = {
41
131
  imageExists: jest.fn().mockResolvedValue(false),
42
132
  pullImage: jest.fn().mockResolvedValue(true)
@@ -58,8 +148,9 @@ describe('containerActions service functions (mocked ESM imports)', () => {
58
148
  }));
59
149
 
60
150
  const mod = await import('../src/helpers/dockerService/serviceComponents/containerActions.js');
61
- const id = await mod.createContainer('busybox:1.0');
62
- expect(id).toBe('CID-456');
151
+ const result = await mod.createContainer('busybox:1.0');
152
+ expect(result.id).toBe('CID-456');
153
+ expect(result).toHaveProperty('ports');
63
154
  const imageUtils = await import('../src/helpers/dockerService/serviceComponents/imageUtils.js');
64
155
  expect(imageUtils.pullImage).toHaveBeenCalledWith('busybox:1.0');
65
156
  });
@@ -0,0 +1,36 @@
1
+ let buildContainerOptions;
2
+
3
+ beforeAll(async () => {
4
+ const mod = await import('../src/helpers/containerOptionsBuilder.js');
5
+ buildContainerOptions = mod.buildContainerOptions;
6
+ });
7
+
8
+ describe('buildContainerOptions', () => {
9
+ test('basic image only sets Tty', () => {
10
+ const opts = buildContainerOptions({ imageName: 'nginx' });
11
+ expect(opts.Tty).toBe(true);
12
+ expect(opts.Env).toBeUndefined();
13
+ });
14
+
15
+ test('env var with value containing = is preserved intact', () => {
16
+ const opts = buildContainerOptions({ imageName: 'nginx', envInput: 'TOKEN=a=b' });
17
+ expect(opts.Env).toEqual(['TOKEN=a=b']);
18
+ });
19
+
20
+ test('multiple env vars with = in values are all preserved', () => {
21
+ const opts = buildContainerOptions({ imageName: 'nginx', envInput: 'TOKEN=a=b,DSN=user:pass@host/db?opt=1' });
22
+ expect(opts.Env).toContain('TOKEN=a=b');
23
+ expect(opts.Env).toContain('DSN=user:pass@host/db?opt=1');
24
+ });
25
+
26
+ test('port input builds ExposedPorts and PortBindings', () => {
27
+ const opts = buildContainerOptions({ imageName: 'nginx', portInput: '8080:80' });
28
+ expect(opts.ExposedPorts).toEqual({ '80/tcp': {} });
29
+ expect(opts.HostConfig.PortBindings['80/tcp']).toEqual([{ HostPort: '8080' }]);
30
+ });
31
+
32
+ test('container name is included when provided', () => {
33
+ const opts = buildContainerOptions({ imageName: 'nginx', containerName: 'my-nginx' });
34
+ expect(opts.name).toBe('my-nginx');
35
+ });
36
+ });
@@ -4,9 +4,10 @@
4
4
  import React, { useEffect } from 'react';
5
5
  import { render, act } from '@testing-library/react';
6
6
  import { useContainerCreation } from '../src/hooks/creation/useContainerCreation.js';
7
+ import { IMAGE_PROFILES } from '../src/helpers/constants.js';
7
8
 
8
- function HookTester({ onCreate, onCancel, dbImages, expose }) {
9
- const hook = useContainerCreation({ onCreate, onCancel, dbImages });
9
+ function HookTester({ onCreate, onCancel, dbImages, imageProfiles, expose }) {
10
+ const hook = useContainerCreation({ onCreate, onCancel, dbImages, imageProfiles });
10
11
  // keep exposing the latest hook values on every render
11
12
  useEffect(() => {
12
13
  if (expose) expose.current = hook;
@@ -97,4 +98,167 @@ describe('useContainerCreation (DOM render)', () => {
97
98
  expect(created.length).toBe(1);
98
99
  expect(created[0]).toEqual({ imageName: 'redis', containerName: '', portInput: '', envInput: 'FOO=bar' });
99
100
  });
101
+
102
+ test('step 3 with mysql image: missing MYSQL_ROOT_PASSWORD blocks creation', () => {
103
+ const created = [];
104
+ const expose = { current: null };
105
+
106
+ render(<HookTester onCreate={(d) => created.push(d)} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
107
+
108
+ // advance to step 3 (env) with mysql image
109
+ act(() => { expose.current.setImageName('mysql:8'); });
110
+ act(() => { expose.current.nextStep(); }); // step 0 → 1
111
+ act(() => { expose.current.nextStep(); }); // step 1 → 2
112
+ act(() => { expose.current.nextStep(); }); // step 2 → 3
113
+
114
+ expect(expose.current.step).toBe(3);
115
+
116
+ // try to advance without required env var
117
+ act(() => { expose.current.nextStep(); });
118
+
119
+ expect(expose.current.step).toBe(3); // still blocked
120
+ expect(expose.current.message).toMatch(/MYSQL_ROOT_PASSWORD/i);
121
+ expect(created.length).toBe(0);
122
+ });
123
+
124
+ test('step 3 with mysql image: valid env advances and calls onCreate', () => {
125
+ const created = [];
126
+ const expose = { current: null };
127
+
128
+ render(<HookTester onCreate={(d) => created.push(d)} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
129
+
130
+ act(() => { expose.current.setImageName('mysql:8'); });
131
+ act(() => { expose.current.nextStep(); });
132
+ act(() => { expose.current.nextStep(); });
133
+ act(() => { expose.current.nextStep(); }); // at step 3
134
+
135
+ act(() => { expose.current.setEnvInput('MYSQL_ROOT_PASSWORD=secret'); });
136
+ act(() => { expose.current.nextStep(); }); // should call onCreate
137
+
138
+ expect(created.length).toBe(1);
139
+ expect(created[0].imageName).toBe('mysql:8');
140
+ });
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
+ });
100
264
  });
@@ -0,0 +1,313 @@
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 }) {
42
+ const hook = useControls(containers);
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');
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
+ const nameBefore = expose.current.creation.imageName;
306
+
307
+ act(() => { triggerInput('\r', {}); }); // advance to step 1
308
+
309
+ // imageName should be unchanged — applyFocusedSuggestion was NOT called
310
+ expect(expose.current.creation.imageName).toBe(nameBefore);
311
+ expect(expose.current.creation.step).toBe(1);
312
+ });
313
+ });