| 1 | import { assert, describe, it } from "@effect/vitest" |
| 2 | import { Context, Effect, FileSystem, Layer, Logger, Option, Path, Stdio } from "effect" |
| 3 | import { CliOutput, Command, Flag, GlobalFlag } from "effect/cli" |
| 4 | import * as ChildProcessSpawner from "effect/process/ChildProcessSpawner" |
| 5 | import * as MockTerminal from "./services/MockTerminal.ts" |
| 6 | |
| 7 | interface Log { |
| 8 | readonly message: unknown |
| 9 | readonly level: string |
| 10 | readonly timestamp: Date |
| 11 | } |
| 12 | |
| 13 | class 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 | |
| 21 | const 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 | |
| 42 | const FileSystemLayer = FileSystem.layerNoop({}) |
| 43 | const PathLayer = Path.layer |
| 44 | const TerminalLayer = MockTerminal.layer |
| 45 | const CliOutputLayer = CliOutput.layer(CliOutput.defaultFormatter({ colors: false })) |
| 46 | const SpawnerLayer = Layer.succeed( |
| 47 | ChildProcessSpawner.ChildProcessSpawner, |
| 48 | ChildProcessSpawner.make(() => Effect.die("Not implemented")) |
| 49 | ) |
| 50 | const LoggerLayer = Layer.effectContext(makeMockLogger) |
| 51 | |
| 52 | const TestLayer = Layer.mergeAll( |
| 53 | FileSystemLayer, |
| 54 | PathLayer, |
| 55 | TerminalLayer, |
| 56 | CliOutputLayer, |
| 57 | SpawnerLayer, |
| 58 | LoggerLayer, |
| 59 | Stdio.layerTest({}) |
| 60 | ) |
| 61 | |
| 62 | describe("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 | }) |