Skip to main content

testcontainers/core/image/
image_ext.rs

1use std::{sync::Arc, time::Duration};
2
3#[cfg(feature = "device-requests")]
4use bollard::models::DeviceRequest;
5use bollard::models::{HostConfig, ResourcesUlimits};
6
7use crate::{
8    core::{
9        copy::{CopyDataSource, CopyTargetOptions, CopyToContainer},
10        healthcheck::Healthcheck,
11        logs::consumer::LogConsumer,
12        CgroupnsMode, ContainerPort, Host, Mount, PortMapping, WaitFor,
13    },
14    ContainerRequest, Image,
15};
16
17#[cfg(feature = "reusable-containers")]
18#[derive(Eq, Copy, Clone, Debug, Default, PartialEq)]
19pub enum ReuseDirective {
20    #[default]
21    Never,
22    Always,
23    CurrentSession,
24}
25
26#[cfg(feature = "reusable-containers")]
27impl std::fmt::Display for ReuseDirective {
28    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
29        formatter.write_str(match self {
30            Self::Never => "never",
31            Self::Always => "always",
32            Self::CurrentSession => "current-session",
33        })
34    }
35}
36
37/// Represents an extension for the [`Image`] trait.
38/// Allows to override image defaults and container configuration.
39pub trait ImageExt<I: Image> {
40    /// Returns a new [`ContainerRequest`] with the specified (overridden) `CMD` ([`Image::cmd`]).
41    ///
42    /// # Examples
43    /// ```rust,no_run
44    /// use testcontainers::{GenericImage, ImageExt};
45    ///
46    /// let image = GenericImage::new("image", "tag");
47    /// let cmd = ["arg1", "arg2"];
48    /// let overridden_cmd = image.clone().with_cmd(cmd);
49    ///
50    /// assert!(overridden_cmd.cmd().eq(cmd));
51    ///
52    /// let another_container_req = image.with_cmd(cmd);
53    ///
54    /// assert!(another_container_req.cmd().eq(overridden_cmd.cmd()));
55    /// ```
56    fn with_cmd(self, cmd: impl IntoIterator<Item = impl Into<String>>) -> ContainerRequest<I>;
57
58    /// Overrides the fully qualified image name (consists of `{domain}/{owner}/{image}`).
59    /// Can be used to specify a custom registry or owner.
60    fn with_name(self, name: impl Into<String>) -> ContainerRequest<I>;
61
62    /// Overrides the image tag.
63    ///
64    /// There is no guarantee that the specified tag for an image would result in a
65    /// running container. Users of this API are advised to use this at their own risk.
66    fn with_tag(self, tag: impl Into<String>) -> ContainerRequest<I>;
67
68    /// Sets the container name.
69    fn with_container_name(self, name: impl Into<String>) -> ContainerRequest<I>;
70
71    /// Sets the platform the container will be run on.
72    ///
73    /// Platform in the format `os[/arch[/variant]]` used for image lookup.
74    ///
75    /// # Examples
76    ///
77    /// ```rust,no_run
78    /// use testcontainers::{GenericImage, ImageExt};
79    ///
80    /// let image = GenericImage::new("image", "tag")
81    ///     .with_platform("linux/amd64");
82    /// ```
83    fn with_platform(self, platform: impl Into<String>) -> ContainerRequest<I>;
84
85    /// Sets the network the container will be connected to.
86    fn with_network(self, network: impl Into<String>) -> ContainerRequest<I>;
87
88    /// Adds the specified label to the container.
89    ///
90    /// **Note**: all keys in the `org.testcontainers.*` namespace should be regarded
91    /// as reserved by `testcontainers` internally, and should not be expected or relied
92    /// upon to be applied correctly if supplied as a value for `key`.
93    fn with_label(self, key: impl Into<String>, value: impl Into<String>) -> ContainerRequest<I>;
94
95    /// Adds the specified labels to the container.
96    ///
97    /// **Note**: all keys in the `org.testcontainers.*` namespace should be regarded
98    /// as reserved by `testcontainers` internally, and should not be expected or relied
99    /// upon to be applied correctly if they are included in `labels`.
100    fn with_labels(
101        self,
102        labels: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
103    ) -> ContainerRequest<I>;
104
105    /// Adds an environment variable to the container.
106    fn with_env_var(self, name: impl Into<String>, value: impl Into<String>)
107        -> ContainerRequest<I>;
108
109    /// Adds a host to the container.
110    fn with_host(self, key: impl Into<String>, value: impl Into<Host>) -> ContainerRequest<I>;
111
112    /// Configures hostname for the container.
113    fn with_hostname(self, hostname: impl Into<String>) -> ContainerRequest<I>;
114
115    /// Adds a mount to the container.
116    fn with_mount(self, mount: impl Into<Mount>) -> ContainerRequest<I>;
117
118    /// Copies data or a file/dir into the container.
119    ///
120    /// The simplest form mirrors existing behavior:
121    /// ```rust,no_run
122    /// use std::path::Path;
123    /// use testcontainers::{GenericImage, ImageExt};
124    ///
125    /// let image = GenericImage::new("image", "tag");
126    /// image.with_copy_to("/app/config.toml", Path::new("./config.toml"));
127    /// ```
128    ///
129    /// By default the target mode is derived from the source file's mode on Unix,
130    /// and falls back to `0o644` on non-Unix platforms.
131    ///
132    /// To override the mode (or add more target options), wrap the target with
133    /// [`CopyTargetOptions`]:
134    /// ```rust,no_run
135    /// use std::path::Path;
136    /// use testcontainers::{CopyTargetOptions, GenericImage, ImageExt};
137    ///
138    /// let image = GenericImage::new("image", "tag");
139    /// image.with_copy_to(
140    ///     CopyTargetOptions::new("/app/config.toml").with_mode(0o600),
141    ///     Path::new("./config.toml"),
142    /// );
143    /// ```
144    fn with_copy_to(
145        self,
146        target: impl Into<CopyTargetOptions>,
147        source: impl Into<CopyDataSource>,
148    ) -> ContainerRequest<I>;
149
150    /// Adds a port mapping to the container, mapping the host port to the container's internal port.
151    ///
152    /// # Examples
153    /// ```rust,no_run
154    /// use testcontainers::{GenericImage, ImageExt};
155    /// use testcontainers::core::IntoContainerPort;
156    ///
157    /// let image = GenericImage::new("image", "tag").with_mapped_port(8080, 80.tcp());
158    /// ```
159    fn with_mapped_port(self, host_port: u16, container_port: ContainerPort)
160        -> ContainerRequest<I>;
161
162    /// Declares a host port that should be reachable from inside the container.
163    #[cfg(feature = "host-port-exposure")]
164    fn with_exposed_host_port(self, port: u16) -> ContainerRequest<I>;
165
166    /// Declares multiple host ports that should be reachable from inside the container.
167    #[cfg(feature = "host-port-exposure")]
168    fn with_exposed_host_ports(self, ports: impl IntoIterator<Item = u16>) -> ContainerRequest<I>;
169
170    /// Adds a resource ulimit to the container.
171    ///
172    /// # Examples
173    /// ```rust,no_run
174    /// use testcontainers::{GenericImage, ImageExt};
175    ///
176    /// let image = GenericImage::new("image", "tag").with_ulimit("nofile", 65536, Some(65536));
177    /// ```
178    fn with_ulimit(self, name: &str, soft: i64, hard: Option<i64>) -> ContainerRequest<I>;
179
180    /// Sets the container to run in privileged mode.
181    fn with_privileged(self, privileged: bool) -> ContainerRequest<I>;
182
183    /// Adds the capabilities to the container
184    fn with_cap_add(self, capability: impl Into<String>) -> ContainerRequest<I>;
185
186    /// Drops the capabilities from the container's capabilities
187    fn with_cap_drop(self, capability: impl Into<String>) -> ContainerRequest<I>;
188
189    /// cgroup namespace mode for the container. Possible values are:
190    /// - [`CgroupnsMode::Private`]: the container runs in its own private cgroup namespace
191    /// - [`CgroupnsMode::Host`]: use the host system's cgroup namespace
192    ///
193    /// If not specified, the daemon default is used, which can either be `\"private\"` or `\"host\"`, depending on daemon version, kernel support and configuration.
194    fn with_cgroupns_mode(self, cgroupns_mode: CgroupnsMode) -> ContainerRequest<I>;
195
196    /// Sets the usernamespace mode for the container when usernamespace remapping option is enabled.
197    fn with_userns_mode(self, userns_mode: &str) -> ContainerRequest<I>;
198
199    /// Sets the shared memory size in bytes
200    fn with_shm_size(self, bytes: u64) -> ContainerRequest<I>;
201
202    /// Sets the startup timeout for the container. The default is 60 seconds.
203    fn with_startup_timeout(self, timeout: Duration) -> ContainerRequest<I>;
204
205    /// Sets the working directory. The default is defined by the underlying image, which in turn may default to `/`.
206    fn with_working_dir(self, working_dir: impl Into<String>) -> ContainerRequest<I>;
207
208    /// Adds the log consumer to the container.
209    ///
210    /// Allows to follow the container logs for the whole lifecycle of the container, starting from the creation.
211    fn with_log_consumer(self, log_consumer: impl LogConsumer + 'static) -> ContainerRequest<I>;
212
213    /// Applies a custom modifier to the Docker `HostConfig` used for container creation.
214    ///
215    /// The modifier runs after `testcontainers` finishes applying its defaults and settings.
216    /// If called multiple times, the last modifier replaces the previous one.
217    fn with_host_config_modifier(
218        self,
219        modifier: impl Fn(&mut HostConfig) + Send + Sync + 'static,
220    ) -> ContainerRequest<I>;
221
222    /// Flag the container as being exempt from the default `testcontainers` remove-on-drop lifecycle,
223    /// indicating that the container should be kept running, and that executions with the same configuration
224    /// reuse it instead of starting a "fresh" container instance.
225    ///
226    /// **NOTE:** Reusable Containers is an experimental feature, and its behavior is therefore subject
227    /// to change. Containers marked as `reuse` **_will not_** be stopped or cleaned up when their associated
228    /// `Container` or `ContainerAsync` is dropped.
229    #[cfg(feature = "reusable-containers")]
230    fn with_reuse(self, reuse: ReuseDirective) -> ContainerRequest<I>;
231
232    /// Sets the user that commands are run as inside the container.
233    fn with_user(self, user: impl Into<String>) -> ContainerRequest<I>;
234
235    /// Sets the container's root filesystem to be mounted as read-only
236    fn with_readonly_rootfs(self, readonly_rootfs: bool) -> ContainerRequest<I>;
237
238    /// Sets security options for the container
239    fn with_security_opt(self, security_opt: impl Into<String>) -> ContainerRequest<I>;
240
241    /// Overrides ready conditions.
242    ///
243    /// There is no guarantee that the specified ready conditions for an image would result
244    /// in a running container. Users of this API are advised to use this at their own risk.
245    fn with_ready_conditions(self, ready_conditions: Vec<WaitFor>) -> ContainerRequest<I>;
246
247    /// Sets a custom health check for the container.
248    ///
249    /// This will override any `HEALTHCHECK` instruction defined in the image.
250    /// See [`Healthcheck`] for more details on how to build a health check.
251    ///
252    /// # Example
253    ///
254    /// ```rust,no_run
255    /// use testcontainers::{core::{Healthcheck, WaitFor}, GenericImage, ImageExt};
256    /// use std::time::Duration;
257    ///
258    /// let image = GenericImage::new("mysql", "8.0")
259    ///     .with_wait_for(WaitFor::healthcheck())
260    ///     .with_health_check(
261    ///         Healthcheck::cmd(["mysqladmin", "ping", "-h", "localhost", "-u", "root", "-proot"])
262    ///             .with_interval(Duration::from_secs(2))
263    ///             .with_timeout(Duration::from_secs(1))
264    ///             .with_retries(5)
265    ///     );
266    /// ```
267    fn with_health_check(self, health_check: Healthcheck) -> ContainerRequest<I>;
268
269    /// Injects device requests into the container request.
270    ///
271    /// This allows, for instance, exposing the underlying host's GPU:
272    /// https://docs.docker.com/compose/how-tos/gpu-support/#example-of-a-compose-file-for-running-a-service-with-access-to-1-gpu-device
273    ///
274    /// This brings in 2 requirements:
275    ///
276    /// - The host must have [NVIDIA container toolkit](https://docs.nvidia.com/datacenter/cloud-native/container-toolkit/latest/install-guide.html) installed.
277    /// - The image must have [NVIDIA drivers](https://www.nvidia.com/en-us/drivers/) installed.
278    ///
279    /// # Example
280    ///
281    /// ```rust,no_run
282    /// use testcontainers::{GenericImage, ImageExt as _, bollard::models::DeviceRequest};
283    ///
284    /// let device_request = DeviceRequest {
285    ///     driver: Some(String::from("nvidia")),
286    ///     count: Some(-1), // expose all
287    ///     capabilities: Some(vec![vec![String::from("gpu")]]),
288    ///     device_ids: None,
289    ///     options: None,
290    /// };
291    ///
292    /// let image = GenericImage::new("ubuntu", "24.04")
293    ///     .with_device_requests(vec![device_request]);
294    /// ```
295    #[cfg(feature = "device-requests")]
296    fn with_device_requests(self, device_requests: Vec<DeviceRequest>) -> ContainerRequest<I>;
297
298    /// Sets whether to keep stdin open for the container.
299    fn with_open_stdin(self, open_stdin: bool) -> ContainerRequest<I>;
300}
301
302/// Implements the [`ImageExt`] trait for the every type that can be converted into a [`ContainerRequest`].
303impl<RI: Into<ContainerRequest<I>>, I: Image> ImageExt<I> for RI {
304    fn with_cmd(self, cmd: impl IntoIterator<Item = impl Into<String>>) -> ContainerRequest<I> {
305        let container_req = self.into();
306        ContainerRequest {
307            overridden_cmd: cmd.into_iter().map(Into::into).collect(),
308            ..container_req
309        }
310    }
311
312    fn with_name(self, name: impl Into<String>) -> ContainerRequest<I> {
313        let container_req = self.into();
314        ContainerRequest {
315            image_name: Some(name.into()),
316            ..container_req
317        }
318    }
319
320    fn with_tag(self, tag: impl Into<String>) -> ContainerRequest<I> {
321        let container_req = self.into();
322        ContainerRequest {
323            image_tag: Some(tag.into()),
324            ..container_req
325        }
326    }
327
328    fn with_container_name(self, name: impl Into<String>) -> ContainerRequest<I> {
329        let container_req = self.into();
330
331        ContainerRequest {
332            container_name: Some(name.into()),
333            ..container_req
334        }
335    }
336
337    fn with_platform(self, platform: impl Into<String>) -> ContainerRequest<I> {
338        let container_req = self.into();
339
340        ContainerRequest {
341            platform: Some(platform.into()),
342            ..container_req
343        }
344    }
345
346    fn with_network(self, network: impl Into<String>) -> ContainerRequest<I> {
347        let container_req = self.into();
348        ContainerRequest {
349            network: Some(network.into()),
350            ..container_req
351        }
352    }
353
354    fn with_label(self, key: impl Into<String>, value: impl Into<String>) -> ContainerRequest<I> {
355        let mut container_req = self.into();
356
357        container_req.labels.insert(key.into(), value.into());
358
359        container_req
360    }
361
362    fn with_labels(
363        self,
364        labels: impl IntoIterator<Item = (impl Into<String>, impl Into<String>)>,
365    ) -> ContainerRequest<I> {
366        let mut container_req = self.into();
367
368        container_req.labels.extend(
369            labels
370                .into_iter()
371                .map(|(key, value)| (key.into(), value.into())),
372        );
373
374        container_req
375    }
376
377    fn with_env_var(
378        self,
379        name: impl Into<String>,
380        value: impl Into<String>,
381    ) -> ContainerRequest<I> {
382        let mut container_req = self.into();
383        container_req.env_vars.insert(name.into(), value.into());
384        container_req
385    }
386
387    fn with_host(self, key: impl Into<String>, value: impl Into<Host>) -> ContainerRequest<I> {
388        let mut container_req = self.into();
389        container_req.hosts.insert(key.into(), value.into());
390        container_req
391    }
392
393    fn with_hostname(self, hostname: impl Into<String>) -> ContainerRequest<I> {
394        let mut container_req = self.into();
395        container_req.hostname = Some(hostname.into());
396        container_req
397    }
398
399    fn with_mount(self, mount: impl Into<Mount>) -> ContainerRequest<I> {
400        let mut container_req = self.into();
401        container_req.mounts.push(mount.into());
402        container_req
403    }
404
405    fn with_copy_to(
406        self,
407        target: impl Into<CopyTargetOptions>,
408        source: impl Into<CopyDataSource>,
409    ) -> ContainerRequest<I> {
410        let mut container_req = self.into();
411        let target = target.into();
412        container_req
413            .copy_to_sources
414            .push(CopyToContainer::new(source, target));
415        container_req
416    }
417
418    fn with_mapped_port(
419        self,
420        host_port: u16,
421        container_port: ContainerPort,
422    ) -> ContainerRequest<I> {
423        let container_req = self.into();
424        let mut ports = container_req.ports.unwrap_or_default();
425        ports.push(PortMapping::new(host_port, container_port));
426
427        ContainerRequest {
428            ports: Some(ports),
429            ..container_req
430        }
431    }
432
433    #[cfg(feature = "host-port-exposure")]
434    fn with_exposed_host_port(self, port: u16) -> ContainerRequest<I> {
435        self.with_exposed_host_ports([port])
436    }
437
438    #[cfg(feature = "host-port-exposure")]
439    fn with_exposed_host_ports(self, ports: impl IntoIterator<Item = u16>) -> ContainerRequest<I> {
440        let mut container_req = self.into();
441        let exposures = container_req
442            .host_port_exposures
443            .get_or_insert_with(Vec::new);
444
445        exposures.extend(ports);
446        exposures.sort_unstable();
447        exposures.dedup();
448
449        container_req
450    }
451
452    fn with_ulimit(self, name: &str, soft: i64, hard: Option<i64>) -> ContainerRequest<I> {
453        let container_req = self.into();
454        let mut ulimits = container_req.ulimits.unwrap_or_default();
455        ulimits.push(ResourcesUlimits {
456            name: Some(name.into()),
457            soft: Some(soft),
458            hard,
459        });
460
461        ContainerRequest {
462            ulimits: Some(ulimits),
463            ..container_req
464        }
465    }
466
467    fn with_privileged(self, privileged: bool) -> ContainerRequest<I> {
468        let container_req = self.into();
469        ContainerRequest {
470            privileged,
471            ..container_req
472        }
473    }
474
475    fn with_cap_add(self, capability: impl Into<String>) -> ContainerRequest<I> {
476        let mut container_req = self.into();
477        container_req
478            .cap_add
479            .get_or_insert_with(Vec::new)
480            .push(capability.into());
481
482        container_req
483    }
484
485    fn with_cap_drop(self, capability: impl Into<String>) -> ContainerRequest<I> {
486        let mut container_req = self.into();
487        container_req
488            .cap_drop
489            .get_or_insert_with(Vec::new)
490            .push(capability.into());
491
492        container_req
493    }
494
495    fn with_cgroupns_mode(self, cgroupns_mode: CgroupnsMode) -> ContainerRequest<I> {
496        let container_req = self.into();
497        ContainerRequest {
498            cgroupns_mode: Some(cgroupns_mode),
499            ..container_req
500        }
501    }
502
503    fn with_userns_mode(self, userns_mode: &str) -> ContainerRequest<I> {
504        let container_req = self.into();
505        ContainerRequest {
506            userns_mode: Some(String::from(userns_mode)),
507            ..container_req
508        }
509    }
510
511    fn with_shm_size(self, bytes: u64) -> ContainerRequest<I> {
512        let container_req = self.into();
513        ContainerRequest {
514            shm_size: Some(bytes),
515            ..container_req
516        }
517    }
518
519    fn with_startup_timeout(self, timeout: Duration) -> ContainerRequest<I> {
520        let container_req = self.into();
521        ContainerRequest {
522            startup_timeout: Some(timeout),
523            ..container_req
524        }
525    }
526
527    fn with_working_dir(self, working_dir: impl Into<String>) -> ContainerRequest<I> {
528        let container_req = self.into();
529        ContainerRequest {
530            working_dir: Some(working_dir.into()),
531            ..container_req
532        }
533    }
534
535    fn with_log_consumer(self, log_consumer: impl LogConsumer + 'static) -> ContainerRequest<I> {
536        let mut container_req = self.into();
537        container_req.log_consumers.push(Box::new(log_consumer));
538        container_req
539    }
540
541    fn with_host_config_modifier(
542        self,
543        modifier: impl Fn(&mut HostConfig) + Send + Sync + 'static,
544    ) -> ContainerRequest<I> {
545        let container_req = self.into();
546        ContainerRequest {
547            host_config_modifier: Some(Arc::new(modifier)),
548            ..container_req
549        }
550    }
551
552    #[cfg(feature = "reusable-containers")]
553    fn with_reuse(self, reuse: ReuseDirective) -> ContainerRequest<I> {
554        ContainerRequest {
555            reuse,
556            ..self.into()
557        }
558    }
559
560    fn with_user(self, user: impl Into<String>) -> ContainerRequest<I> {
561        let container_req = self.into();
562        ContainerRequest {
563            user: Some(user.into()),
564            ..container_req
565        }
566    }
567
568    fn with_readonly_rootfs(self, readonly_rootfs: bool) -> ContainerRequest<I> {
569        let container_req = self.into();
570        ContainerRequest {
571            readonly_rootfs,
572            ..container_req
573        }
574    }
575
576    fn with_security_opt(self, security_opt: impl Into<String>) -> ContainerRequest<I> {
577        let mut container_req = self.into();
578        container_req
579            .security_opts
580            .get_or_insert_with(Vec::new)
581            .push(security_opt.into());
582
583        container_req
584    }
585
586    fn with_ready_conditions(self, ready_conditions: Vec<WaitFor>) -> ContainerRequest<I> {
587        let mut container_req = self.into();
588        container_req.ready_conditions = Some(ready_conditions);
589        container_req
590    }
591
592    fn with_health_check(self, health_check: Healthcheck) -> ContainerRequest<I> {
593        let mut container_req = self.into();
594        container_req.health_check = Some(health_check);
595        container_req
596    }
597
598    #[cfg(feature = "device-requests")]
599    fn with_device_requests(self, device_requests: Vec<DeviceRequest>) -> ContainerRequest<I> {
600        let container_req = self.into();
601        ContainerRequest {
602            device_requests: Some(device_requests),
603            ..container_req
604        }
605    }
606
607    fn with_open_stdin(self, open_stdin: bool) -> ContainerRequest<I> {
608        let mut container_req = self.into();
609        container_req.open_stdin = Some(open_stdin);
610        container_req
611    }
612}
613
614#[cfg(all(test, feature = "host-port-exposure"))]
615mod tests {
616    use super::*;
617    use crate::images::generic::GenericImage;
618
619    #[test]
620    fn test_with_exposed_host_port_single() {
621        let image = GenericImage::new("test", "latest");
622        let request = image.with_exposed_host_port(8080);
623
624        assert_eq!(request.host_port_exposures, Some(vec![8080]));
625    }
626
627    #[test]
628    fn test_with_exposed_host_ports_multiple() {
629        let image = GenericImage::new("test", "latest");
630        let request = image.with_exposed_host_ports([8080, 9090, 3000]);
631
632        assert_eq!(request.host_port_exposures, Some(vec![3000, 8080, 9090]));
633    }
634
635    #[test]
636    fn test_with_exposed_host_ports_deduplication() {
637        let image = GenericImage::new("test", "latest");
638        let request = image.with_exposed_host_ports([8080, 9090, 8080, 3000, 9090]);
639
640        assert_eq!(request.host_port_exposures, Some(vec![3000, 8080, 9090]));
641    }
642
643    #[test]
644    fn test_with_exposed_host_ports_empty() {
645        let image = GenericImage::new("test", "latest");
646        let request = image.with_exposed_host_ports([]);
647
648        assert_eq!(request.host_port_exposures, Some(vec![]));
649    }
650
651    #[test]
652    fn test_with_exposed_host_ports_chaining() {
653        let image = GenericImage::new("test", "latest");
654        let request = image
655            .with_exposed_host_port(8080)
656            .with_exposed_host_ports([9090, 3000]);
657
658        assert_eq!(request.host_port_exposures, Some(vec![3000, 8080, 9090]));
659    }
660
661    #[test]
662    fn test_with_exposed_host_ports_preserves_existing() {
663        let image = GenericImage::new("test", "latest");
664        let request = image.with_exposed_host_port(8080);
665
666        // The first call already set host_port_exposures to Some(vec![8080])
667        // Now we add more ports
668        let request = request.with_exposed_host_ports([9090, 3000]);
669
670        // The result should include all ports: 8080 (from first call), 9090, 3000 (from second call)
671        assert_eq!(request.host_port_exposures, Some(vec![3000, 8080, 9090]));
672    }
673}