1import { PlatformServices } from "@/Util/PlatformServices.ts";
2import { describe, expect, it } from "alchemy-test";
3import * as Duration from "effect/Duration";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7import * as ChildProcess from "effect/process/ChildProcess";
8import { fileURLToPath } from "node:url";
9
10const FIXTURE = fileURLToPath(
11 new URL("./fixtures/shutdown-feedback-fixture.ts", import.meta.url),
12);
13
14/**
15 * Spawn the fixture (piped stdio, so the feedback takes the non-TTY log-line
16 * branch), wait for its ready line, and expose accumulated stderr.
17 */
18const spawnFixture = (env: Record<string, string> = {}) =>
19 Effect.gen(function* () {
20 const handle = yield* ChildProcess.make("bun", [FIXTURE], {
21 env,
22 extendEnv: true,
23 stdin: "ignore",
24 stdout: "pipe",
25 stderr: "pipe",
26 killSignal: "SIGTERM",
27 forceKillAfter: "1 second",
28 });
29 const chunks: string[] = [];
30 yield* handle.stderr.pipe(
31 Stream.decodeText,
32 Stream.runForEach((chunk) =>
33 Effect.sync(() => {
34 chunks.push(chunk);
35 }),
36 ),
37 Effect.ignore,
38 Effect.forkScoped,
39 );
40 yield* handle.stdout.pipe(
41 Stream.decodeText,
42 Stream.splitLines,
43 Stream.filter((line) => line.includes("ready")),
44 Stream.take(1),
45 Stream.runDrain,
46 Effect.timeout(Duration.seconds(10)),
47 );
48 return {
49 handle,
50 sigint: Effect.sync(() => process.kill(handle.pid, "SIGINT")),
51 stderr: () => chunks.join(""),
52 };
53 });
54
55const waitForStderr = (read: () => string, text: string) =>
56 Effect.sync(read).pipe(
57 Effect.repeat({
58 schedule: Schedule.spaced(Duration.millis(50)),
59 until: (output) => output.includes(text),
60 times: 100,
61 }),
62 Effect.flatMap((output) =>
63 output.includes(text)
64 ? Effect.void
65 : Effect.fail(
66 new Error(
67 `stderr never contained ${JSON.stringify(text)}: ${output}`,
68 ),
69 ),
70 ),
71 );
72
73describe("shutdown feedback", { tags: ["unit", "local"] }, () => {
74 it.live(
75 "a slow shutdown prints one delayed status line",
76 () =>
77 Effect.gen(function* () {
78 const fixture = yield* spawnFixture({
79 SHUTDOWN_FIXTURE_EXIT_AFTER_MS: "800",
80 });
81 yield* fixture.sigint;
82 yield* Effect.sleep(Duration.millis(300));
83 expect(fixture.stderr()).not.toContain("Shutting down");
84 yield* waitForStderr(fixture.stderr, "Shutting down");
85 expect(yield* fixture.handle.exitCode).toBe(0);
86 expect(fixture.stderr().match(/Shutting down/g)).toHaveLength(1);
87 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
88 { timeout: 30_000 },
89 );
90
91 it.live(
92 "a shutdown finishing within the delay stays silent",
93 () =>
94 Effect.gen(function* () {
95 const fixture = yield* spawnFixture({
96 SHUTDOWN_FIXTURE_EXIT_AFTER_MS: "50",
97 });
98 yield* fixture.sigint;
99 expect(yield* fixture.handle.exitCode).toBe(0);
100 expect(fixture.stderr()).not.toContain("Shutting down");
101 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
102 { timeout: 30_000 },
103 );
104
105 it.live(
106 "a suppressed process stays silent",
107 () =>
108 Effect.gen(function* () {
109 const fixture = yield* spawnFixture({
110 SHUTDOWN_FIXTURE_SUPPRESS: "1",
111 SHUTDOWN_FIXTURE_EXIT_AFTER_MS: "800",
112 });
113 yield* fixture.sigint;
114 expect(yield* fixture.handle.exitCode).toBe(0);
115 expect(fixture.stderr()).not.toContain("Shutting down");
116 }).pipe(Effect.scoped, Effect.provide(PlatformServices)),
117 { timeout: 30_000 },
118 );
119});