Line data Source code
1 : import 'dart:io';
2 :
3 : import 'package:testainers/src/testainers_config.dart';
4 : import 'package:testainers/src/testainers_exception.dart';
5 :
6 : /// Manages multi-container environments defined by a Docker Compose file.
7 : ///
8 : /// [TestainersCompose] wraps the `docker compose` CLI to start, stop, and
9 : /// interact with services declared in a `docker-compose.yml` (or equivalent)
10 : /// file. It is designed for integration/system tests that require multiple
11 : /// cooperating containers (e.g., an app server + database).
12 : ///
13 : /// The class delegates all process execution to [TestainersConfig], so the
14 : /// configured runner, timeout, and debug settings are respected.
15 : ///
16 : /// Example:
17 : /// ```dart
18 : /// final compose = TestainersCompose(
19 : /// composePath: 'docker-compose.test.yml',
20 : /// );
21 : ///
22 : /// await compose.up();
23 : /// await compose.waitForHealth('http://localhost:3100/health');
24 : ///
25 : /// // ... run tests ...
26 : ///
27 : /// await compose.down(volumes: true);
28 : /// ```
29 : class TestainersCompose {
30 : /// Path to the Docker Compose file.
31 : ///
32 : /// Can be absolute or relative to the working directory.
33 : /// Passed as the `-f` argument to `docker compose`.
34 : final String composePath;
35 :
36 : /// Configuration for process execution (runner, timeout, debug).
37 : final TestainersConfig config;
38 :
39 : /// Optional project name for the Compose stack.
40 : ///
41 : /// If provided, passed as the `-p` argument to `docker compose`,
42 : /// isolating this stack from other Compose projects.
43 : /// If `null`, Docker Compose uses its default naming scheme.
44 : final String? projectName;
45 :
46 : /// Creates a [TestainersCompose] instance.
47 : ///
48 : /// - [composePath] — path to the `docker-compose.yml` file (required).
49 : /// - [config] — execution configuration. Defaults to [TestainersConfig]
50 : /// with standard settings.
51 : /// - [projectName] — optional Compose project name for stack isolation.
52 1 : TestainersCompose({
53 : required this.composePath,
54 : this.config = const TestainersConfig(),
55 : this.projectName,
56 : });
57 :
58 : /// Builds the common prefix arguments for all `docker compose` commands.
59 : ///
60 : /// Returns `['compose', '-f', composePath]` with an optional
61 : /// `['-p', projectName]` segment when [projectName] is set.
62 1 : List<String> _composeArgs() {
63 1 : return <String>[
64 : 'compose',
65 : '-f',
66 1 : composePath,
67 3 : if (projectName != null) ...<String>['-p', projectName!],
68 : ];
69 : }
70 :
71 : /// Starts all services defined in the Compose file in detached mode.
72 : ///
73 : /// Runs `docker compose -f <composePath> up -d`.
74 : ///
75 : /// Parameters:
76 : /// - [build] — if `true`, forces a rebuild of service images before
77 : /// starting (`--build` flag). Defaults to `false`.
78 : ///
79 : /// Throws [TestainersException] if the command exits with a non-zero code.
80 : ///
81 : /// Example:
82 : /// ```dart
83 : /// await compose.up(); // start without rebuild
84 : /// await compose.up(build: true); // rebuild images first
85 : /// ```
86 1 : Future<void> up({bool build = false}) async {
87 1 : final List<String> arguments = <String>[
88 1 : ..._composeArgs(),
89 1 : 'up',
90 1 : '-d',
91 1 : if (build) '--build',
92 : ];
93 :
94 2 : await config.exec(
95 : arguments: arguments,
96 2 : exceptionExec: 'Compose up failed for $composePath.',
97 : );
98 : }
99 :
100 : /// Stops and removes all containers, networks, and (optionally) volumes
101 : /// created by [up].
102 : ///
103 : /// Runs `docker compose -f <composePath> down`.
104 : ///
105 : /// Parameters:
106 : /// - [volumes] — if `true`, also removes named volumes declared in the
107 : /// Compose file and anonymous volumes attached to containers
108 : /// (`--volumes` flag). Defaults to `false`.
109 : ///
110 : /// Throws [TestainersException] if the command exits with a non-zero code.
111 : ///
112 : /// Example:
113 : /// ```dart
114 : /// await compose.down(); // keep volumes
115 : /// await compose.down(volumes: true); // remove volumes too
116 : /// ```
117 1 : Future<void> down({bool volumes = false}) async {
118 1 : final List<String> arguments = <String>[
119 1 : ..._composeArgs(),
120 1 : 'down',
121 1 : if (volumes) '--volumes',
122 : ];
123 :
124 2 : await config.exec(
125 : arguments: arguments,
126 2 : exceptionExec: 'Compose down failed for $composePath.',
127 : );
128 : }
129 :
130 : /// Executes a command inside a running Compose service container.
131 : ///
132 : /// Runs `docker compose -f <composePath> exec -T <service> <command...>`.
133 : /// The `-T` flag disables pseudo-TTY allocation, which is appropriate
134 : /// for non-interactive usage in tests and CI.
135 : ///
136 : /// Parameters:
137 : /// - [service] — the name of the service as defined in the Compose file
138 : /// (e.g., `'web'`, `'db'`).
139 : /// - [command] — the command and its arguments to execute inside the
140 : /// service container (e.g., `['rails', 'db:seed']`).
141 : /// - [env] — optional environment variables to set inside the container.
142 : /// Each entry is passed as `-e KEY=VALUE` to `docker compose exec`.
143 : ///
144 : /// Returns the trimmed stdout output of the executed command.
145 : ///
146 : /// Throws [TestainersException] if the command exits with a non-zero code.
147 : ///
148 : /// Example:
149 : /// ```dart
150 : /// final tables = await compose.serviceExec(
151 : /// 'db',
152 : /// ['psql', '-U', 'postgres', '-c', r'\dt'],
153 : /// );
154 : /// print(tables);
155 : ///
156 : /// // With environment variables:
157 : /// await compose.serviceExec(
158 : /// 'app',
159 : /// ['bin/rails', 'db:reset'],
160 : /// env: {'RAILS_ENV': 'test'},
161 : /// );
162 : /// ```
163 1 : Future<String> serviceExec(
164 : String service,
165 : List<String> command, {
166 : Map<String, String> env = const <String, String>{},
167 : }) async {
168 1 : final List<String> arguments = <String>[
169 1 : ..._composeArgs(),
170 1 : 'exec',
171 1 : '-T',
172 1 : for (final MapEntry<String, String> e in env.entries)
173 0 : ...<String>['-e', '${e.key}=${e.value}'],
174 1 : service,
175 1 : ...command,
176 : ];
177 :
178 2 : return config.exec(
179 : arguments: arguments,
180 2 : exceptionExec: 'Exec failed in service $service for $composePath.',
181 : );
182 : }
183 :
184 : /// Polls an HTTP endpoint until it returns a 200 status code.
185 : ///
186 : /// Useful for waiting until a service inside the Compose stack is fully
187 : /// booted and ready to accept requests.
188 : ///
189 : /// Parameters:
190 : /// - [url] — the HTTP(S) URL to poll (e.g., `'http://localhost:3100/health'`).
191 : /// - [timeout] — maximum duration to keep retrying before giving up.
192 : /// Defaults to 60 seconds.
193 : /// - [interval] — delay between successive poll attempts.
194 : /// Defaults to 2 seconds.
195 : ///
196 : /// Returns `true` if the endpoint responded with HTTP 200 within the
197 : /// timeout window, `false` otherwise.
198 : ///
199 : /// This method never throws — connection errors and non-200 responses
200 : /// are silently retried until the timeout expires.
201 : ///
202 : /// Example:
203 : /// ```dart
204 : /// await compose.up();
205 : ///
206 : /// final ready = await compose.waitForHealth(
207 : /// 'http://localhost:3100/health',
208 : /// timeout: const Duration(seconds: 90),
209 : /// );
210 : ///
211 : /// if (!ready) {
212 : /// throw StateError('Backend did not become healthy');
213 : /// }
214 : /// ```
215 1 : Future<bool> waitForHealth(
216 : String url, {
217 : Duration timeout = const Duration(seconds: 60),
218 : Duration interval = const Duration(seconds: 2),
219 : }) async {
220 2 : final DateTime deadline = DateTime.now().add(timeout);
221 :
222 2 : while (DateTime.now().isBefore(deadline)) {
223 : try {
224 1 : final HttpClient client = HttpClient();
225 :
226 : try {
227 : final HttpClientRequest request = await client
228 2 : .getUrl(Uri.parse(url))
229 1 : .timeout(const Duration(seconds: 5));
230 : final HttpClientResponse response =
231 0 : await request.close().timeout(const Duration(seconds: 5));
232 :
233 0 : if (response.statusCode == 200) {
234 : return true;
235 : }
236 : } finally {
237 1 : client.close();
238 : }
239 1 : } on Exception {
240 : // Ignore and retry.
241 : }
242 :
243 1 : await Future<void>.delayed(interval);
244 : }
245 :
246 : return false;
247 : }
248 : }
|