cdd-cli 3.1.9 → 3.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (45) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/coverage/clover.xml +604 -0
  3. package/coverage/coverage-final.json +17 -0
  4. package/coverage/lcov-report/base.css +224 -0
  5. package/coverage/lcov-report/block-navigation.js +87 -0
  6. package/coverage/lcov-report/favicon.png +0 -0
  7. package/coverage/lcov-report/helpers/constants.js.html +235 -0
  8. package/coverage/lcov-report/helpers/containerOptionsBuilder.js.html +211 -0
  9. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerActions.js.html +853 -0
  10. package/coverage/lcov-report/helpers/dockerService/serviceComponents/containerList.js.html +289 -0
  11. package/coverage/lcov-report/helpers/dockerService/serviceComponents/index.html +131 -0
  12. package/coverage/lcov-report/helpers/index.html +176 -0
  13. package/coverage/lcov-report/helpers/logger.js.html +424 -0
  14. package/coverage/lcov-report/helpers/safeCall.js.html +139 -0
  15. package/coverage/lcov-report/helpers/validationHelpers.js.html +394 -0
  16. package/coverage/lcov-report/hooks/creation/index.html +146 -0
  17. package/coverage/lcov-report/hooks/creation/useContainerActions.js.html +292 -0
  18. package/coverage/lcov-report/hooks/creation/useContainerCreation.js.html +478 -0
  19. package/coverage/lcov-report/hooks/creation/useLogsViewer.js.html +238 -0
  20. package/coverage/lcov-report/hooks/debug/index.html +116 -0
  21. package/coverage/lcov-report/hooks/debug/useDebugLogs.js.html +172 -0
  22. package/coverage/lcov-report/hooks/index.html +161 -0
  23. package/coverage/lcov-report/hooks/navigation/index.html +116 -0
  24. package/coverage/lcov-report/hooks/navigation/useContainerSelection.js.html +160 -0
  25. package/coverage/lcov-report/hooks/useContainerCommandRouter.js.html +415 -0
  26. package/coverage/lcov-report/hooks/useControls.js.html +685 -0
  27. package/coverage/lcov-report/hooks/useEraseConfirmation.js.html +202 -0
  28. package/coverage/lcov-report/hooks/useExitHandler.js.html +283 -0
  29. package/coverage/lcov-report/index.html +191 -0
  30. package/coverage/lcov-report/prettify.css +1 -0
  31. package/coverage/lcov-report/prettify.js +2 -0
  32. package/coverage/lcov-report/sort-arrow-sprite.png +0 -0
  33. package/coverage/lcov-report/sorter.js +210 -0
  34. package/coverage/lcov.info +1216 -0
  35. package/package.json +1 -1
  36. package/src/helpers/constants.js +20 -0
  37. package/src/helpers/dockerService/serviceComponents/containerActions.js +55 -8
  38. package/src/helpers/validationHelpers.js +75 -18
  39. package/src/hooks/creation/useContainerCreation.js +19 -4
  40. package/src/hooks/useControls.js +8 -3
  41. package/test/containerActions.test.js +104 -13
  42. package/test/containerOptionsBuilder.test.js +36 -0
  43. package/test/useContainerCreation.dom.test.js +43 -2
  44. package/test/useControls.dom.test.js +117 -0
  45. package/test/validationHelpers.test.js +51 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cdd-cli",
3
- "version": "3.1.9",
3
+ "version": "3.2.0",
4
4
  "description": "CLI Docker Dashboard",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -28,3 +28,23 @@ export const TIMEOUTS = {
28
28
  CONTAINER_OP: 30000,
29
29
  PULL_IMAGE: 300000,
30
30
  };
31
+
32
+ /**
33
+ * List of well-known database image names (without tags or registry prefixes).
34
+ */
35
+ export const DB_IMAGES = ["mysql", "mariadb", "postgres", "mongo", "mssql", "redis"];
36
+
37
+ /**
38
+ * Per-image profiles with required env vars and default exposed port.
39
+ * Keys are normalized image names (no tag, no registry prefix).
40
+ *
41
+ * @type {Record<string, { requiredEnv: string[], defaultPort: string }>}
42
+ */
43
+ export const IMAGE_PROFILES = {
44
+ mysql: { requiredEnv: ["MYSQL_ROOT_PASSWORD"], defaultPort: "3306" },
45
+ mariadb: { requiredEnv: ["MARIADB_ROOT_PASSWORD"], defaultPort: "3306" },
46
+ postgres: { requiredEnv: ["POSTGRES_PASSWORD"], defaultPort: "5432" },
47
+ mongo: { requiredEnv: [], defaultPort: "27017" },
48
+ mssql: { requiredEnv: ["ACCEPT_EULA", "SA_PASSWORD"], defaultPort: "1433" },
49
+ redis: { requiredEnv: [], defaultPort: "6379" },
50
+ };
@@ -1,6 +1,6 @@
1
1
  import { docker } from "../dockerService.js";
2
2
  import { imageExists, pullImage } from "./imageUtils.js";
3
- import { TIMEOUTS } from "../../constants.js";
3
+ import { TIMEOUTS, IMAGE_PROFILES } from "../../constants.js";
4
4
  import { logger } from "../../logger.js";
5
5
 
6
6
  /**
@@ -40,15 +40,33 @@ export async function removeContainer(containerId) {
40
40
  }
41
41
  }
42
42
 
43
+ /**
44
+ * Normalize image name to base name (strips registry prefix and tag).
45
+ * e.g. "docker.io/library/postgres:16" → "postgres"
46
+ *
47
+ * @param {string} imageName - Raw image name
48
+ * @returns {string} Base name (lowercase)
49
+ */
50
+ function normalizeImageName(imageName) {
51
+ if (!imageName) return "";
52
+ let name = imageName;
53
+ const slashIdx = name.lastIndexOf("/");
54
+ if (slashIdx !== -1) name = name.slice(slashIdx + 1);
55
+ const colonIdx = name.indexOf(":");
56
+ if (colonIdx !== -1) name = name.slice(0, colonIdx);
57
+ return name.toLowerCase();
58
+ }
59
+
43
60
  /**
44
61
  * Create a new container from an image. If the image is missing locally, it will be pulled.
45
62
  *
46
63
  * @param {string} imageName - Image name (e.g. 'nginx:alpine')
47
64
  * @param {Object} [options] - Docker create options (Env, ExposedPorts, HostConfig, name, etc.)
48
- * @returns {Promise<string>} The created container id
65
+ * @param {Object} [imageProfiles] - Image profiles map for defaultPort fallback
66
+ * @returns {Promise<{id: string, ports: Array<{containerPort: string, hostPort: string, protocol: string, source: string}>}>}
49
67
  * @throws {Error} If image listing/pull or creation fails
50
68
  */
51
- export async function createContainer(imageName, options = {}) {
69
+ export async function createContainer(imageName, options = {}, imageProfiles = IMAGE_PROFILES) {
52
70
  logger.info("Creating container from image %s", imageName);
53
71
  let exists;
54
72
  try {
@@ -75,6 +93,9 @@ export async function createContainer(imageName, options = {}) {
75
93
 
76
94
  const hasUserDefinedPorts = Boolean(createOpts.ExposedPorts && Object.keys(createOpts.ExposedPorts).length);
77
95
 
96
+ /** @type {Array<{containerPort: string, hostPort: string, protocol: string, source: string}>} */
97
+ const assignedPorts = [];
98
+
78
99
  if (!hasUserDefinedPorts) {
79
100
  try {
80
101
  const image = docker.getImage(imageName);
@@ -101,7 +122,6 @@ export async function createContainer(imageName, options = {}) {
101
122
  const pickNextAvailablePort = (base) => {
102
123
  const numericBase = Number.parseInt(base, 10);
103
124
  if (Number.isNaN(numericBase)) {
104
- // If the base port is non-numeric, just return the base.
105
125
  if (!usedHostPorts.has(base)) {
106
126
  usedHostPorts.add(base);
107
127
  return base;
@@ -124,8 +144,20 @@ export async function createContainer(imageName, options = {}) {
124
144
  };
125
145
 
126
146
  const inspectData = await withTimeout(image.inspect(), TIMEOUTS.CONTAINER_OP);
127
- const exposed = inspectData?.Config?.ExposedPorts || inspectData?.ContainerConfig?.ExposedPorts || {};
128
- const exposedKeys = Object.keys(exposed || {});
147
+ let exposed = inspectData?.Config?.ExposedPorts || inspectData?.ContainerConfig?.ExposedPorts || {};
148
+ let exposedKeys = Object.keys(exposed || {});
149
+
150
+ // If no ExposedPorts in image, fall back to IMAGE_PROFILES defaultPort
151
+ if (!exposedKeys.length) {
152
+ const baseName = normalizeImageName(imageName);
153
+ const profile = imageProfiles[baseName];
154
+ if (profile && profile.defaultPort) {
155
+ const fallbackKey = `${profile.defaultPort}/tcp`;
156
+ exposed = { [fallbackKey]: {} };
157
+ exposedKeys = [fallbackKey];
158
+ }
159
+ }
160
+
129
161
  if (exposedKeys.length) {
130
162
  createOpts.ExposedPorts = createOpts.ExposedPorts || {};
131
163
  createOpts.HostConfig = { ...(createOpts.HostConfig || {}) };
@@ -135,9 +167,12 @@ export async function createContainer(imageName, options = {}) {
135
167
  createOpts.ExposedPorts[portKey] = exposed[portKey] || {};
136
168
  }
137
169
  if (!createOpts.HostConfig.PortBindings[portKey] || !createOpts.HostConfig.PortBindings[portKey].length) {
138
- const containerPort = portKey.split("/")[0];
170
+ const parts = portKey.split("/");
171
+ const containerPort = parts[0];
172
+ const protocol = parts[1] || "tcp";
139
173
  const hostPort = pickNextAvailablePort(containerPort);
140
174
  createOpts.HostConfig.PortBindings[portKey] = [{ HostPort: hostPort }];
175
+ assignedPorts.push({ containerPort, hostPort, protocol, source: "auto" });
141
176
  }
142
177
  });
143
178
  }
@@ -145,12 +180,24 @@ export async function createContainer(imageName, options = {}) {
145
180
  } catch (err) {
146
181
  logger.warn("Could not inspect image %s for default ports", imageName, err);
147
182
  }
183
+ } else {
184
+ // User-defined ports: collect them with source: "user"
185
+ const portBindings = createOpts.HostConfig?.PortBindings || {};
186
+ Object.entries(portBindings).forEach(([portKey, bindings]) => {
187
+ const parts = portKey.split("/");
188
+ const containerPort = parts[0];
189
+ const protocol = parts[1] || "tcp";
190
+ (bindings || []).forEach((b) => {
191
+ assignedPorts.push({ containerPort, hostPort: b.HostPort, protocol, source: "user" });
192
+ });
193
+ });
148
194
  }
195
+
149
196
  try {
150
197
  const container = await withTimeout(docker.createContainer(createOpts), TIMEOUTS.CONTAINER_OP);
151
198
  const containerId = container.id || container.Id;
152
199
  logger.info("Created container %s from image %s", containerId, imageName);
153
- return container.id || container.Id;
200
+ return { id: containerId, ports: assignedPorts };
154
201
  } catch (err) {
155
202
  logger.error("Failed to create container from image %s", imageName, err);
156
203
  throw new Error('Error creating container: ' + err.message);
@@ -19,28 +19,85 @@ export function validatePorts(portInput) {
19
19
  return !invalid;
20
20
  }
21
21
 
22
+ /**
23
+ * Normalize an image name to its base name (strips registry prefix and tag).
24
+ * e.g. "docker.io/library/postgres:16-alpine" → "postgres"
25
+ *
26
+ * @param {string} imageName - Raw image name from user
27
+ * @returns {string} Base image name (lowercase)
28
+ */
29
+ function normalizeImageName(imageName) {
30
+ if (!imageName) return "";
31
+ // Remove registry prefix (anything before the last / when it contains a dot or colon before the /)
32
+ let name = imageName;
33
+ const slashIdx = name.lastIndexOf("/");
34
+ if (slashIdx !== -1) {
35
+ name = name.slice(slashIdx + 1);
36
+ }
37
+ // Remove tag
38
+ const colonIdx = name.indexOf(":");
39
+ if (colonIdx !== -1) {
40
+ name = name.slice(0, colonIdx);
41
+ }
42
+ return name.toLowerCase();
43
+ }
44
+
22
45
  /**
23
46
  * Validate environment variable input as comma-separated VAR=value pairs.
24
- * Empty or whitespace-only input is considered valid (no env vars).
25
- * Variable names must start with a letter or underscore and contain only
26
- * alphanumeric characters and underscores.
47
+ *
48
+ * When called with 3 arguments (envInput, imageName, imageProfiles), performs
49
+ * contextual validation: checks that all required env vars for the given image
50
+ * are present and non-empty. Returns an object { valid, errors, parsedEnv }.
51
+ *
52
+ * When called with 1 argument (legacy), returns a boolean for backward compatibility.
27
53
  *
28
54
  * @param {string} envInput - Comma-separated environment variable assignments
29
- * @returns {boolean} True when all env var assignments are valid, false otherwise
55
+ * @param {string} [imageName] - Optional Docker image name for contextual validation
56
+ * @param {Object} [imageProfiles] - Optional map of image profiles (from constants.js)
57
+ * @returns {boolean|{valid: boolean, errors: string[], parsedEnv: Record<string,string>}}
30
58
  */
31
- export function validateEnvVars(envInput) {
32
- if (!envInput || !envInput.trim()) return true; // Empty is valid
33
-
34
- const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
35
- const invalid = vars.find(v => {
36
- const parts = v.split("=");
37
- // Must have at least VAR=value format
38
- if (parts.length < 2) return true;
39
- const varName = parts[0].trim();
40
- // Variable names should be alphanumeric with underscores
41
- if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) return true;
42
- return false;
43
- });
59
+ export function validateEnvVars(envInput, imageName, imageProfiles) {
60
+ const contextual = imageName !== undefined && imageProfiles !== undefined;
44
61
 
45
- return !invalid;
62
+ // Parse: split on FIRST '=' only
63
+ const parsedEnv = {};
64
+ const syntaxErrors = [];
65
+
66
+ if (envInput && envInput.trim()) {
67
+ const vars = envInput.split(",").map(v => v.trim()).filter(Boolean);
68
+ for (const v of vars) {
69
+ const eqIdx = v.indexOf("=");
70
+ if (eqIdx === -1) {
71
+ syntaxErrors.push(`"${v}" is missing an '=' sign`);
72
+ continue;
73
+ }
74
+ const varName = v.slice(0, eqIdx).trim();
75
+ const varValue = v.slice(eqIdx + 1);
76
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(varName)) {
77
+ syntaxErrors.push(`"${varName}" is not a valid variable name`);
78
+ continue;
79
+ }
80
+ parsedEnv[varName] = varValue;
81
+ }
82
+ }
83
+
84
+ if (!contextual) {
85
+ // Legacy: return boolean
86
+ return syntaxErrors.length === 0;
87
+ }
88
+
89
+ // Contextual: also check required env vars from the profile
90
+ const errors = [...syntaxErrors];
91
+ const baseName = normalizeImageName(imageName);
92
+ const profile = imageProfiles[baseName];
93
+
94
+ if (profile && profile.requiredEnv && profile.requiredEnv.length) {
95
+ for (const required of profile.requiredEnv) {
96
+ if (!parsedEnv[required] || parsedEnv[required].trim() === "") {
97
+ errors.push(`Missing required env var: ${required}`);
98
+ }
99
+ }
100
+ }
101
+
102
+ return { valid: errors.length === 0, errors, parsedEnv };
46
103
  }
@@ -1,5 +1,6 @@
1
1
  import { useState } from "react";
2
- import { validatePorts } from "../../helpers/validationHelpers.js";
2
+ import { validatePorts, validateEnvVars } from "../../helpers/validationHelpers.js";
3
+ import { IMAGE_PROFILES } from "../../helpers/constants.js";
3
4
  import { safeCall } from "../../helpers/safeCall.js";
4
5
 
5
6
  /**
@@ -9,10 +10,11 @@ import { safeCall } from "../../helpers/safeCall.js";
9
10
  * @param {Object} params
10
11
  * @param {Function} params.onCreate - Callback when creation is confirmed
11
12
  * @param {Function} params.onCancel - Callback when creation is cancelled
12
- * @param {Array<string>} params.dbImages - List of DB image names for env var warning
13
+ * @param {Array<string>} params.dbImages - List of DB image names for env var warning (legacy)
14
+ * @param {Object} [params.imageProfiles] - Image profiles map for contextual validation
13
15
  * @returns {Object} Creation state, setters, and helpers
14
16
  */
15
- export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
17
+ export function useContainerCreation({ onCreate, onCancel, dbImages = [], imageProfiles = IMAGE_PROFILES }) {
16
18
  const [step, setStep] = useState(0); // 0: image, 1: name, 2: ports, 3: env
17
19
  const [imageName, setImageName] = useState("");
18
20
  const [containerName, setContainerName] = useState("");
@@ -51,7 +53,13 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
51
53
  }
52
54
  setStep(3);
53
55
  const isDb = dbImages.some(db => imageName.trim().toLowerCase().includes(db));
54
- if (isDb) {
56
+ // Check if the image has a profile with required env vars
57
+ const baseName = imageName.trim().toLowerCase().split(":")[0].split("/").pop();
58
+ const profile = imageProfiles[baseName];
59
+ if (profile && profile.requiredEnv && profile.requiredEnv.length) {
60
+ setMessage(`Required env vars for ${baseName}: ${profile.requiredEnv.join(", ")}. Enter as VAR=val,VAR2=val2`);
61
+ setMessageColor("yellow");
62
+ } else if (isDb) {
55
63
  setMessage("Warning: This image usually requires environment variables (e.g. MYSQL_ROOT_PASSWORD=my-secret-pw for MySQL, POSTGRES_PASSWORD=yourpassword for Postgres). Enter them as VAR=val,VAR2=val2 or leave empty and press Enter.");
56
64
  setMessageColor("yellow");
57
65
  } else {
@@ -61,6 +69,13 @@ export function useContainerCreation({ onCreate, onCancel, dbImages = [] }) {
61
69
  return;
62
70
  }
63
71
  if (step === 3) {
72
+ // Contextual env validation using image profiles
73
+ const result = validateEnvVars(envInput, imageName, imageProfiles);
74
+ if (!result.valid) {
75
+ setMessage(result.errors.join(" | "));
76
+ setMessageColor("red");
77
+ return;
78
+ }
64
79
  // Final step: call onCreate with all data (safely)
65
80
  safeCall(onCreate, { imageName, containerName, portInput, envInput });
66
81
  }
@@ -11,6 +11,7 @@ import { useContainerCommandRouter } from "./useContainerCommandRouter";
11
11
  import { getLogsStream } from "../helpers/dockerService/serviceComponents/containerLogs.js";
12
12
  import { createContainer as svcCreateContainer } from "../helpers/dockerService/serviceComponents/containerActions.js";
13
13
  import { buildContainerOptions } from "../helpers/containerOptionsBuilder.js";
14
+ import { DB_IMAGES } from "../helpers/constants.js";
14
15
 
15
16
  // Principal hook to manage user inputs and control the app state
16
17
  /**
@@ -32,8 +33,12 @@ export function useControls(containers = []) {
32
33
  actions.setMessage(`Creating container ${imageName}...`);
33
34
  actions.setMessageColor("yellow");
34
35
  try {
35
- const id = await svcCreateContainer(imageName, options);
36
- actions.setMessage(`Created container ${id}`);
36
+ const { id, ports } = await svcCreateContainer(imageName, options);
37
+ let portMsg = "";
38
+ if (ports && ports.length) {
39
+ portMsg = " | Ports: " + ports.map(p => `${p.hostPort}→${p.containerPort}/${p.protocol}`).join(", ");
40
+ }
41
+ actions.setMessage(`Created container ${id}${portMsg}`);
37
42
  actions.setMessageColor("green");
38
43
  } catch (err) {
39
44
  actions.setMessage(`Error creating container: ${err.message}`);
@@ -43,7 +48,7 @@ export function useControls(containers = []) {
43
48
  }
44
49
  },
45
50
  onCancel: () => setCreatingContainer(false),
46
- dbImages: ["mysql", "mariadb", "postgres", "mongo", "mssql", "redis"],
51
+ dbImages: DB_IMAGES,
47
52
  });
48
53
 
49
54
  const logsViewer = useLogsViewer();
@@ -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,44 @@ 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
+ });
100
141
  });