99import java .nio .file .Path ;
1010import java .nio .file .StandardCopyOption ;
1111import java .time .Duration ;
12+ import java .time .Instant ;
1213import java .time .temporal .ChronoUnit ;
1314
1415/**
@@ -33,7 +34,7 @@ public class FederatedTestEnvironment {
3334 private static boolean initialized = false ;
3435
3536 // Time to wait for services to start up completely
36- private static final Duration STARTUP_WAIT = Duration .of (30 , ChronoUnit .SECONDS );
37+ private static final Duration STARTUP_WAIT = Duration .of (3 , ChronoUnit .MINUTES );
3738
3839 // Port configuration for each XMPP server
3940 public static final int XMPP1_PORT = 5221 ; // First server client port
@@ -70,8 +71,7 @@ public static synchronized void start() throws Exception {
7071 if (!initialized ) {
7172 setupSqlOverlay ();
7273 startFederatedEnvironment ();
73- logger .info ("Waiting {} seconds for servers to initialize..." , STARTUP_WAIT .toSeconds ());
74- Thread .sleep (STARTUP_WAIT .toMillis ());
74+ waitForFederatedEnvironment ();
7575 initialized = true ;
7676 // Register shutdown hook to ensure cleanup happens even if tests fail
7777 Runtime .getRuntime ().addShutdownHook (new Thread (() -> {
@@ -175,6 +175,77 @@ private static void startFederatedEnvironment() throws IOException, InterruptedE
175175 }
176176 }
177177
178+ /**
179+ * Waits for the federated environment to have fully initialized and all servers to be healthy.
180+ * <p>
181+ * Blocks until all Openfire instances in the federated environment report a healthy status,
182+ * or until {@link #STARTUP_WAIT} has elapsed.
183+ *
184+ * @throws IllegalStateException if any server does not become healthy within the allotted time
185+ * @throws InterruptedException if the thread is interrupted while waiting
186+ * @throws IOException if an error occurs communicating with the Docker daemon
187+ */
188+ private static void waitForFederatedEnvironment () throws IllegalStateException , InterruptedException , IOException
189+ {
190+ logger .info ("Waiting up to {} seconds for servers to initialize..." , STARTUP_WAIT .toSeconds ());
191+ final Instant deadline = Instant .now ().plus (STARTUP_WAIT );
192+ waitForHealthy ("openfire" , "xmpp1" , deadline );
193+ waitForHealthy ("openfire" , "xmpp2" , deadline );
194+ }
195+
196+ /**
197+ * Blocks until a specific Docker container reports a healthy status, or the deadline is reached.
198+ *
199+ * The container is identified by the standard Compose naming convention: {@code <project>-<service>-1}.
200+ *
201+ * Health status is polled using the Docker inspect command.
202+ *
203+ * @param project the Docker Compose project name
204+ * @param service the service name within the Compose project
205+ * @param deadline the point in time after which the wait is abandoned
206+ * @throws IllegalStateException if the container does not become healthy before the deadline
207+ * @throws InterruptedException if the thread is interrupted while waiting
208+ * @throws IOException if an error occurs communicating with the Docker daemon
209+ */
210+ private static void waitForHealthy (String project , String service , Instant deadline ) throws IOException , InterruptedException
211+ {
212+ System .out .print ("Waiting for " + service + "..." );
213+ while (Instant .now ().isBefore (deadline )) {
214+ if (isContainerHealthy (project , service )) {
215+ System .out .println (" healthy." );
216+ return ;
217+ }
218+ Thread .sleep (1_000 );
219+ System .out .print ("." );
220+ }
221+ throw new IllegalStateException ("Timed out waiting for " + service + " to become healthy" );
222+ }
223+
224+ /**
225+ * Checks whether a Docker container is currently reporting a healthy status.
226+ *
227+ * Invokes {@code docker inspect} to retrieve the container's health status. Returns {@code false} for any status
228+ * other than {@code healthy}, including {@code starting}, {@code unhealthy}, or if the container cannot be found.
229+ *
230+ * @param project the Docker Compose project name
231+ * @param service the service name within the Compose project
232+ * @return {@code true} if the container's health status is {@code healthy}, {@code false} otherwise
233+ * @throws InterruptedException if the thread is interrupted while waiting for the {@code docker inspect} process to complete
234+ * @throws IOException if the {@code docker} executable cannot be found or an I/O error occurs
235+ */
236+ private static boolean isContainerHealthy (String project , String service ) throws IOException , InterruptedException
237+ {
238+ final String containerName = project + "-" + service + "-1" ;
239+ final ProcessBuilder pb = new ProcessBuilder ("docker" , "inspect" , "--format" , "{{.State.Health.Status}}" , containerName );
240+ pb .redirectErrorStream (true );
241+
242+ final Process process = pb .start ();
243+ final String output = new String (process .getInputStream ().readAllBytes ()).trim ();
244+ process .waitFor ();
245+
246+ return "healthy" .equals (output );
247+ }
248+
178249 /**
179250 * Invokes the get_logs.sh script to export Openfire logs from the images.
180251 *
0 commit comments