cdd-cli 3.2.0 → 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.
- package/.atl/skill-registry.md +30 -0
- package/.editorconfig +12 -0
- package/.eslintrc.json +1 -1
- package/.prettierignore +4 -0
- package/.prettierrc +8 -0
- package/.~lock.ROADMAP.md# +1 -0
- package/CHANGELOG.md +80 -0
- package/README.es.md +198 -0
- package/README.md +81 -23
- package/ROADMAP.md +647 -0
- package/SECURITY.md +5 -0
- package/__mocks__/ink.cjs +17 -0
- package/coverage/clover.xml +471 -500
- package/coverage/coverage-final.json +16 -16
- package/coverage/lcov-report/ContainerCreationPrompt.jsx.html +400 -0
- package/coverage/lcov-report/ControlsHUD.jsx.html +256 -0
- package/coverage/lcov-report/components/ContainerCreationPrompt.jsx.html +394 -0
- package/coverage/lcov-report/components/PromptField.jsx.html +229 -0
- package/coverage/lcov-report/components/SuggestionPanel.jsx.html +268 -0
- package/coverage/lcov-report/components/index.html +146 -0
- package/coverage/lcov-report/helpers/constants.js.html +139 -22
- package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +75 -42
- package/coverage/lcov-report/helpers/dockerHubService.js.html +310 -0
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +162 -84
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +50 -32
- package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +21 -21
- package/coverage/lcov-report/helpers/imageNameUtils.js.html +181 -0
- package/coverage/lcov-report/helpers/index.html +84 -54
- package/coverage/lcov-report/helpers/logger.js.html +68 -68
- package/coverage/lcov-report/helpers/safeCall.js.html +15 -15
- package/coverage/lcov-report/helpers/validationHelpers.js.html +66 -114
- package/coverage/lcov-report/hooks/creation/index.html +20 -20
- package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +38 -17
- package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +593 -89
- package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +7 -7
- package/coverage/lcov-report/hooks/debug/index.html +5 -5
- package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +35 -20
- package/coverage/lcov-report/hooks/index.html +40 -40
- package/coverage/lcov-report/hooks/navigation/index.html +19 -19
- package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +38 -29
- package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +119 -80
- package/coverage/lcov-report/hooks/useControls.js.html +306 -123
- package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +8 -8
- package/coverage/lcov-report/hooks/useExitHandler.js.html +50 -41
- package/coverage/lcov-report/index.html +52 -67
- package/coverage/lcov.info +899 -952
- package/dist/App.js +18 -12
- package/dist/components/ContainerCreationPrompt.js +50 -13
- package/dist/components/ContainerList.js +3 -3
- package/dist/components/ContainerRow.js +20 -20
- package/dist/components/ContainerSection.js +3 -3
- package/dist/components/ControlsHUD.js +106 -0
- package/dist/components/Footer.js +2 -2
- package/dist/components/Header.js +7 -7
- package/dist/components/LogViewer.js +3 -3
- package/dist/components/MessageFeedback.js +2 -2
- package/dist/components/PromptField.js +4 -4
- package/dist/components/StatsBar.js +5 -5
- package/dist/components/SuggestionPanel.js +53 -0
- package/dist/components/UsageMenu.js +3 -3
- package/dist/helpers/appInfo.js +7 -7
- package/dist/helpers/constants.js +154 -1
- package/dist/helpers/containerOptionsBuilder.js +3 -3
- package/dist/helpers/dockerHubService.js +111 -0
- package/dist/helpers/dockerService/dockerService.js +1 -1
- package/dist/helpers/dockerService/serviceComponents/containerActions.js +99 -40
- package/dist/helpers/dockerService/serviceComponents/containerList.js +11 -11
- package/dist/helpers/dockerService/serviceComponents/containerLogs.js +9 -9
- package/dist/helpers/dockerService/serviceComponents/containerStats.js +5 -5
- package/dist/helpers/dockerService/serviceComponents/imageUtils.js +1 -1
- package/dist/helpers/exitWithMessage.js +10 -10
- package/dist/helpers/imageNameUtils.js +32 -0
- package/dist/helpers/logger.js +13 -13
- package/dist/helpers/safeCall.js +2 -2
- package/dist/helpers/validationHelpers.js +76 -21
- package/dist/hooks/creation/useContainerActions.js +28 -11
- package/dist/hooks/creation/useContainerCreation.js +452 -62
- package/dist/hooks/creation/useLogsViewer.js +2 -2
- package/dist/hooks/debug/useDebugLogs.js +4 -4
- package/dist/hooks/useContainerCommandRouter.js +14 -14
- package/dist/hooks/useContainers.js +3 -3
- package/dist/hooks/useControls.js +85 -38
- package/dist/hooks/useEraseConfirmation.js +3 -3
- package/dist/index.js +9 -2
- package/jest.config.cjs +7 -0
- package/package.json +7 -3
- package/src/App.jsx +39 -16
- package/src/components/ContainerCreationPrompt.jsx +54 -10
- package/src/components/ContainerList.jsx +3 -3
- package/src/components/ContainerRow.jsx +19 -16
- package/src/components/ContainerSection.jsx +3 -3
- package/src/components/ControlsHUD.jsx +66 -0
- package/src/components/Footer.jsx +2 -2
- package/src/components/Header.jsx +7 -7
- package/src/components/LogViewer.jsx +5 -3
- package/src/components/MessageFeedback.jsx +2 -2
- package/src/components/PromptField.jsx +4 -4
- package/src/components/StatsBar.jsx +11 -7
- package/src/components/SuggestionPanel.jsx +61 -0
- package/src/components/UsageMenu.jsx +18 -13
- package/src/helpers/appInfo.js +7 -7
- package/src/helpers/constants.js +48 -9
- package/src/helpers/containerOptionsBuilder.js +16 -5
- package/src/helpers/dockerHubService.js +75 -0
- package/src/helpers/dockerService/dockerService.js +1 -1
- package/src/helpers/dockerService/serviceComponents/containerActions.js +87 -61
- package/src/helpers/dockerService/serviceComponents/containerList.js +31 -25
- package/src/helpers/dockerService/serviceComponents/containerLogs.js +41 -26
- package/src/helpers/dockerService/serviceComponents/containerStats.js +13 -11
- package/src/helpers/dockerService/serviceComponents/imageUtils.js +5 -4
- package/src/helpers/exitWithMessage.js +13 -7
- package/src/helpers/imageNameUtils.js +32 -0
- package/src/helpers/logger.js +15 -16
- package/src/helpers/safeCall.js +2 -2
- package/src/helpers/validationHelpers.js +13 -29
- package/src/hooks/creation/useContainerActions.js +32 -12
- package/src/hooks/creation/useContainerCreation.js +293 -47
- package/src/hooks/creation/useLogsViewer.js +2 -2
- package/src/hooks/debug/useDebugLogs.js +15 -10
- package/src/hooks/navigation/useContainerSelection.js +17 -14
- package/src/hooks/useContainerCommandRouter.js +71 -58
- package/src/hooks/useContainers.js +4 -4
- package/src/hooks/useControls.js +135 -72
- package/src/hooks/useEraseConfirmation.js +3 -3
- package/src/hooks/useExitHandler.js +29 -26
- package/src/index.js +8 -4
- package/test/ContainerCreationPrompt.dom.test.js +313 -0
- package/test/ControlsHUD.dom.test.js +125 -0
- package/test/SuggestionPanel.dom.test.js +83 -0
- package/test/constants.test.js +41 -0
- package/test/dockerHubService.test.js +213 -0
- package/test/logger.test.js +58 -0
- package/test/resolveImageTag.test.js +26 -0
- package/test/useContainerCreation.dom.test.js +428 -2
- package/test/useContainerCreationHub.dom.test.js +184 -0
- package/test/useControls.dom.test.js +406 -4
|
@@ -3,8 +3,18 @@
|
|
|
3
3
|
*/
|
|
4
4
|
import React, { useEffect } from 'react';
|
|
5
5
|
import { render, act } from '@testing-library/react';
|
|
6
|
-
import { useContainerCreation } from '../src/hooks/creation/useContainerCreation.js';
|
|
7
6
|
import { IMAGE_PROFILES } from '../src/helpers/constants.js';
|
|
7
|
+
import { jest } from '@jest/globals';
|
|
8
|
+
|
|
9
|
+
// Mock dockerHubService BEFORE any other import that might pull it in
|
|
10
|
+
jest.unstable_mockModule('../src/helpers/dockerHubService.js', () => ({
|
|
11
|
+
searchDockerHub: jest.fn(),
|
|
12
|
+
formatHubResult: jest.fn((r) => r.name), // identity-like stub: returns name string
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
// Dynamic import resolves AFTER jest.unstable_mockModule is registered
|
|
16
|
+
const { useContainerCreation } = await import('../src/hooks/creation/useContainerCreation.js');
|
|
17
|
+
const { searchDockerHub } = await import('../src/helpers/dockerHubService.js');
|
|
8
18
|
|
|
9
19
|
function HookTester({ onCreate, onCancel, dbImages, imageProfiles, expose }) {
|
|
10
20
|
const hook = useContainerCreation({ onCreate, onCancel, dbImages, imageProfiles });
|
|
@@ -96,7 +106,7 @@ describe('useContainerCreation (DOM render)', () => {
|
|
|
96
106
|
});
|
|
97
107
|
|
|
98
108
|
expect(created.length).toBe(1);
|
|
99
|
-
expect(created[0]).toEqual({ imageName: 'redis', containerName: '', portInput: '', envInput: 'FOO=bar' });
|
|
109
|
+
expect(created[0]).toEqual({ imageName: 'redis:7-alpine', containerName: '', portInput: '', envInput: 'FOO=bar' });
|
|
100
110
|
});
|
|
101
111
|
|
|
102
112
|
test('step 3 with mysql image: missing MYSQL_ROOT_PASSWORD blocks creation', () => {
|
|
@@ -139,3 +149,419 @@ describe('useContainerCreation (DOM render)', () => {
|
|
|
139
149
|
expect(created[0].imageName).toBe('mysql:8');
|
|
140
150
|
});
|
|
141
151
|
});
|
|
152
|
+
|
|
153
|
+
describe('useContainerCreation — suggestions & updateImageInput()', () => {
|
|
154
|
+
test('hook exposes suggestions, selectedSuggestionIndex, visibleOffset', () => {
|
|
155
|
+
const expose = { current: null };
|
|
156
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
157
|
+
expect(Array.isArray(expose.current.suggestions)).toBe(true);
|
|
158
|
+
expect(typeof expose.current.selectedSuggestionIndex).toBe('number');
|
|
159
|
+
expect(typeof expose.current.visibleOffset).toBe('number');
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
test('updateImageInput() with "ng" filters IMAGE_PROFILES keys containing "ng"', () => {
|
|
163
|
+
const expose = { current: null };
|
|
164
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
165
|
+
|
|
166
|
+
act(() => { expose.current.updateImageInput('ng'); });
|
|
167
|
+
|
|
168
|
+
expect(expose.current.imageName).toBe('ng');
|
|
169
|
+
expect(expose.current.suggestions).toContain('nginx');
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('updateImageInput() with empty string clears suggestions', () => {
|
|
173
|
+
const expose = { current: null };
|
|
174
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
175
|
+
|
|
176
|
+
act(() => { expose.current.updateImageInput('ng'); });
|
|
177
|
+
act(() => { expose.current.updateImageInput(''); });
|
|
178
|
+
|
|
179
|
+
expect(expose.current.suggestions).toHaveLength(0);
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
test('updateImageInput() resets selectedSuggestionIndex to -1', () => {
|
|
183
|
+
const expose = { current: null };
|
|
184
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
185
|
+
|
|
186
|
+
act(() => { expose.current.updateImageInput('ng'); });
|
|
187
|
+
|
|
188
|
+
expect(expose.current.selectedSuggestionIndex).toBe(-1);
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test('step-3 message includes suggestedEnv hints when profile has them', () => {
|
|
192
|
+
const expose = { current: null };
|
|
193
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
194
|
+
|
|
195
|
+
act(() => { expose.current.setImageName('postgres'); });
|
|
196
|
+
act(() => { expose.current.nextStep(); }); // → step 1
|
|
197
|
+
act(() => { expose.current.nextStep(); }); // → step 2
|
|
198
|
+
act(() => { expose.current.nextStep(); }); // → step 3
|
|
199
|
+
|
|
200
|
+
expect(expose.current.message).toMatch(/POSTGRES_PASSWORD/i);
|
|
201
|
+
expect(expose.current.message).toMatch(/POSTGRES_USER/i);
|
|
202
|
+
});
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
describe('useContainerCreation — moveSuggestionSelection() & applyFocusedSuggestion()', () => {
|
|
206
|
+
test('moveSuggestionSelection(1) increases selectedSuggestionIndex', () => {
|
|
207
|
+
const expose = { current: null };
|
|
208
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
209
|
+
|
|
210
|
+
act(() => { expose.current.updateImageInput('m'); });
|
|
211
|
+
// There should be several suggestions with "m"
|
|
212
|
+
act(() => { expose.current.moveSuggestionSelection(1); });
|
|
213
|
+
|
|
214
|
+
expect(expose.current.selectedSuggestionIndex).toBe(0);
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('moveSuggestionSelection() clamps at last item', () => {
|
|
218
|
+
const expose = { current: null };
|
|
219
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
220
|
+
|
|
221
|
+
act(() => { expose.current.updateImageInput('m'); });
|
|
222
|
+
const count = expose.current.suggestions.length;
|
|
223
|
+
|
|
224
|
+
// Move past end
|
|
225
|
+
for (let i = 0; i <= count + 2; i++) {
|
|
226
|
+
act(() => { expose.current.moveSuggestionSelection(1); });
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
expect(expose.current.selectedSuggestionIndex).toBe(count - 1);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
test('moveSuggestionSelection(-1) clamps at -1 (no selection)', () => {
|
|
233
|
+
const expose = { current: null };
|
|
234
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
235
|
+
|
|
236
|
+
act(() => { expose.current.updateImageInput('m'); });
|
|
237
|
+
act(() => { expose.current.moveSuggestionSelection(-1); });
|
|
238
|
+
|
|
239
|
+
expect(expose.current.selectedSuggestionIndex).toBe(-1);
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
test('applyFocusedSuggestion() fills imageName with focused suggestion', () => {
|
|
243
|
+
const expose = { current: null };
|
|
244
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
245
|
+
|
|
246
|
+
act(() => { expose.current.updateImageInput('ng'); });
|
|
247
|
+
act(() => { expose.current.moveSuggestionSelection(1); }); // select first
|
|
248
|
+
act(() => { expose.current.applyFocusedSuggestion(); });
|
|
249
|
+
|
|
250
|
+
expect(expose.current.imageName).toBe('nginx:1.27-alpine');
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
test('applyFocusedSuggestion() does NOT advance step', () => {
|
|
254
|
+
const expose = { current: null };
|
|
255
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
256
|
+
|
|
257
|
+
act(() => { expose.current.updateImageInput('ng'); });
|
|
258
|
+
act(() => { expose.current.moveSuggestionSelection(1); });
|
|
259
|
+
act(() => { expose.current.applyFocusedSuggestion(); });
|
|
260
|
+
|
|
261
|
+
expect(expose.current.step).toBe(0);
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
test('applyFocusedSuggestion() clears suggestions after applying', () => {
|
|
265
|
+
const expose = { current: null };
|
|
266
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
267
|
+
|
|
268
|
+
act(() => { expose.current.updateImageInput('ng'); });
|
|
269
|
+
act(() => { expose.current.moveSuggestionSelection(1); });
|
|
270
|
+
act(() => { expose.current.applyFocusedSuggestion(); });
|
|
271
|
+
|
|
272
|
+
expect(expose.current.suggestions).toHaveLength(0);
|
|
273
|
+
});
|
|
274
|
+
|
|
275
|
+
test('applyFocusedSuggestion() preserves explicit tag when suggestion already has one', () => {
|
|
276
|
+
const expose = { current: null };
|
|
277
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
278
|
+
|
|
279
|
+
// Manually set imageName with a tag and simulate a suggestion with tag
|
|
280
|
+
act(() => { expose.current.updateImageInput('pg'); });
|
|
281
|
+
// inject a suggestion with a tag by directly calling with known value
|
|
282
|
+
// Instead: use updateImageInput then manually apply — but suggestions come from profiles (no tag)
|
|
283
|
+
// So test that if imageName already has ':' when applyFocusedSuggestion runs, it is not double-tagged
|
|
284
|
+
// The real scenario: user types 'postgres:15', selects suggestion — but suggestions list only contains 'postgres'
|
|
285
|
+
// Better test: set imageName to 'postgres:15' via setImageName then call nextStep on step 0 → tag preserved
|
|
286
|
+
act(() => { expose.current.setImageName('postgres:15'); });
|
|
287
|
+
act(() => { expose.current.nextStep(); }); // step 0 → should advance with postgres:15 preserved
|
|
288
|
+
|
|
289
|
+
expect(expose.current.imageName).toBe('postgres:15');
|
|
290
|
+
expect(expose.current.step).toBe(1);
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
describe('useContainerCreation — resolveImageTag on nextStep & applyFocusedSuggestion', () => {
|
|
295
|
+
test('nextStep at step 0 with bare image name appends defaultTag', () => {
|
|
296
|
+
const expose = { current: null };
|
|
297
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
298
|
+
|
|
299
|
+
act(() => { expose.current.setImageName('postgres'); });
|
|
300
|
+
act(() => { expose.current.nextStep(); });
|
|
301
|
+
|
|
302
|
+
expect(expose.current.imageName).toBe('postgres:17-alpine');
|
|
303
|
+
expect(expose.current.step).toBe(1);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
test('nextStep at step 0 with explicit tag preserves it', () => {
|
|
307
|
+
const expose = { current: null };
|
|
308
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
309
|
+
|
|
310
|
+
act(() => { expose.current.setImageName('postgres:15'); });
|
|
311
|
+
act(() => { expose.current.nextStep(); });
|
|
312
|
+
|
|
313
|
+
expect(expose.current.imageName).toBe('postgres:15');
|
|
314
|
+
expect(expose.current.step).toBe(1);
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
|
|
318
|
+
describe('useContainerCreation — insertNextSuggestedEnv() & hasSuggestedEnv', () => {
|
|
319
|
+
test('insertNextSuggestedEnv is exposed from hook', () => {
|
|
320
|
+
const expose = { current: null };
|
|
321
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
322
|
+
expect(typeof expose.current.insertNextSuggestedEnv).toBe('function');
|
|
323
|
+
});
|
|
324
|
+
|
|
325
|
+
test('hasSuggestedEnv is true when profile has suggestedEnv entries (postgres)', () => {
|
|
326
|
+
const expose = { current: null };
|
|
327
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
328
|
+
act(() => { expose.current.setImageName('postgres:17-alpine'); });
|
|
329
|
+
expect(expose.current.hasSuggestedEnv).toBe(true);
|
|
330
|
+
});
|
|
331
|
+
|
|
332
|
+
test('hasSuggestedEnv is false when image has no suggestedEnv (nginx)', () => {
|
|
333
|
+
const expose = { current: null };
|
|
334
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
335
|
+
act(() => { expose.current.setImageName('nginx:1.27-alpine'); });
|
|
336
|
+
expect(expose.current.hasSuggestedEnv).toBe(false);
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
test('calling insertNextSuggestedEnv with postgres image inserts first suggested env', () => {
|
|
340
|
+
const expose = { current: null };
|
|
341
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
342
|
+
// advance to step 3 with postgres
|
|
343
|
+
act(() => { expose.current.setImageName('postgres'); });
|
|
344
|
+
act(() => { expose.current.nextStep(); }); // → step 1 (resolves tag)
|
|
345
|
+
act(() => { expose.current.nextStep(); }); // → step 2
|
|
346
|
+
act(() => { expose.current.nextStep(); }); // → step 3
|
|
347
|
+
|
|
348
|
+
act(() => { expose.current.insertNextSuggestedEnv(); });
|
|
349
|
+
// envInput should contain the first suggestedEnv for postgres (required var first)
|
|
350
|
+
expect(expose.current.envInput).toBe('POSTGRES_PASSWORD=secret');
|
|
351
|
+
});
|
|
352
|
+
|
|
353
|
+
test('calling insertNextSuggestedEnv again inserts the next pending suggestion', () => {
|
|
354
|
+
const expose = { current: null };
|
|
355
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
356
|
+
act(() => { expose.current.setImageName('postgres'); });
|
|
357
|
+
act(() => { expose.current.nextStep(); });
|
|
358
|
+
act(() => { expose.current.nextStep(); });
|
|
359
|
+
act(() => { expose.current.nextStep(); });
|
|
360
|
+
|
|
361
|
+
act(() => { expose.current.insertNextSuggestedEnv(); }); // inserts first
|
|
362
|
+
act(() => { expose.current.insertNextSuggestedEnv(); }); // inserts second
|
|
363
|
+
expect(expose.current.envInput).toBe('POSTGRES_PASSWORD=secret,POSTGRES_USER=postgres');
|
|
364
|
+
});
|
|
365
|
+
|
|
366
|
+
test('calling insertNextSuggestedEnv when all already added sets feedback message', () => {
|
|
367
|
+
const expose = { current: null };
|
|
368
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
369
|
+
act(() => { expose.current.setImageName('postgres'); });
|
|
370
|
+
act(() => { expose.current.nextStep(); });
|
|
371
|
+
act(() => { expose.current.nextStep(); });
|
|
372
|
+
act(() => { expose.current.nextStep(); });
|
|
373
|
+
|
|
374
|
+
// manually set all suggested envs
|
|
375
|
+
const profile = IMAGE_PROFILES['postgres'];
|
|
376
|
+
act(() => { expose.current.setEnvInput(profile.suggestedEnv.join(',')); });
|
|
377
|
+
|
|
378
|
+
act(() => { expose.current.insertNextSuggestedEnv(); });
|
|
379
|
+
expect(expose.current.message).toBe('All suggested env vars added');
|
|
380
|
+
// envInput unchanged
|
|
381
|
+
expect(expose.current.envInput).toBe(profile.suggestedEnv.join(','));
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
test('if user manually typed a key matching a suggestion, Tab skips that key', () => {
|
|
385
|
+
const expose = { current: null };
|
|
386
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
387
|
+
act(() => { expose.current.setImageName('postgres'); });
|
|
388
|
+
act(() => { expose.current.nextStep(); });
|
|
389
|
+
act(() => { expose.current.nextStep(); });
|
|
390
|
+
act(() => { expose.current.nextStep(); });
|
|
391
|
+
|
|
392
|
+
// user manually typed first suggestion key with different value
|
|
393
|
+
act(() => { expose.current.setEnvInput('POSTGRES_PASSWORD=mypass'); });
|
|
394
|
+
|
|
395
|
+
act(() => { expose.current.insertNextSuggestedEnv(); });
|
|
396
|
+
// should skip POSTGRES_PASSWORD and insert the next one (POSTGRES_USER)
|
|
397
|
+
expect(expose.current.envInput).toContain('POSTGRES_USER=postgres');
|
|
398
|
+
expect(expose.current.envInput).not.toContain('POSTGRES_PASSWORD=secret');
|
|
399
|
+
});
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
describe('useContainerCreation — triggerHubSearch()', () => {
|
|
403
|
+
beforeEach(() => {
|
|
404
|
+
searchDockerHub.mockReset();
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test('triggerHubSearch is exposed from the hook', () => {
|
|
408
|
+
const expose = { current: null };
|
|
409
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
410
|
+
|
|
411
|
+
expect(typeof expose.current.triggerHubSearch).toBe('function');
|
|
412
|
+
});
|
|
413
|
+
|
|
414
|
+
test('calling triggerHubSearch() sets isSearchingHub=true while searching, then false after resolve', async () => {
|
|
415
|
+
let resolveSearch;
|
|
416
|
+
searchDockerHub.mockImplementation(
|
|
417
|
+
() => new Promise((resolve) => { resolveSearch = resolve; })
|
|
418
|
+
);
|
|
419
|
+
|
|
420
|
+
const expose = { current: null };
|
|
421
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
422
|
+
|
|
423
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
424
|
+
|
|
425
|
+
// Trigger search (async, don't await yet)
|
|
426
|
+
act(() => { expose.current.triggerHubSearch(); });
|
|
427
|
+
|
|
428
|
+
expect(expose.current.isSearchingHub).toBe(true);
|
|
429
|
+
|
|
430
|
+
// Resolve the mock search
|
|
431
|
+
const results = [{ name: 'nginx', isOfficial: true, pullCount: 1000, description: 'nginx' }];
|
|
432
|
+
await act(async () => { resolveSearch(results); });
|
|
433
|
+
|
|
434
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
435
|
+
// hubResults is string[] after formatHubResult mapping (mock returns r.name)
|
|
436
|
+
expect(expose.current.hubResults).toEqual(['nginx']);
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
test('after search resolves, hubResults is set and isSearchingHub is false', async () => {
|
|
440
|
+
const results = [{ name: 'nginx', isOfficial: true, pullCount: 500, description: '' }];
|
|
441
|
+
searchDockerHub.mockResolvedValue(results);
|
|
442
|
+
|
|
443
|
+
const expose = { current: null };
|
|
444
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
445
|
+
|
|
446
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
447
|
+
|
|
448
|
+
await act(async () => { await expose.current.triggerHubSearch(); });
|
|
449
|
+
|
|
450
|
+
// hubResults is string[] after formatHubResult mapping (mock returns r.name)
|
|
451
|
+
expect(expose.current.hubResults).toEqual(['nginx']);
|
|
452
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
453
|
+
});
|
|
454
|
+
|
|
455
|
+
test('calling triggerHubSearch() when isSearchingHub===true is a no-op (concurrent guard)', async () => {
|
|
456
|
+
let resolveFirst;
|
|
457
|
+
searchDockerHub.mockImplementation(
|
|
458
|
+
() => new Promise((resolve) => { resolveFirst = resolve; })
|
|
459
|
+
);
|
|
460
|
+
|
|
461
|
+
const expose = { current: null };
|
|
462
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
463
|
+
|
|
464
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
465
|
+
act(() => { expose.current.triggerHubSearch(); }); // first call
|
|
466
|
+
|
|
467
|
+
expect(expose.current.isSearchingHub).toBe(true);
|
|
468
|
+
expect(searchDockerHub).toHaveBeenCalledTimes(1);
|
|
469
|
+
|
|
470
|
+
// Second call while still searching should be a no-op
|
|
471
|
+
act(() => { expose.current.triggerHubSearch(); });
|
|
472
|
+
|
|
473
|
+
expect(searchDockerHub).toHaveBeenCalledTimes(1); // still only once
|
|
474
|
+
|
|
475
|
+
// Cleanup
|
|
476
|
+
await act(async () => { resolveFirst([]); });
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
test('calling triggerHubSearch() with empty imageName is a no-op', async () => {
|
|
480
|
+
const expose = { current: null };
|
|
481
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
482
|
+
|
|
483
|
+
// imageName is empty by default
|
|
484
|
+
await act(async () => { await expose.current.triggerHubSearch(); });
|
|
485
|
+
|
|
486
|
+
expect(searchDockerHub).not.toHaveBeenCalled();
|
|
487
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
488
|
+
});
|
|
489
|
+
|
|
490
|
+
test('typing into imageName (updateImageInput) aborts ongoing search and clears hubResults', async () => {
|
|
491
|
+
let resolveSearch;
|
|
492
|
+
searchDockerHub.mockImplementation(
|
|
493
|
+
() => new Promise((resolve) => { resolveSearch = resolve; })
|
|
494
|
+
);
|
|
495
|
+
|
|
496
|
+
const expose = { current: null };
|
|
497
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
498
|
+
|
|
499
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
500
|
+
act(() => { expose.current.triggerHubSearch(); }); // in-flight
|
|
501
|
+
|
|
502
|
+
expect(expose.current.isSearchingHub).toBe(true);
|
|
503
|
+
|
|
504
|
+
// User types more — should abort search and clear results
|
|
505
|
+
await act(async () => {
|
|
506
|
+
expose.current.updateImageInput('ngin');
|
|
507
|
+
// Resolve the (now stale) promise — it should be ignored
|
|
508
|
+
resolveSearch([{ name: 'nginx', isOfficial: true, pullCount: 100, description: '' }]);
|
|
509
|
+
});
|
|
510
|
+
|
|
511
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
512
|
+
expect(expose.current.hubResults).toBeNull();
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
|
|
516
|
+
describe('useContainerCreation — message auto-clear after timeout', () => {
|
|
517
|
+
beforeEach(() => {
|
|
518
|
+
jest.useFakeTimers();
|
|
519
|
+
});
|
|
520
|
+
|
|
521
|
+
afterEach(() => {
|
|
522
|
+
jest.runOnlyPendingTimers();
|
|
523
|
+
jest.useRealTimers();
|
|
524
|
+
});
|
|
525
|
+
|
|
526
|
+
test('error message set on nextStep clears after 4000ms', () => {
|
|
527
|
+
const expose = { current: null };
|
|
528
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} expose={expose} />);
|
|
529
|
+
|
|
530
|
+
// Trigger validation error (empty imageName)
|
|
531
|
+
act(() => {
|
|
532
|
+
expose.current.nextStep();
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
expect(expose.current.message).toBe('Image name cannot be empty.');
|
|
536
|
+
|
|
537
|
+
// Advance timers by 4000ms
|
|
538
|
+
act(() => {
|
|
539
|
+
jest.advanceTimersByTime(4000);
|
|
540
|
+
});
|
|
541
|
+
|
|
542
|
+
expect(expose.current.message).toBe('');
|
|
543
|
+
});
|
|
544
|
+
|
|
545
|
+
test('success message from insertNextSuggestedEnv clears after 4000ms', () => {
|
|
546
|
+
const expose = { current: null };
|
|
547
|
+
render(<HookTester onCreate={() => {}} onCancel={() => {}} dbImages={[]} imageProfiles={IMAGE_PROFILES} expose={expose} />);
|
|
548
|
+
|
|
549
|
+
act(() => { expose.current.setImageName('postgres'); });
|
|
550
|
+
act(() => { expose.current.nextStep(); }); // step 0 → 1
|
|
551
|
+
act(() => { expose.current.nextStep(); }); // step 1 → 2
|
|
552
|
+
act(() => { expose.current.nextStep(); }); // step 2 → 3
|
|
553
|
+
|
|
554
|
+
// Exhaust all suggested envs then call insertNextSuggestedEnv once more to get 'All suggested' message
|
|
555
|
+
const profile = IMAGE_PROFILES['postgres'];
|
|
556
|
+
act(() => { expose.current.setEnvInput(profile.suggestedEnv.join(',')); });
|
|
557
|
+
act(() => { expose.current.insertNextSuggestedEnv(); });
|
|
558
|
+
|
|
559
|
+
expect(expose.current.message).toBe('All suggested env vars added');
|
|
560
|
+
|
|
561
|
+
act(() => {
|
|
562
|
+
jest.advanceTimersByTime(4000);
|
|
563
|
+
});
|
|
564
|
+
|
|
565
|
+
expect(expose.current.message).toBe('');
|
|
566
|
+
});
|
|
567
|
+
});
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @jest-environment jsdom
|
|
3
|
+
*
|
|
4
|
+
* Tests for Hub-search additions to useContainerCreation:
|
|
5
|
+
* - isSearchingHub state
|
|
6
|
+
* - hubResults state (string[] after formatHubResult mapping)
|
|
7
|
+
* - triggerHubSearch() guards (empty input, already searching)
|
|
8
|
+
* - stale response ignored via requestId
|
|
9
|
+
* - updateImageInput() aborts in-flight search and clears hubResults
|
|
10
|
+
*/
|
|
11
|
+
import React, { useEffect } from 'react';
|
|
12
|
+
import { render, act } from '@testing-library/react';
|
|
13
|
+
import { jest } from '@jest/globals';
|
|
14
|
+
|
|
15
|
+
// ── Mock dockerHubService BEFORE any import that might pull it in ─────────────
|
|
16
|
+
jest.unstable_mockModule('../src/helpers/dockerHubService.js', () => ({
|
|
17
|
+
searchDockerHub: jest.fn(),
|
|
18
|
+
formatHubResult: jest.fn((r) => r.name), // identity-like stub: returns name string
|
|
19
|
+
}));
|
|
20
|
+
|
|
21
|
+
// Dynamic import resolves AFTER jest.unstable_mockModule is registered
|
|
22
|
+
const { useContainerCreation } = await import('../src/hooks/creation/useContainerCreation.js');
|
|
23
|
+
const { searchDockerHub } = await import('../src/helpers/dockerHubService.js');
|
|
24
|
+
|
|
25
|
+
const mockSearchDockerHub = searchDockerHub;
|
|
26
|
+
|
|
27
|
+
// ── Test harness ─────────────────────────────────────────────────────────────
|
|
28
|
+
|
|
29
|
+
function HookTester({ onCreate, onCancel, dbImages, imageProfiles, expose }) {
|
|
30
|
+
const hook = useContainerCreation({
|
|
31
|
+
onCreate: onCreate ?? (() => {}),
|
|
32
|
+
onCancel: onCancel ?? (() => {}),
|
|
33
|
+
dbImages: dbImages ?? [],
|
|
34
|
+
imageProfiles: imageProfiles ?? {},
|
|
35
|
+
});
|
|
36
|
+
useEffect(() => {
|
|
37
|
+
if (expose) expose.current = hook;
|
|
38
|
+
});
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function makeExpose() {
|
|
43
|
+
const expose = { current: null };
|
|
44
|
+
render(<HookTester expose={expose} />);
|
|
45
|
+
return expose;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// ── Tests ─────────────────────────────────────────────────────────────────────
|
|
49
|
+
|
|
50
|
+
describe('useContainerCreation — Hub search state', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
mockSearchDockerHub.mockReset();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test('exposes isSearchingHub (initially false) and hubResults (initially null)', () => {
|
|
56
|
+
const expose = makeExpose();
|
|
57
|
+
|
|
58
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
59
|
+
expect(expose.current.hubResults).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('triggerHubSearch() ignores empty imageName', async () => {
|
|
63
|
+
const expose = makeExpose();
|
|
64
|
+
// imageName is '' by default
|
|
65
|
+
await act(async () => {
|
|
66
|
+
expose.current.triggerHubSearch();
|
|
67
|
+
});
|
|
68
|
+
expect(mockSearchDockerHub).not.toHaveBeenCalled();
|
|
69
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
test('triggerHubSearch() sets isSearchingHub=true during the request', async () => {
|
|
73
|
+
const expose = makeExpose();
|
|
74
|
+
let resolveSearch;
|
|
75
|
+
mockSearchDockerHub.mockReturnValue(
|
|
76
|
+
new Promise((res) => { resolveSearch = res; })
|
|
77
|
+
);
|
|
78
|
+
|
|
79
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
80
|
+
|
|
81
|
+
let searchPromise;
|
|
82
|
+
act(() => { searchPromise = expose.current.triggerHubSearch(); });
|
|
83
|
+
|
|
84
|
+
expect(expose.current.isSearchingHub).toBe(true);
|
|
85
|
+
|
|
86
|
+
await act(async () => { resolveSearch([]); await searchPromise; });
|
|
87
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('triggerHubSearch() ignores call when isSearchingHub is already true', async () => {
|
|
91
|
+
const expose = makeExpose();
|
|
92
|
+
let resolveFirst;
|
|
93
|
+
mockSearchDockerHub.mockReturnValue(
|
|
94
|
+
new Promise((res) => { resolveFirst = res; })
|
|
95
|
+
);
|
|
96
|
+
|
|
97
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
98
|
+
act(() => { expose.current.triggerHubSearch(); }); // first call — now searching
|
|
99
|
+
expect(expose.current.isSearchingHub).toBe(true);
|
|
100
|
+
|
|
101
|
+
// second call — should be ignored
|
|
102
|
+
act(() => { expose.current.triggerHubSearch(); });
|
|
103
|
+
expect(mockSearchDockerHub).toHaveBeenCalledTimes(1);
|
|
104
|
+
|
|
105
|
+
// cleanup
|
|
106
|
+
await act(async () => { resolveFirst([]); });
|
|
107
|
+
});
|
|
108
|
+
|
|
109
|
+
test('triggerHubSearch() stores formatted results (strings) in hubResults on success', async () => {
|
|
110
|
+
const expose = makeExpose();
|
|
111
|
+
const fakeResults = [
|
|
112
|
+
{ name: 'library/nginx', isOfficial: true, pullCount: 1e9, description: 'official' },
|
|
113
|
+
];
|
|
114
|
+
mockSearchDockerHub.mockResolvedValue(fakeResults);
|
|
115
|
+
|
|
116
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
117
|
+
await act(async () => { await expose.current.triggerHubSearch(); });
|
|
118
|
+
|
|
119
|
+
// hubResults should be string[] (after formatHubResult mapping)
|
|
120
|
+
expect(Array.isArray(expose.current.hubResults)).toBe(true);
|
|
121
|
+
expect(expose.current.hubResults).toHaveLength(1);
|
|
122
|
+
expect(typeof expose.current.hubResults[0]).toBe('string');
|
|
123
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test('stale response is ignored — only the latest requestId wins', async () => {
|
|
127
|
+
const expose = makeExpose();
|
|
128
|
+
|
|
129
|
+
let resolveFirst;
|
|
130
|
+
let resolveSecond;
|
|
131
|
+
|
|
132
|
+
mockSearchDockerHub
|
|
133
|
+
.mockReturnValueOnce(new Promise((res) => { resolveFirst = res; }))
|
|
134
|
+
.mockReturnValueOnce(new Promise((res) => { resolveSecond = res; }));
|
|
135
|
+
|
|
136
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
137
|
+
act(() => { expose.current.triggerHubSearch(); }); // request #1 starts, isSearchingHub=true
|
|
138
|
+
|
|
139
|
+
// Simulate user typing (aborts #1, clears hub state)
|
|
140
|
+
act(() => { expose.current.updateImageInput('ngi'); });
|
|
141
|
+
// Now trigger again (request #2)
|
|
142
|
+
act(() => { expose.current.triggerHubSearch(); });
|
|
143
|
+
|
|
144
|
+
const secondResults = [{ name: 'ngi/x', isOfficial: false, pullCount: 0, description: '' }];
|
|
145
|
+
|
|
146
|
+
// Resolve second first (fresh), then first (stale)
|
|
147
|
+
await act(async () => { resolveSecond(secondResults); });
|
|
148
|
+
await act(async () => { resolveFirst([{ name: 'stale', isOfficial: false, pullCount: 0, description: '' }]); });
|
|
149
|
+
|
|
150
|
+
// Only second results should be stored (as strings)
|
|
151
|
+
expect(Array.isArray(expose.current.hubResults)).toBe(true);
|
|
152
|
+
expect(expose.current.hubResults).toHaveLength(1);
|
|
153
|
+
expect(expose.current.hubResults[0]).toBe('ngi/x');
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test('updateImageInput() clears hubResults and sets isSearchingHub=false', async () => {
|
|
157
|
+
const expose = makeExpose();
|
|
158
|
+
mockSearchDockerHub.mockResolvedValue([
|
|
159
|
+
{ name: 'library/nginx', isOfficial: true, pullCount: 1e9, description: '' },
|
|
160
|
+
]);
|
|
161
|
+
|
|
162
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
163
|
+
await act(async () => { await expose.current.triggerHubSearch(); });
|
|
164
|
+
expect(expose.current.hubResults).not.toBeNull();
|
|
165
|
+
|
|
166
|
+
// Typing clears Hub results
|
|
167
|
+
act(() => { expose.current.updateImageInput('nginy'); });
|
|
168
|
+
expect(expose.current.hubResults).toBeNull();
|
|
169
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('failed Hub search (non-abort) sets hubResults=null silently', async () => {
|
|
173
|
+
const expose = makeExpose();
|
|
174
|
+
mockSearchDockerHub.mockRejectedValue(new Error('Network Error'));
|
|
175
|
+
|
|
176
|
+
act(() => { expose.current.updateImageInput('nginx'); });
|
|
177
|
+
await act(async () => {
|
|
178
|
+
try { await expose.current.triggerHubSearch(); } catch (_) { /* silenced */ }
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
expect(expose.current.hubResults).toBeNull();
|
|
182
|
+
expect(expose.current.isSearchingHub).toBe(false);
|
|
183
|
+
});
|
|
184
|
+
});
|