From 2c5749f4e51ee508805bff09c9f31688a63ac242 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 07:43:13 +0000 Subject: [PATCH 1/2] test(stack): name the stalled phase when the Mailpit launch test hangs The launch test now bounds start, readiness and the /readyz request inside the test timeout and fails with the phase and Mailpit's output so far, instead of a bare test timeout. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EMySvvbM4B1zdmhw3pp1wA --- .../src/services/Mail.integration.test.ts | 41 ++++++++++++++++--- 1 file changed, 35 insertions(+), 6 deletions(-) diff --git a/packages/stack/src/services/Mail.integration.test.ts b/packages/stack/src/services/Mail.integration.test.ts index 84a0ed4bde..abdd4d0d20 100644 --- a/packages/stack/src/services/Mail.integration.test.ts +++ b/packages/stack/src/services/Mail.integration.test.ts @@ -1,6 +1,6 @@ import { NodeHttpClient, NodeServices } from "@effect/platform-node"; import { describe, expect, it } from "@effect/vitest"; -import { Effect, Exit, FileSystem, Layer, Path } from "effect"; +import { Data, Effect, Exit, FileSystem, Layer, Path, Ref, Stream } from "effect"; import { HttpClient, HttpClientRequest, HttpClientResponse } from "effect/http"; import { makeStandaloneService } from "../../tests/standalone-service.ts"; import { makeServiceRecipe } from "./Catalog.ts"; @@ -59,6 +59,28 @@ const sendTestEmail = ( yield* HttpClientResponse.filterStatusOk(response); }); +class MailStallError extends Data.TaggedError("MailStallError")<{ readonly message: string }> {} + +/** Fails a stalled phase within the test timeout, naming the phase and the Mailpit output so far. */ +const guardPhase = + (phase: string, duration: `${number} seconds`, logs: Ref.Ref) => + (effect: Effect.Effect) => + effect.pipe( + Effect.timeoutOrElse({ + duration, + orElse: () => + Ref.get(logs).pipe( + Effect.flatMap((output) => + Effect.fail( + new MailStallError({ + message: `Mailpit ${phase} did not finish within ${duration}; output:\n${output || "(none)"}`, + }), + ), + ), + ), + }), + ); + describe("service catalog", () => { it.live("launches the real Mailpit recipe and serves its HTTP endpoint", () => Effect.scoped( @@ -70,16 +92,23 @@ describe("service catalog", () => { { service: "mail", pin: selection.mail, config: {} }, dockerOptions(root), ); + const logs = yield* Ref.make(""); + yield* Stream.fromSubscription(yield* recipe.logs).pipe( + Stream.runForEach(({ bytes }) => + Ref.update(logs, (text) => text + new TextDecoder().decode(bytes)), + ), + Effect.forkScoped({ startImmediately: true }), + ); const instance = yield* makeStandaloneService(recipe.definition, { id: "mail", config: recipe.creation, }); - yield* instance.start; - yield* instance.ready; + yield* instance.start.pipe(guardPhase("start", "20 seconds", logs)); + yield* instance.ready.pipe(guardPhase("readiness", "6 seconds", logs)); const endpoint = yield* recipe.endpoint("http"); - const response = yield* client.execute( - HttpClientRequest.get(`http://${httpHost(endpoint)}:${endpoint.port}/readyz`), - ); + const response = yield* client + .execute(HttpClientRequest.get(`http://${httpHost(endpoint)}:${endpoint.port}/readyz`)) + .pipe(guardPhase("/readyz request", "3 seconds", logs)); expect(response.status).toBe(200); yield* instance.stop; }), From 40c82d6b4730d7d958bb720da0ae24638e85ba49 Mon Sep 17 00:00:00 2001 From: Claude Date: Sat, 24 Oct 2026 07:48:15 +0000 Subject: [PATCH 2/2] test(stack): share one launch deadline across the Mailpit phases Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01EMySvvbM4B1zdmhw3pp1wA --- .../src/services/Mail.integration.test.ts | 29 +++++++++++-------- 1 file changed, 17 insertions(+), 12 deletions(-) diff --git a/packages/stack/src/services/Mail.integration.test.ts b/packages/stack/src/services/Mail.integration.test.ts index abdd4d0d20..ec60227b6c 100644 --- a/packages/stack/src/services/Mail.integration.test.ts +++ b/packages/stack/src/services/Mail.integration.test.ts @@ -61,19 +61,19 @@ const sendTestEmail = ( class MailStallError extends Data.TaggedError("MailStallError")<{ readonly message: string }> {} -/** Fails a stalled phase within the test timeout, naming the phase and the Mailpit output so far. */ -const guardPhase = - (phase: string, duration: `${number} seconds`, logs: Ref.Ref) => +/** Fails a stalled launch within the test timeout, naming its current phase and Mailpit's output. */ +const guardLaunch = + (duration: `${number} seconds`, phase: Ref.Ref, logs: Ref.Ref) => (effect: Effect.Effect) => effect.pipe( Effect.timeoutOrElse({ duration, orElse: () => - Ref.get(logs).pipe( - Effect.flatMap((output) => + Effect.all([Ref.get(phase), Ref.get(logs)]).pipe( + Effect.flatMap(([stalled, output]) => Effect.fail( new MailStallError({ - message: `Mailpit ${phase} did not finish within ${duration}; output:\n${output || "(none)"}`, + message: `Mailpit ${stalled} did not finish within ${duration}; output:\n${output || "(none)"}`, }), ), ), @@ -103,12 +103,17 @@ describe("service catalog", () => { id: "mail", config: recipe.creation, }); - yield* instance.start.pipe(guardPhase("start", "20 seconds", logs)); - yield* instance.ready.pipe(guardPhase("readiness", "6 seconds", logs)); - const endpoint = yield* recipe.endpoint("http"); - const response = yield* client - .execute(HttpClientRequest.get(`http://${httpHost(endpoint)}:${endpoint.port}/readyz`)) - .pipe(guardPhase("/readyz request", "3 seconds", logs)); + const phase = yield* Ref.make("start"); + const response = yield* Effect.gen(function* () { + yield* instance.start; + yield* Ref.set(phase, "readiness"); + yield* instance.ready; + yield* Ref.set(phase, "/readyz request"); + const endpoint = yield* recipe.endpoint("http"); + return yield* client.execute( + HttpClientRequest.get(`http://${httpHost(endpoint)}:${endpoint.port}/readyz`), + ); + }).pipe(guardLaunch("25 seconds", phase, logs)); expect(response.status).toBe(200); yield* instance.stop; }),