test(diary): config, channels, rotation, redaction and slogtest
- config rules name their key; env strings overlay defaults; D-08 defaults - component floors, verbosity 1-3, quiet on the console only - daily rotation, retention, 0640 files, unwritable dir, lazy open, path confinement - redaction on every driver, console control-character stripping and prefix - loggers before Setup, Reset, HideOnTTY, 100 concurrent writers - testing/slogtest for the console handler and the JSON pipeline - sunscreen InstallDefault still installs a redacting logfmt handler
This commit is contained in:
159
modules/diary/console_test.go
Normal file
159
modules/diary/console_test.go
Normal file
@@ -0,0 +1,159 @@
|
||||
package diary_test
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.golem15.com/golem15/summercms/modules/bonfire"
|
||||
"git.golem15.com/golem15/summercms/modules/diary"
|
||||
"github.com/charmbracelet/colorprofile"
|
||||
)
|
||||
|
||||
// record handles one record with a zero time (no time column) on h.
|
||||
func record(t *testing.T, h slog.Handler, level slog.Level, msg string, attrs ...slog.Attr) {
|
||||
t.Helper()
|
||||
r := slog.NewRecord(time.Time{}, level, msg, 0)
|
||||
r.AddAttrs(attrs...)
|
||||
if err := h.Handle(context.Background(), r); err != nil {
|
||||
t.Fatalf("Handle: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleStripsControlChars: control characters in the message, in
|
||||
// attribute keys and in values never reach the terminal; line breaks become
|
||||
// spaces, so one record is one line.
|
||||
func TestConsoleStripsControlChars(t *testing.T) {
|
||||
for _, profile := range []colorprofile.Profile{colorprofile.NoTTY, colorprofile.ANSI256} {
|
||||
var buf bytes.Buffer
|
||||
h := diary.NewConsoleHandler(&buf, diary.ConsoleOptions{Profile: profile})
|
||||
record(t, h, slog.LevelInfo, "evil \x1b[31mred\x1b[0m\a bell\nsecond line\rreturn",
|
||||
slog.String("value", "a\x1b]0;title\x07b\nc"),
|
||||
slog.String("k\x1bey", "v"),
|
||||
slog.Any("err", errString("boom\x1b[2Jclear")))
|
||||
out := buf.String()
|
||||
if strings.Count(out, "\n") != 1 || !strings.HasSuffix(out, "\n") {
|
||||
t.Errorf("profile %v: record is not exactly one line: %q", profile, out)
|
||||
}
|
||||
for _, bad := range []string{"\x1b[31mred", "\x1b]0;", "\a", "\x07", "\r", "\x1b[2J", "k\x1bey"} {
|
||||
if strings.Contains(out, bad) {
|
||||
t.Errorf("profile %v: output carries %q: %q", profile, bad, out)
|
||||
}
|
||||
}
|
||||
for _, want := range []string{"[31mred", "bell second line return", "[2Jclear", "key="} {
|
||||
if !strings.Contains(out, want) {
|
||||
t.Errorf("profile %v: output lacks the cleaned text %q: %q", profile, want, out)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type errString string
|
||||
|
||||
func (e errString) Error() string { return string(e) }
|
||||
|
||||
// TestConsoleComponentPrefix: the component is a fixed-width column, so
|
||||
// messages line up whether a record has a short component, none or one
|
||||
// given as a record attribute; inside a group it is an ordinary attribute.
|
||||
func TestConsoleComponentPrefix(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
h := diary.NewConsoleHandler(&buf, diary.ConsoleOptions{Profile: colorprofile.NoTTY})
|
||||
record(t, h.WithAttrs([]slog.Attr{slog.String(diary.ComponentKey, "db")}), slog.LevelInfo, "MSG")
|
||||
record(t, h.WithAttrs([]slog.Attr{slog.String(diary.ComponentKey, "journal")}), slog.LevelWarn, "MSG")
|
||||
record(t, h, slog.LevelError, "MSG")
|
||||
record(t, h, slog.LevelDebug, "MSG", slog.String(diary.ComponentKey, "river"))
|
||||
record(t, h.WithGroup("g").WithAttrs([]slog.Attr{slog.String(diary.ComponentKey, "inner")}), slog.LevelInfo, "MSG")
|
||||
|
||||
lines := strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n")
|
||||
if len(lines) != 5 {
|
||||
t.Fatalf("got %d lines, want 5:\n%s", len(lines), buf.String())
|
||||
}
|
||||
wantPrefix := []string{
|
||||
"INFO db MSG",
|
||||
"WARN journal MSG",
|
||||
"ERROR MSG",
|
||||
"DEBUG river MSG",
|
||||
"INFO MSG g.component=inner",
|
||||
}
|
||||
for i, want := range wantPrefix {
|
||||
if !strings.HasPrefix(lines[i], want) {
|
||||
t.Errorf("line %d = %q, want prefix %q", i, lines[i], want)
|
||||
}
|
||||
if col := strings.Index(lines[i], "MSG"); col != 17 {
|
||||
t.Errorf("line %d: message at column %d, want 17: %q", i, col, lines[i])
|
||||
}
|
||||
}
|
||||
if strings.Contains(lines[3], "component=") {
|
||||
t.Errorf("record-level component printed as an attribute: %q", lines[3])
|
||||
}
|
||||
|
||||
// A record with a time starts with HH:MM:SS.
|
||||
buf.Reset()
|
||||
r := slog.NewRecord(time.Date(2026, 10, 21, 9, 5, 7, 0, time.Local), slog.LevelInfo, "timed", 0)
|
||||
if err := h.Handle(context.Background(), r); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(buf.String(), "09:05:07 INFO ") {
|
||||
t.Errorf("timed line = %q, want the 09:05:07 prefix", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleStyledOnlyOnTerminal: the NoTTY profile writes no escape
|
||||
// codes and ANSI256 does; through Setup, the auto format styles only when
|
||||
// the profile allows it (never on a plain buffer unless forced).
|
||||
func TestConsoleStyledOnlyOnTerminal(t *testing.T) {
|
||||
for _, tc := range []struct {
|
||||
profile colorprofile.Profile
|
||||
styled bool
|
||||
}{{colorprofile.NoTTY, false}, {colorprofile.ANSI256, true}, {colorprofile.TrueColor, true}} {
|
||||
var buf bytes.Buffer
|
||||
h := diary.NewConsoleHandler(&buf, diary.ConsoleOptions{Profile: tc.profile})
|
||||
record(t, h.WithAttrs([]slog.Attr{slog.String(diary.ComponentKey, "blog")}), slog.LevelWarn, "styled?", slog.Int("n", 1))
|
||||
if got := strings.Contains(buf.String(), "\x1b["); got != tc.styled {
|
||||
t.Errorf("profile %v: escape codes %v, want %v: %q", tc.profile, got, tc.styled, buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
for _, tc := range []struct {
|
||||
name string
|
||||
color bonfire.ColorMode
|
||||
environ []string
|
||||
styled bool
|
||||
}{
|
||||
{"auto on a buffer", bonfire.ColorAuto, []string{}, false},
|
||||
{"auto forced", bonfire.ColorAuto, []string{"FORCE_COLOR=1"}, true},
|
||||
{"auto forced but NO_COLOR", bonfire.ColorAuto, []string{"FORCE_COLOR=1", "NO_COLOR=1"}, false},
|
||||
{"always", bonfire.ColorAlways, []string{}, true},
|
||||
{"never forced", bonfire.ColorNever, []string{"FORCE_COLOR=1"}, false},
|
||||
} {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
var buf lockedBuffer
|
||||
setup(t, diary.Options{Stderr: &buf, Color: tc.color, Environ: tc.environ}, "SUMMER_LOG__DEFAULT=console")
|
||||
diary.For("blog").Info("hello")
|
||||
out := buf.String()
|
||||
if got := strings.Contains(out, "\x1b["); got != tc.styled {
|
||||
t.Errorf("escape codes %v, want %v: %q", got, tc.styled, out)
|
||||
}
|
||||
if !tc.styled && !strings.Contains(out, "level=INFO") {
|
||||
t.Errorf("unstyled console is not logfmt: %q", out)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestConsoleLevelFromOptions: ConsoleOptions.Level gates records and nil
|
||||
// means info.
|
||||
func TestConsoleLevelFromOptions(t *testing.T) {
|
||||
var buf bytes.Buffer
|
||||
h := diary.NewConsoleHandler(&buf, diary.ConsoleOptions{Profile: colorprofile.NoTTY})
|
||||
if h.Enabled(context.Background(), slog.LevelDebug) || !h.Enabled(context.Background(), slog.LevelInfo) {
|
||||
t.Error("default console level is not info")
|
||||
}
|
||||
h = diary.NewConsoleHandler(&buf, diary.ConsoleOptions{Profile: colorprofile.NoTTY, Level: slog.LevelError})
|
||||
if h.Enabled(context.Background(), slog.LevelWarn) {
|
||||
t.Error("console level error lets warn through")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user