cdd-cli 3.2.2 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. package/.~lock.ROADMAP.md# +1 -0
  2. package/CHANGELOG.md +51 -0
  3. package/README.es.md +198 -0
  4. package/README.md +81 -23
  5. package/ROADMAP.md +647 -0
  6. package/coverage/clover.xml +400 -519
  7. package/coverage/coverage-final.json +16 -20
  8. package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
  9. package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
  10. package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +31 -13
  11. package/coverage/lcov-report/components/PromptField.jsx.html +4 -4
  12. package/coverage/lcov-report/components/SuggestionPanel.jsx.html +37 -13
  13. package/coverage/lcov-report/components/index.html +14 -14
  14. package/coverage/lcov-report/helpers/constants.js.html +87 -33
  15. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +40 -40
  16. package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
  17. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +1 -1
  18. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +1 -1
  19. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +1 -1
  20. package/coverage/lcov-report/helpers/imageNameUtils.js.html +18 -18
  21. package/coverage/lcov-report/helpers/index.html +77 -62
  22. package/coverage/lcov-report/helpers/logger.js.html +60 -60
  23. package/coverage/lcov-report/helpers/safeCall.js.html +14 -14
  24. package/coverage/lcov-report/helpers/validationHelpers.js.html +52 -52
  25. package/coverage/lcov-report/hooks/creation/index.html +19 -19
  26. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +4 -4
  27. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +225 -48
  28. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +5 -5
  29. package/coverage/lcov-report/hooks/debug/index.html +1 -1
  30. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +7 -7
  31. package/coverage/lcov-report/hooks/index.html +15 -15
  32. package/coverage/lcov-report/hooks/navigation/index.html +1 -1
  33. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +6 -6
  34. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +13 -13
  35. package/coverage/lcov-report/hooks/useControls.js.html +82 -40
  36. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +5 -5
  37. package/coverage/lcov-report/hooks/useExitHandler.js.html +6 -6
  38. package/coverage/lcov-report/index.html +38 -68
  39. package/coverage/lcov.info +742 -981
  40. package/dist/App.js +7 -1
  41. package/dist/components/ContainerCreationPrompt.js +26 -10
  42. package/dist/components/ControlsHUD.js +106 -0
  43. package/dist/components/SuggestionPanel.js +16 -7
  44. package/dist/helpers/constants.js +60 -21
  45. package/dist/helpers/dockerHubService.js +111 -0
  46. package/dist/helpers/logger.js +1 -1
  47. package/dist/hooks/creation/useContainerActions.js +22 -5
  48. package/dist/hooks/creation/useContainerCreation.js +395 -107
  49. package/dist/hooks/useContainerCommandRouter.js +1 -1
  50. package/dist/hooks/useControls.js +23 -7
  51. package/dist/index.js +7 -0
  52. package/package.json +1 -1
  53. package/src/App.jsx +6 -0
  54. package/src/components/ContainerCreationPrompt.jsx +15 -5
  55. package/src/components/ControlsHUD.jsx +66 -0
  56. package/src/components/SuggestionPanel.jsx +10 -2
  57. package/src/helpers/constants.js +38 -20
  58. package/src/helpers/dockerHubService.js +75 -0
  59. package/src/helpers/logger.js +1 -2
  60. package/src/hooks/creation/useContainerActions.js +19 -6
  61. package/src/hooks/creation/useContainerCreation.js +224 -87
  62. package/src/hooks/useContainerCommandRouter.js +1 -1
  63. package/src/hooks/useControls.js +24 -8
  64. package/src/index.js +4 -0
  65. package/test/ContainerCreationPrompt.dom.test.js +245 -0
  66. package/test/ControlsHUD.dom.test.js +125 -0
  67. package/test/SuggestionPanel.dom.test.js +30 -0
  68. package/test/constants.test.js +7 -0
  69. package/test/dockerHubService.test.js +213 -0
  70. package/test/logger.test.js +58 -0
  71. package/test/resolveImageTag.test.js +26 -0
  72. package/test/useContainerCreation.dom.test.js +306 -3
  73. package/test/useContainerCreationHub.dom.test.js +184 -0
  74. package/test/useControls.dom.test.js +212 -6
@@ -65,4 +65,249 @@ describe('ContainerCreationPrompt — SuggestionPanel wiring', () => {
65
65
  const nginxEls = getAllByText(/nginx/);
66
66
  expect(nginxEls.length).toBeGreaterThan(0);
67
67
  });
68
+
69
+ // FR8 — hubResults wiring
70
+ test('when hubResults is provided, activeItems = hubResults (replaces suggestions)', () => {
71
+ const { getByText, queryByText } = render(
72
+ <ContainerCreationPrompt
73
+ step={0}
74
+ imageName="ng"
75
+ containerName=""
76
+ portInput=""
77
+ envInput=""
78
+ message=""
79
+ messageColor="yellow"
80
+ suggestions={['nginx', 'golang']}
81
+ hubResults={['hub-nginx', 'hub-node']}
82
+ selectedSuggestionIndex={-1}
83
+ visibleOffset={0}
84
+ />
85
+ );
86
+ expect(getByText(/hub-nginx/)).toBeTruthy();
87
+ expect(queryByText(/golang/)).toBeNull();
88
+ });
89
+
90
+ test('when hubResults is null, falls back to suggestions', () => {
91
+ const { getByText } = render(
92
+ <ContainerCreationPrompt
93
+ step={0}
94
+ imageName="ng"
95
+ containerName=""
96
+ portInput=""
97
+ envInput=""
98
+ message=""
99
+ messageColor="yellow"
100
+ suggestions={['nginx', 'golang']}
101
+ hubResults={null}
102
+ selectedSuggestionIndex={-1}
103
+ visibleOffset={0}
104
+ />
105
+ );
106
+ expect(getByText(/golang/)).toBeTruthy();
107
+ });
108
+
109
+ test('when step===0 and isSearchingHub=true, SuggestionPanel is shown even if activeItems is empty', () => {
110
+ const { getByText } = render(
111
+ <ContainerCreationPrompt
112
+ step={0}
113
+ imageName="ng"
114
+ containerName=""
115
+ portInput=""
116
+ envInput=""
117
+ message=""
118
+ messageColor="yellow"
119
+ suggestions={[]}
120
+ hubResults={null}
121
+ isSearchingHub={true}
122
+ selectedSuggestionIndex={-1}
123
+ visibleOffset={0}
124
+ />
125
+ );
126
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
127
+ });
128
+
129
+ test('when step===0 and isSearchingHub=false and activeItems is empty, SuggestionPanel is NOT shown', () => {
130
+ const { queryByTestId } = render(
131
+ <ContainerCreationPrompt
132
+ step={0}
133
+ imageName="ng"
134
+ containerName=""
135
+ portInput=""
136
+ envInput=""
137
+ message=""
138
+ messageColor="yellow"
139
+ suggestions={[]}
140
+ hubResults={null}
141
+ isSearchingHub={false}
142
+ selectedSuggestionIndex={-1}
143
+ visibleOffset={0}
144
+ />
145
+ );
146
+ expect(queryByTestId('suggestion-panel')).toBeNull();
147
+ });
148
+
149
+ test('isLoading prop is passed down to SuggestionPanel when isSearchingHub=true', () => {
150
+ const { getByText } = render(
151
+ <ContainerCreationPrompt
152
+ step={0}
153
+ imageName="ng"
154
+ containerName=""
155
+ portInput=""
156
+ envInput=""
157
+ message=""
158
+ messageColor="yellow"
159
+ suggestions={['nginx']}
160
+ hubResults={null}
161
+ isSearchingHub={true}
162
+ selectedSuggestionIndex={-1}
163
+ visibleOffset={0}
164
+ />
165
+ );
166
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
167
+ });
168
+ });
169
+
170
+ describe('ContainerCreationPrompt — ControlsHUD wiring', () => {
171
+ test('step=0, no suggestions: renders ControlsHUD with [Tab] and Search Hub', () => {
172
+ const { getByText } = render(
173
+ <ContainerCreationPrompt
174
+ step={0}
175
+ imageName=""
176
+ containerName=""
177
+ portInput=""
178
+ envInput=""
179
+ message=""
180
+ messageColor="yellow"
181
+ suggestions={[]}
182
+ hubResults={null}
183
+ isSearchingHub={false}
184
+ selectedSuggestionIndex={-1}
185
+ visibleOffset={0}
186
+ />
187
+ );
188
+ expect(getByText('[Tab]')).toBeTruthy();
189
+ expect(getByText(/Search Hub/)).toBeTruthy();
190
+ });
191
+
192
+ test('does NOT render old static text "Press Enter to continue, Escape to cancel"', () => {
193
+ const { queryByText } = render(
194
+ <ContainerCreationPrompt
195
+ step={0}
196
+ imageName=""
197
+ containerName=""
198
+ portInput=""
199
+ envInput=""
200
+ message=""
201
+ messageColor="yellow"
202
+ suggestions={[]}
203
+ hubResults={null}
204
+ isSearchingHub={false}
205
+ selectedSuggestionIndex={-1}
206
+ visibleOffset={0}
207
+ />
208
+ );
209
+ expect(queryByText(/Press Enter to continue, Escape to cancel/)).toBeNull();
210
+ });
211
+
212
+ test('step=0, hasSuggestions=true: renders [↑↓] not [Tab]', () => {
213
+ const { getByText, queryByText } = render(
214
+ <ContainerCreationPrompt
215
+ step={0}
216
+ imageName="ng"
217
+ containerName=""
218
+ portInput=""
219
+ envInput=""
220
+ message=""
221
+ messageColor="yellow"
222
+ suggestions={['nginx', 'golang']}
223
+ hubResults={null}
224
+ isSearchingHub={false}
225
+ selectedSuggestionIndex={-1}
226
+ visibleOffset={0}
227
+ />
228
+ );
229
+ expect(getByText('[↑↓]')).toBeTruthy();
230
+ expect(queryByText('[Tab]')).toBeNull();
231
+ });
232
+
233
+ test('step=0, isSearchingHub=true, no suggestions: ControlsHUD does NOT render [Tab] or Navigate, DOES render [Enter] and [Esc]', () => {
234
+ const { queryByText, getByText } = render(
235
+ <ContainerCreationPrompt
236
+ step={0}
237
+ imageName="ng"
238
+ containerName=""
239
+ portInput=""
240
+ envInput=""
241
+ message=""
242
+ messageColor="yellow"
243
+ suggestions={[]}
244
+ hubResults={null}
245
+ isSearchingHub={true}
246
+ selectedSuggestionIndex={-1}
247
+ visibleOffset={0}
248
+ />
249
+ );
250
+ expect(queryByText('[Tab]')).toBeNull();
251
+ expect(queryByText(/Navigate/)).toBeNull();
252
+ expect(getByText('[Enter]')).toBeTruthy();
253
+ expect(getByText('[Esc]')).toBeTruthy();
254
+ });
255
+
256
+ test('step=1: renders [Enter] and [Esc]', () => {
257
+ const { getByText } = render(
258
+ <ContainerCreationPrompt
259
+ step={1}
260
+ imageName="nginx"
261
+ containerName=""
262
+ portInput=""
263
+ envInput=""
264
+ message=""
265
+ messageColor="yellow"
266
+ suggestions={[]}
267
+ selectedSuggestionIndex={-1}
268
+ visibleOffset={0}
269
+ />
270
+ );
271
+ expect(getByText('[Enter]')).toBeTruthy();
272
+ expect(getByText('[Esc]')).toBeTruthy();
273
+ });
274
+
275
+ test('step=3, hasSuggestedEnv=true: renders [Tab] in HUD', () => {
276
+ const { getByText } = render(
277
+ <ContainerCreationPrompt
278
+ step={3}
279
+ imageName="postgres:17-alpine"
280
+ containerName=""
281
+ portInput=""
282
+ envInput=""
283
+ message=""
284
+ messageColor="yellow"
285
+ suggestions={[]}
286
+ selectedSuggestionIndex={-1}
287
+ visibleOffset={0}
288
+ hasSuggestedEnv={true}
289
+ />
290
+ );
291
+ expect(getByText('[Tab]')).toBeTruthy();
292
+ expect(getByText(/Insert next env/)).toBeTruthy();
293
+ });
294
+
295
+ test('step=3, hasSuggestedEnv=false: does NOT render [Tab] in HUD', () => {
296
+ const { queryByText } = render(
297
+ <ContainerCreationPrompt
298
+ step={3}
299
+ imageName="nginx:1.27-alpine"
300
+ containerName=""
301
+ portInput=""
302
+ envInput=""
303
+ message=""
304
+ messageColor="yellow"
305
+ suggestions={[]}
306
+ selectedSuggestionIndex={-1}
307
+ visibleOffset={0}
308
+ hasSuggestedEnv={false}
309
+ />
310
+ );
311
+ expect(queryByText('[Tab]')).toBeNull();
312
+ });
68
313
  });
@@ -0,0 +1,125 @@
1
+ /**
2
+ * @jest-environment jsdom
3
+ */
4
+ import React from 'react';
5
+ import { render } from '@testing-library/react';
6
+ import { getHints, ControlsHUD } from '../src/components/ControlsHUD.jsx';
7
+
8
+ describe('getHints — pure function', () => {
9
+ test('step=0, no suggestions, not searching: includes Tab=Search Hub', () => {
10
+ const hints = getHints(0, false, false);
11
+ const keys = hints.map(h => h.key);
12
+ expect(keys).toContain('Tab');
13
+ const tabHint = hints.find(h => h.key === 'Tab');
14
+ expect(tabHint.label).toBe('Search Hub');
15
+ });
16
+
17
+ test('step=0, hasSuggestions=true: includes ↑↓ Navigate, does NOT include Tab', () => {
18
+ const hints = getHints(0, true, false);
19
+ const keys = hints.map(h => h.key);
20
+ expect(keys).toContain('↑↓');
21
+ expect(keys).not.toContain('Tab');
22
+ const navHint = hints.find(h => h.key === '↑↓');
23
+ expect(navHint.label).toBe('Navigate');
24
+ });
25
+
26
+ test('step=0, isSearchingHub=true: does NOT include Tab, includes Enter and Esc', () => {
27
+ const hints = getHints(0, false, true);
28
+ const keys = hints.map(h => h.key);
29
+ expect(keys).not.toContain('Tab');
30
+ expect(keys).toContain('Enter');
31
+ expect(keys).toContain('Esc');
32
+ });
33
+
34
+ test('step=1: includes Enter=Continue and Esc=Cancel, no Tab, no ↑↓', () => {
35
+ const hints = getHints(1, false, false);
36
+ const keys = hints.map(h => h.key);
37
+ expect(keys).toContain('Enter');
38
+ expect(keys).toContain('Esc');
39
+ expect(keys).not.toContain('Tab');
40
+ expect(keys).not.toContain('↑↓');
41
+ const enterHint = hints.find(h => h.key === 'Enter');
42
+ expect(enterHint.label).toBe('Continue');
43
+ });
44
+
45
+ test('step=2: same as step=1', () => {
46
+ const hints = getHints(2, false, false);
47
+ const keys = hints.map(h => h.key);
48
+ expect(keys).toContain('Enter');
49
+ expect(keys).toContain('Esc');
50
+ expect(keys).not.toContain('Tab');
51
+ expect(keys).not.toContain('↑↓');
52
+ });
53
+
54
+ test('step=3: same as step=1', () => {
55
+ const hints = getHints(3, false, false);
56
+ const keys = hints.map(h => h.key);
57
+ expect(keys).toContain('Enter');
58
+ expect(keys).toContain('Esc');
59
+ expect(keys).not.toContain('Tab');
60
+ expect(keys).not.toContain('↑↓');
61
+ });
62
+
63
+ test('getHints(3, false, false, true) includes Tab=Insert next env', () => {
64
+ const hints = getHints(3, false, false, true);
65
+ const keys = hints.map(h => h.key);
66
+ expect(keys).toContain('Tab');
67
+ const tabHint = hints.find(h => h.key === 'Tab');
68
+ expect(tabHint.label).toBe('Insert next env');
69
+ });
70
+
71
+ test('getHints(3, false, false, false) does NOT include Tab hint', () => {
72
+ const hints = getHints(3, false, false, false);
73
+ const keys = hints.map(h => h.key);
74
+ expect(keys).not.toContain('Tab');
75
+ });
76
+
77
+ test('getHints(3, false, false, true) also includes Enter and Esc', () => {
78
+ const hints = getHints(3, false, false, true);
79
+ const keys = hints.map(h => h.key);
80
+ expect(keys).toContain('Enter');
81
+ expect(keys).toContain('Esc');
82
+ });
83
+ });
84
+
85
+ describe('ControlsHUD — component rendering', () => {
86
+ test('step=0, no suggestions: renders [Tab] and "Search Hub" text', () => {
87
+ const { getByText } = render(
88
+ <ControlsHUD step={0} hasSuggestions={false} isSearchingHub={false} />
89
+ );
90
+ expect(getByText('[Tab]')).toBeTruthy();
91
+ expect(getByText(/Search Hub/)).toBeTruthy();
92
+ });
93
+
94
+ test('step=0, hasSuggestions=true: renders [↑↓], does NOT render [Tab]', () => {
95
+ const { getByText, queryByText } = render(
96
+ <ControlsHUD step={0} hasSuggestions={true} isSearchingHub={false} />
97
+ );
98
+ expect(getByText('[↑↓]')).toBeTruthy();
99
+ expect(queryByText('[Tab]')).toBeNull();
100
+ });
101
+
102
+ test('step=1: renders [Enter] and [Esc], does NOT render [Tab]', () => {
103
+ const { getByText, queryByText } = render(
104
+ <ControlsHUD step={1} hasSuggestions={false} isSearchingHub={false} />
105
+ );
106
+ expect(getByText('[Enter]')).toBeTruthy();
107
+ expect(getByText('[Esc]')).toBeTruthy();
108
+ expect(queryByText('[Tab]')).toBeNull();
109
+ });
110
+
111
+ test('step=3, hasSuggestedEnv=true: renders [Tab] and "Insert next env"', () => {
112
+ const { getByText } = render(
113
+ <ControlsHUD step={3} hasSuggestions={false} isSearchingHub={false} hasSuggestedEnv={true} />
114
+ );
115
+ expect(getByText('[Tab]')).toBeTruthy();
116
+ expect(getByText(/Insert next env/)).toBeTruthy();
117
+ });
118
+
119
+ test('step=3, hasSuggestedEnv=false: does NOT render [Tab]', () => {
120
+ const { queryByText } = render(
121
+ <ControlsHUD step={3} hasSuggestions={false} isSearchingHub={false} hasSuggestedEnv={false} />
122
+ );
123
+ expect(queryByText('[Tab]')).toBeNull();
124
+ });
125
+ });
@@ -50,4 +50,34 @@ describe('SuggestionPanel', () => {
50
50
  const el = getByText(/caddy/);
51
51
  expect(el.textContent).not.toMatch(/›/);
52
52
  });
53
+
54
+ // FR8 — isLoading prop
55
+ test('renders [searching Docker Hub...] row when isLoading=true', () => {
56
+ const { getByText } = render(
57
+ <SuggestionPanel items={[]} selectedIndex={-1} visibleOffset={0} isLoading={true} />
58
+ );
59
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
60
+ });
61
+
62
+ test('loading row is NOT selectable — no › prefix on loading row', () => {
63
+ const { getByText } = render(
64
+ <SuggestionPanel items={items} selectedIndex={-1} visibleOffset={0} maxVisible={5} isLoading={true} />
65
+ );
66
+ const loadingEl = getByText(/searching Docker Hub/);
67
+ expect(loadingEl.textContent).not.toMatch(/›/);
68
+ });
69
+
70
+ test('does NOT render loading row when isLoading=false', () => {
71
+ const { queryByText } = render(
72
+ <SuggestionPanel items={items} selectedIndex={-1} visibleOffset={0} maxVisible={5} isLoading={false} />
73
+ );
74
+ expect(queryByText(/searching Docker Hub/)).toBeNull();
75
+ });
76
+
77
+ test('renders panel even when items is empty but isLoading=true', () => {
78
+ const { getByText } = render(
79
+ <SuggestionPanel items={[]} selectedIndex={-1} visibleOffset={0} isLoading={true} />
80
+ );
81
+ expect(getByText(/searching Docker Hub/)).toBeTruthy();
82
+ });
53
83
  });
@@ -31,4 +31,11 @@ describe('IMAGE_PROFILES', () => {
31
31
  test('node has suggestedEnv with NODE_ENV hint', () => {
32
32
  expect(IMAGE_PROFILES.node.suggestedEnv).toContain('NODE_ENV=production');
33
33
  });
34
+
35
+ test('every entry has defaultTag as a non-empty string', () => {
36
+ for (const [key, profile] of Object.entries(IMAGE_PROFILES)) {
37
+ expect(typeof profile.defaultTag).toBe('string');
38
+ expect(profile.defaultTag.length).toBeGreaterThan(0);
39
+ }
40
+ });
34
41
  });
@@ -0,0 +1,213 @@
1
+ /**
2
+ * @jest-environment node
3
+ */
4
+ import { jest } from '@jest/globals';
5
+ import { formatHubResult } from '../src/helpers/dockerHubService.js';
6
+
7
+ // We mock fetch at the global level using jest.spyOn
8
+ // The module under test is imported dynamically after spy setup
9
+
10
+ describe('searchDockerHub — unit tests', () => {
11
+ let searchDockerHub;
12
+ let fetchSpy;
13
+
14
+ beforeEach(async () => {
15
+ // Reset modules so each test gets fresh state
16
+ jest.resetModules();
17
+ // Dynamically import after setting up spy
18
+ ({ searchDockerHub } = await import('../src/helpers/dockerHubService.js'));
19
+ });
20
+
21
+ afterEach(() => {
22
+ if (fetchSpy) fetchSpy.mockRestore();
23
+ fetchSpy = null;
24
+ });
25
+
26
+ // ── 1. Query trim & encode ──────────────────────────────────────────────
27
+
28
+ test('trims whitespace from query before encoding', async () => {
29
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
30
+ ok: true,
31
+ json: async () => ({ results: [] }),
32
+ });
33
+
34
+ await searchDockerHub(' nginx ');
35
+
36
+ const calledUrl = fetchSpy.mock.calls[0][0];
37
+ expect(calledUrl).toContain('query=nginx');
38
+ expect(calledUrl).not.toContain('query=+nginx');
39
+ expect(calledUrl).not.toContain(' ');
40
+ });
41
+
42
+ test('encodes special characters in query', async () => {
43
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
44
+ ok: true,
45
+ json: async () => ({ results: [] }),
46
+ });
47
+
48
+ await searchDockerHub('hello world');
49
+
50
+ const calledUrl = fetchSpy.mock.calls[0][0];
51
+ expect(calledUrl).toContain('query=hello%20world');
52
+ });
53
+
54
+ test('returns [] immediately for empty query (no fetch call)', async () => {
55
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
56
+ ok: true,
57
+ json: async () => ({ results: [] }),
58
+ });
59
+
60
+ const result = await searchDockerHub(' ');
61
+
62
+ expect(result).toEqual([]);
63
+ expect(fetchSpy).not.toHaveBeenCalled();
64
+ });
65
+
66
+ // ── 2. AbortSignal.timeout(5000) default ──────────────────────────────
67
+
68
+ test('uses AbortSignal.timeout(5000) by default when no signal provided', async () => {
69
+ const timeoutSignalSpy = jest.spyOn(AbortSignal, 'timeout');
70
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
71
+ ok: true,
72
+ json: async () => ({ results: [] }),
73
+ });
74
+
75
+ await searchDockerHub('nginx');
76
+
77
+ expect(timeoutSignalSpy).toHaveBeenCalledWith(5000);
78
+ timeoutSignalSpy.mockRestore();
79
+ });
80
+
81
+ test('does NOT call AbortSignal.timeout when signal is injected', async () => {
82
+ const timeoutSignalSpy = jest.spyOn(AbortSignal, 'timeout');
83
+ const controller = new AbortController();
84
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
85
+ ok: true,
86
+ json: async () => ({ results: [] }),
87
+ });
88
+
89
+ await searchDockerHub('nginx', { signal: controller.signal });
90
+
91
+ expect(timeoutSignalSpy).not.toHaveBeenCalled();
92
+ timeoutSignalSpy.mockRestore();
93
+ });
94
+
95
+ // ── 3. Injected signal is passed to fetch ──────────────────────────────
96
+
97
+ test('passes injected signal to fetch', async () => {
98
+ const controller = new AbortController();
99
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
100
+ ok: true,
101
+ json: async () => ({ results: [] }),
102
+ });
103
+
104
+ await searchDockerHub('nginx', { signal: controller.signal });
105
+
106
+ const fetchOptions = fetchSpy.mock.calls[0][1];
107
+ expect(fetchOptions.signal).toBe(controller.signal);
108
+ });
109
+
110
+ // ── 4. Result mapping ──────────────────────────────────────────────────
111
+
112
+ test('maps results to normalized objects with name, isOfficial, pullCount, description', async () => {
113
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
114
+ ok: true,
115
+ json: async () => ({
116
+ results: [
117
+ {
118
+ repo_name: 'library/nginx',
119
+ is_official: true,
120
+ pull_count: 1000000000,
121
+ short_description: 'Official nginx image',
122
+ },
123
+ {
124
+ repo_name: 'bitnami/nginx',
125
+ is_official: false,
126
+ pull_count: 50000000,
127
+ short_description: 'Bitnami nginx image',
128
+ },
129
+ ],
130
+ }),
131
+ });
132
+
133
+ const results = await searchDockerHub('nginx');
134
+
135
+ expect(results).toHaveLength(2);
136
+ expect(results[0]).toEqual({
137
+ name: 'library/nginx',
138
+ isOfficial: true,
139
+ pullCount: 1000000000,
140
+ description: 'Official nginx image',
141
+ });
142
+ expect(results[1]).toEqual({
143
+ name: 'bitnami/nginx',
144
+ isOfficial: false,
145
+ pullCount: 50000000,
146
+ description: 'Bitnami nginx image',
147
+ });
148
+ });
149
+
150
+ test('returns empty array when results is missing from response', async () => {
151
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
152
+ ok: true,
153
+ json: async () => ({}),
154
+ });
155
+
156
+ const results = await searchDockerHub('nginx');
157
+ expect(results).toEqual([]);
158
+ });
159
+
160
+ // ── 5. Non-OK rejection ────────────────────────────────────────────────
161
+
162
+ test('throws when response.ok is false', async () => {
163
+ fetchSpy = jest.spyOn(global, 'fetch').mockResolvedValue({
164
+ ok: false,
165
+ status: 429,
166
+ json: async () => ({}),
167
+ });
168
+
169
+ await expect(searchDockerHub('nginx')).rejects.toThrow(
170
+ 'Docker Hub search failed: 429'
171
+ );
172
+ });
173
+
174
+ test('re-throws AbortError from fetch', async () => {
175
+ const abortErr = new DOMException('Aborted', 'AbortError');
176
+ fetchSpy = jest.spyOn(global, 'fetch').mockRejectedValue(abortErr);
177
+
178
+ await expect(searchDockerHub('nginx')).rejects.toThrow('Aborted');
179
+ });
180
+ });
181
+
182
+ describe('formatHubResult — unit tests', () => {
183
+ test('official repo includes [★ official]', () => {
184
+ const result = formatHubResult({ name: 'library/nginx', isOfficial: true, pullCount: 0 });
185
+ expect(result).toContain('[★ official]');
186
+ expect(result).toContain('library/nginx');
187
+ });
188
+
189
+ test('high pull count (≥1B) is formatted as 1B+', () => {
190
+ const result = formatHubResult({ name: 'nginx', isOfficial: true, pullCount: 1e9 });
191
+ expect(result).toContain('[1B+ pulls]');
192
+ });
193
+
194
+ test('pull count ≥1M is formatted as NM+', () => {
195
+ const result = formatHubResult({ name: 'myimage', isOfficial: false, pullCount: 500e6 });
196
+ expect(result).toContain('[500M+ pulls]');
197
+ });
198
+
199
+ test('pull count ≥1K is formatted as NK+', () => {
200
+ const result = formatHubResult({ name: 'myimage', isOfficial: false, pullCount: 500e3 });
201
+ expect(result).toContain('[500K+ pulls]');
202
+ });
203
+
204
+ test('no pull count → just name (no suffix)', () => {
205
+ const result = formatHubResult({ name: 'myimage', isOfficial: false, pullCount: 0 });
206
+ expect(result).toBe('myimage');
207
+ });
208
+
209
+ test('unofficial + low pull count → just name with raw count', () => {
210
+ const result = formatHubResult({ name: 'myimage', isOfficial: false, pullCount: 42 });
211
+ expect(result).toBe('myimage [42 pulls]');
212
+ });
213
+ });
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Tests for src/helpers/logger.js
3
+ * Verifies that console output is suppressed when Ink listeners are active.
4
+ */
5
+ import { jest } from '@jest/globals';
6
+
7
+ const { logger } = await import('../src/helpers/logger.js');
8
+
9
+ describe('logger — console output suppression', () => {
10
+ let consoleSpy;
11
+ let warnSpy;
12
+
13
+ beforeEach(() => {
14
+ consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {});
15
+ warnSpy = jest.spyOn(console, 'warn').mockImplementation(() => {});
16
+ });
17
+
18
+ afterEach(() => {
19
+ consoleSpy.mockRestore();
20
+ warnSpy.mockRestore();
21
+ });
22
+
23
+ test('when NO listeners are registered, logger.error calls console.error (fallback)', () => {
24
+ logger.error('test error no listener');
25
+ expect(consoleSpy).toHaveBeenCalledTimes(1);
26
+ });
27
+
28
+ test('when a listener IS registered, logger.error does NOT call console.error', () => {
29
+ const unsubscribe = logger.subscribe(() => {});
30
+ logger.error('test error with listener');
31
+ expect(consoleSpy).not.toHaveBeenCalled();
32
+ unsubscribe();
33
+ });
34
+
35
+ test('when a listener IS registered, logger.warn does NOT call console.warn', () => {
36
+ const unsubscribe = logger.subscribe(() => {});
37
+ logger.warn('test warn with listener');
38
+ expect(warnSpy).not.toHaveBeenCalled();
39
+ unsubscribe();
40
+ });
41
+
42
+ test('after unsubscribing last listener, logger.error resumes calling console.error', () => {
43
+ const unsubscribe = logger.subscribe(() => {});
44
+ unsubscribe();
45
+ logger.error('after unsubscribe');
46
+ expect(consoleSpy).toHaveBeenCalledTimes(1);
47
+ });
48
+
49
+ test('listener is invoked with the log entry even when console output is suppressed', () => {
50
+ const received = [];
51
+ const unsubscribe = logger.subscribe((entry) => received.push(entry));
52
+ logger.error('entry delivered', 'extra-arg');
53
+ expect(received).toHaveLength(1);
54
+ expect(received[0].level).toBe('error');
55
+ expect(received[0].message).toBe('entry delivered');
56
+ unsubscribe();
57
+ });
58
+ });