1import { assert, describe, it } from "@effect/vitest"
2import { Context, Effect, FileSystem, Layer, Logger, Option, Path, Stdio } from "effect"
3import { CliOutput, Command, Flag, GlobalFlag } from "effect/cli"
4import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner"
5import * as MockTerminal from "./services/MockTerminal.ts"
6
7interface Log {
8 readonly message: unknown
9 readonly level: string
10 readonly timestamp: Date
11}
12
13class MockLogger extends Context.Service<MockLogger, {
14 readonly logs: Effect.Effect<ReadonlyArray<Log>>
15}>()("MockLogger") {
16 static logs = Effect.service(MockLogger).pipe(
17 Effect.flatMap((logger) => logger.logs)
18 )
19}
20
21const makeMockLogger = Effect.gen(function*() {
22 const logs: Array<Log> = []
23
24 const mockLogger = Logger.make((options) => {
25 // Extract the actual message from the array wrapper
26 const message = Array.isArray(options.message) && options.message.length === 1
27 ? options.message[0]
28 : options.message
29
30 logs.push({
31 message,
32 level: options.logLevel,
33 timestamp: options.date
34 })
35 })
36
37 return Context.make(MockLogger, { logs: Effect.sync(() => logs) }).pipe(
38 Context.add(Logger.CurrentLoggers, new Set([mockLogger]))
39 )
40})
41
42const FileSystemLayer = FileSystem.layerNoop({})
43const PathLayer = Path.layer
44const TerminalLayer = MockTerminal.layer
45const CliOutputLayer = CliOutput.layer(CliOutput.defaultFormatter({ colors: false }))
46const SpawnerLayer = Layer.succeed(
47 ChildProcessSpawner.ChildProcessSpawner,
48 ChildProcessSpawner.make(() => Effect.die("Not implemented"))
49)
50const LoggerLayer = Layer.effectContext(makeMockLogger)
51
52const TestLayer = Layer.mergeAll(
53 FileSystemLayer,
54 PathLayer,
55 TerminalLayer,
56 CliOutputLayer,
57 SpawnerLayer,
58 LoggerLayer,
59 Stdio.layerTest({})
60)
61
62describe("LogLevel", () => {
63 // All possible logs in severity order
64 const allLogs = [
65 { level: "Fatal", message: "fatal" },
66 { level: "Error", message: "error" },
67 { level: "Warn", message: "warn" },
68 { level: "Info", message: "info" },
69 { level: "Debug", message: "debug" },
70 { level: "Info", message: "trace" } // Effect.log() creates Info level
71 ]
72
73 // Log level severity order (higher index = less severe)
74 const severityOrder = ["Fatal", "Error", "Warn", "Info", "Debug", "Trace"]
75
76 // Filter logs based on minimum level
77 const filterLogs = (minLevel: string) => {
78 if (minLevel === "none") return []
79 if (minLevel === "all") return allLogs.slice().reverse()
80
81 const minSeverity = severityOrder.indexOf(
82 minLevel === "warning" ? "Warn" : minLevel.charAt(0).toUpperCase() + minLevel.slice(1)
83 )
84 return allLogs
85 .filter((log) => severityOrder.indexOf(log.level) <= minSeverity)
86 .reverse()
87 }
88
89 // Test helper that logs at all levels and returns captured logs
90 const testLogLevels = Effect.fnUntraced(function*(logLevel?: string) {
91 const testCommand = Command.make("test").pipe(
92 Command.withHandler(Effect.fnUntraced(function*() {
93 // Log at all levels to test filtering
94 yield* Effect.log("trace") // Info level by default
95 yield* Effect.logDebug("debug")
96 yield* Effect.logInfo("info")
97 yield* Effect.logWarning("warn")
98 yield* Effect.logError("error")
99 yield* Effect.logFatal("fatal")
100 }))
101 )
102
103 const runCommand = Command.runWith(testCommand, { version: "1.0.0" })
104 yield* runCommand(logLevel ? ["--log-level", logLevel] : [])
105
106 const logs = yield* MockLogger.logs
107 return logs.map((log) => ({ level: log.level, message: log.message }))
108 })
109
110 // Test cases
111 const testCases = [
112 "all",
113 "trace",
114 "debug",
115 "info",
116 "warn",
117 "warning",
118 "error",
119 "fatal",
120 "none"
121 ]
122
123 it.effect.each(testCases)("level=%s", (level) =>
124 Effect.gen(function*() {
125 const logs = yield* testLogLevels(level)
126 assert.deepStrictEqual(logs, filterLogs(level))
127 }).pipe(Effect.provide(TestLayer)))
128
129 it.effect("uses Info as the default minimum when --log-level is omitted", () =>
130 Effect.gen(function*() {
131 const logs = yield* testLogLevels()
132 // Default minimum log level filters out Debug but keeps Info and above
133 const expected = allLogs.filter((log) => log.message !== "debug").reverse()
134 assert.deepStrictEqual(logs, expected)
135 }).pipe(Effect.provide(TestLayer)))
136
137 it.effect("exposes the built-in log-level setting value", () =>
138 Effect.gen(function*() {
139 const seen: Array<Option.Option<string>> = []
140 const command = Command.make("test").pipe(
141 Command.withHandler(Effect.fnUntraced(function*() {
142 seen.push(Option.map(yield* GlobalFlag.LogLevel, (level) => level.toLowerCase()))
143 }))
144 )
145
146 const runCommand = Command.runWith(command, { version: "1.0.0" })
147
148 yield* runCommand([])
149 yield* runCommand(["--log-level", "warn"])
150
151 assert.deepStrictEqual(seen, [Option.none(), Option.some("warn")])
152 }).pipe(Effect.provide(TestLayer)))
153
154 it.effect("applies the selected log level to subcommands", () =>
155 Effect.gen(function*() {
156 const parentCommand = Command.make("parent", {
157 verbose: Flag.Boolean("verbose")
158 })
159
160 const childCommand = Command.make("child", {}, () =>
161 Effect.gen(function*() {
162 yield* Effect.logDebug("debug from child")
163 yield* Effect.logInfo("info from child")
164 yield* Effect.logError("error from child")
165 }))
166
167 const combined = parentCommand.pipe(Command.withSubcommands([childCommand]))
168 const runCommand = Command.runWith(combined, { version: "1.0.0" })
169
170 yield* runCommand(["--log-level", "info", "child"]).pipe(Effect.provide(TestLayer))
171
172 const logs = yield* MockLogger.logs
173
174 assert.deepStrictEqual(
175 logs.map((l) => ({ level: l.level, message: l.message })),
176 [
177 { level: "Info", message: "info from child" },
178 { level: "Error", message: "error from child" }
179 ]
180 )
181 }).pipe(Effect.provide(TestLayer)))
182
183 it.effect("keeps concurrent command log levels scoped to each run", () =>
184 Effect.gen(function*() {
185 const testCommand = Command.make("test", {}, () => Effect.logInfo("Should not see this"))
186
187 const runCommand = Command.runWith(testCommand, { version: "1.0.0" })
188
189 const result = yield* Effect.flip(
190 runCommand(["--log-level", "invalid"]).pipe(Effect.provide(TestLayer))
191 )
192
193 assert.strictEqual(result._tag, "InvalidValue")
194 if (result._tag === "InvalidValue") {
195 assert.strictEqual(result.option, "log-level")
196 assert.strictEqual(result.value, "invalid")
197 }
198
199 const logs = yield* MockLogger.logs
200 assert.strictEqual(logs.length, 0)
201 }).pipe(Effect.provide(TestLayer)))
202})