Files
summercms/modules/diary/slogtest_test.go
Jakub Zych 24f4643283 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
2026-10-21 19:09:28 +02:00

160 lines
4.2 KiB
Go

package diary_test
import (
"bytes"
"encoding/json"
"fmt"
"log/slog"
"regexp"
"strconv"
"strings"
"testing"
"testing/slogtest"
"git.golem15.com/golem15/summercms/modules/diary"
"github.com/charmbracelet/colorprofile"
)
// TestSlogtestConsoleHandler: the styled console handler (with the NoTTY
// profile, so its lines carry no escape codes) passes testing/slogtest.
func TestSlogtestConsoleHandler(t *testing.T) {
var buf bytes.Buffer
h := diary.NewConsoleHandler(&buf, diary.ConsoleOptions{Profile: colorprofile.NoTTY})
results := func() []map[string]any {
var out []map[string]any
for _, line := range strings.Split(strings.TrimSuffix(buf.String(), "\n"), "\n") {
if line == "" {
continue
}
m, err := parseConsoleLine(line)
if err != nil {
t.Fatalf("unparsable console line %q: %v", line, err)
}
out = append(out, m)
}
return out
}
if err := slogtest.TestHandler(h, results); err != nil {
t.Fatal(err)
}
}
// TestSlogtestPipeline: the full pipeline (component handler, sunscreen and
// a stack of a JSON stderr channel and a null channel) passes
// testing/slogtest through its JSON output, for a component logger and for
// the process default.
func TestSlogtestPipeline(t *testing.T) {
for _, name := range []string{"pipeline", ""} {
t.Run(fmt.Sprintf("component %q", name), func(t *testing.T) {
var buf lockedBuffer
setup(t, diary.Options{Stderr: &buf},
"SUMMER_LOG__DEFAULT=stack",
"SUMMER_LOG__CHANNELS__STACK__CHANNELS=stderr,null",
"SUMMER_LOG__CHANNELS__STDERR__FORMAT=json",
)
h := diary.For(name).Handler()
if name == "" {
h = slog.Default().Handler()
}
results := func() []map[string]any {
var out []map[string]any
for _, line := range strings.Split(strings.TrimSpace(buf.String()), "\n") {
var m map[string]any
if err := json.Unmarshal([]byte(line), &m); err != nil {
t.Fatalf("pipeline line is not JSON: %q: %v", line, err)
}
if name != "" && m[diary.ComponentKey] != name {
t.Fatalf("pipeline record lacks component %q: %v", name, m)
}
out = append(out, m)
}
return out
}
if err := slogtest.TestHandler(h, results); err != nil {
t.Fatal(err)
}
})
}
}
var consoleTime = regexp.MustCompile(`^\d{2}:\d{2}:\d{2} `)
// parseConsoleLine reads one console line without a component: an optional
// HH:MM:SS time, the level, the message (the words before the first
// key=value field) and key=value fields whose dotted keys become nested
// maps. Quoted values are unquoted.
func parseConsoleLine(line string) (map[string]any, error) {
m := map[string]any{}
if loc := consoleTime.FindStringIndex(line); loc != nil {
m[slog.TimeKey] = strings.TrimSpace(line[:loc[1]])
line = line[loc[1]:]
}
level, rest, ok := strings.Cut(line, " ")
if !ok {
return nil, fmt.Errorf("no level")
}
m[slog.LevelKey] = level
tokens, err := splitFields(strings.TrimLeft(rest, " "))
if err != nil {
return nil, err
}
var msg []string
i := 0
for ; i < len(tokens) && !strings.Contains(tokens[i], "="); i++ {
msg = append(msg, tokens[i])
}
m[slog.MessageKey] = strings.Join(msg, " ")
for ; i < len(tokens); i++ {
key, raw, _ := strings.Cut(tokens[i], "=")
value := raw
if strings.HasPrefix(raw, `"`) {
if value, err = strconv.Unquote(raw); err != nil {
return nil, fmt.Errorf("value of %s: %w", key, err)
}
}
parts := strings.Split(key, ".")
cur := m
for _, p := range parts[:len(parts)-1] {
next, ok := cur[p].(map[string]any)
if !ok {
next = map[string]any{}
cur[p] = next
}
cur = next
}
cur[parts[len(parts)-1]] = value
}
return m, nil
}
// splitFields splits s on spaces, keeping a double-quoted value (with
// backslash escapes) inside its field.
func splitFields(s string) ([]string, error) {
var out []string
for s != "" {
var b strings.Builder
for s != "" && s[0] != ' ' {
if s[0] == '"' {
end := 1
for end < len(s) && s[end] != '"' {
if s[end] == '\\' {
end++
}
end++
}
if end >= len(s) {
return nil, fmt.Errorf("unterminated quote")
}
b.WriteString(s[:end+1])
s = s[end+1:]
continue
}
b.WriteByte(s[0])
s = s[1:]
}
out = append(out, b.String())
s = strings.TrimLeft(s, " ")
}
return out, nil
}