package logging import ( "context" "io" "log/slog" "os" "path/filepath" "strings" "testing" "github.com/lmittmann/tint" ) // saveGlobals restores the package level and the default slog logger after a // test that changes them. func saveGlobals(t *testing.T) { t.Helper() level := logLevel.Level() def := slog.Default() out, detect := terminal, isTerminal t.Cleanup(func() { logLevel.Set(level) slog.SetDefault(def) terminal, isTerminal = out, detect Shutdown() }) } func TestLevels(t *testing.T) { saveGlobals(t) slog.SetDefault(slog.New(slog.DiscardHandler)) SetLevel(slog.LevelInfo) if IsDebugEnabled() { t.Fatal("debug enabled at info level") } EnableDebug() if !IsDebugEnabled() { t.Fatal("EnableDebug did not enable debug") } // EnableDebug must not raise a level that is already more verbose. SetLevel(slog.LevelDebug - 4) EnableDebug() if got := logLevel.Level(); got != slog.LevelDebug-4 { t.Fatalf("level = %v, want %v", got, slog.LevelDebug-4) } SetLevel(slog.LevelError) if IsDebugEnabled() { t.Fatal("debug enabled at error level") } } // wrapper is a handler that wraps another, as middleware handlers do. type wrapper struct{ slog.Handler } func TestIsJSONHandler(t *testing.T) { // The handlers' types are what is tested, so slog.DiscardHandler won't do. //nolint:sloglint // see above jsonWithAttrs := slog.NewJSONHandler(io.Discard, nil).WithAttrs( []slog.Attr{slog.String("k", "v")}, ) //nolint:sloglint // as above tests := []struct { name string h slog.Handler want bool }{ {"json", slog.NewJSONHandler(io.Discard, nil), true}, {"text", slog.NewTextHandler(io.Discard, nil), false}, {"tint", tint.NewTextHandler(io.Discard, nil), false}, {"nil", nil, false}, {"json with attrs", jsonWithAttrs, true}, // Only slog's own JSON handler is recognised, not one wrapped in another. {"wrapped json", wrapper{ Handler: slog.NewJSONHandler(io.Discard, nil), }, false}, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { if got := IsJSONHandler(tc.h); got != tc.want { t.Fatalf("IsJSONHandler = %v, want %v", got, tc.want) } }) } } func TestLogToFileWritesJSON(t *testing.T) { saveGlobals(t) SetLevel(slog.LevelInfo) path := filepath.Join(t.TempDir(), "app.log") if err := LogToFile(path); err != nil { t.Fatal(err) } if st, err := os.Stat(path); err != nil { t.Fatal(err) } else if perm := st.Mode().Perm(); perm != 0o600 { t.Errorf("log file permissions = %o, want 600", perm) } if !IsJSONHandler(slog.Default().Handler()) { t.Fatal("default handler is not JSON after LogToFile") } slog.Info("hello file", "k", "v") slog.Debug("hidden") //nolint:gosec // G304: reading a file the test created in t.TempDir data, err := os.ReadFile(path) if err != nil { t.Fatal(err) } s := string(data) if !strings.Contains(s, `"msg":"hello file"`) || !strings.Contains(s, `"k":"v"`) { t.Fatalf("log file missing entry: %s", s) } if strings.Contains(s, "hidden") { t.Fatalf("debug line written at info level: %s", s) } } func TestLogToFileBadPath(t *testing.T) { saveGlobals(t) if err := LogToFile( filepath.Join(t.TempDir(), "missing", "app.log"), ); err == nil { t.Fatal("expected an error for a path in a missing directory") } } func TestLogToFileTracksFile(t *testing.T) { saveGlobals(t) if err := LogToFile(filepath.Join(t.TempDir(), "app.log")); err != nil { t.Fatal(err) } if !IsFileLogger() { t.Fatal("IsFileLogger() = false after LogToFile") } if err := Shutdown(); err != nil { t.Fatal(err) } if IsFileLogger() { t.Fatal("IsFileLogger() = true after Shutdown") } } func TestShutdownWithoutFile(t *testing.T) { if err := Shutdown(); err != nil { t.Fatal(err) } if IsFileLogger() { t.Fatal("IsFileLogger() = true with no file") } } func TestLogToTerminal(t *testing.T) { saveGlobals(t) LogToTerminal() h := slog.Default().Handler() if IsJSONHandler(h) { t.Fatal("terminal handler reported as JSON") } SetLevel(slog.LevelWarn) if h.Enabled(context.Background(), slog.LevelInfo) { t.Fatal("terminal handler ignores the package level") } } // The text is coloured on an interactive terminal only. func TestLogToTerminalColour(t *testing.T) { for _, interactive := range []bool{true, false} { saveGlobals(t) out, err := os.CreateTemp(t.TempDir(), "stdout") if err != nil { t.Fatal(err) } t.Cleanup(func() { out.Close() }) terminal = out isTerminal = func(*os.File) bool { return interactive } LogToTerminal() slog.Warn("hello", "key", "value") written, err := os.ReadFile(out.Name()) if err != nil { t.Fatal(err) } coloured := strings.Contains(string(written), "\x1b[") if coloured != interactive || !strings.Contains(string(written), "hello") { t.Errorf("interactive %v: wrote %q", interactive, written) } } } // On an interactive terminal the handler, and loggers derived from it, report // that they write to the terminal; when stdout is a file or pipe they don't. func TestWritesToTerminal(t *testing.T) { for _, interactive := range []bool{true, false} { saveGlobals(t) isTerminal = func(*os.File) bool { return interactive } LogToTerminal() derived := slog.Default().With("component", "test").WithGroup("g") for name, h := range map[string]slog.Handler{ "default": slog.Default().Handler(), "derived": derived.Handler(), } { if got := WritesToTerminal(h); got != interactive { t.Errorf( "interactive %v, %s: WritesToTerminal = %v", interactive, name, got, ) } } } if WritesToTerminal(slog.DiscardHandler) { t.Error("another handler reported as the terminal") } } func TestPrintToTerminal(t *testing.T) { saveGlobals(t) out, err := os.Create(filepath.Join(t.TempDir(), "terminal")) if err != nil { t.Fatal(err) } defer out.Close() terminal = out PrintToTerminal("line one\nline two") got, err := os.ReadFile(out.Name()) if err != nil { t.Fatal(err) } if string(got) != "line one\nline two\n" { t.Errorf("printed %q; want the text as it is, then a newline", got) } }