LCOV - code coverage report
Current view: top level - src - testainers_compose.dart (source / functions) Coverage Total Hit
Test: testainers Lines: 92.9 % 42 39
Test Date: 2026-05-24 17:06:20 Functions: - 0 0

            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              : }
        

Generated by: LCOV version 2.0-1