Files
bwilliams 71eca2f194 Terminal logging is now coloured only on an interactive terminal
The terminal logger always coloured its text, so output piped or
redirected to a file, and the systemd journal, got escape codes. It
now colours only an interactive terminal.

The service's logs can therefore go to the journal: logging.file is
now optional for it, and the unit's comments and the README say so.
2026-10-21 15:39:36 +01:00

252 lines
6.0 KiB
Go

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)
}
}