Test / test (push) Canceled after 0s
Test / fuzz (./compat, FuzzParseFormat) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatJSON) (push) Canceled after 0s
Test / fuzz (./formatter, FuzzFormatTextPolicy) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSanitize) (push) Canceled after 0s
Test / fuzz (./sanitizer, FuzzSerializerJSON) (push) Canceled after 0s
A host that draws on the terminal (a TUI) cannot let the logger write to stdout or stderr, yet the records are worth showing inside the host. SetErrorHandler already covers internal diagnostics; SetConsoleWriter is its sibling for console output. - SetConsoleWriter(w) sends console output of every console_target to w, split's warn-and-above records included; nil restores stdout/stderr. It holds across ApplyConfig and is safe while logging. - One stored value: ApplyConfig and SetConsoleWriter publish the console writers through storeConsole; the processor's split branch now reads the stored warn writer instead of writing os.Stderr directly. - TestLoggerConsoleTargets becomes TestConsoleWriterTakesEveryTarget, which asserts where each target's records land; a second test checks that nil restores the configured writers. - README names both hooks for terminal hosts; the API doc's callback contract covers console writers. Verified: go build ./... ok; gofmt -l on changed files clean; go vet ./... ok; go test ./... ok (all four packages, under 1 s); go test -race on the new tests ok; mutation run 6 of 6 caught. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01SyaMAhyEzAp3ULLWJZG4jQ
279 lines
9.3 KiB
Go
279 lines
9.3 KiB
Go
package log
|
|
|
|
import (
|
|
"bytes"
|
|
"io"
|
|
"os"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
// TestNewLogger verifies initial state of an unconfigured logger.
|
|
func TestNewLogger(t *testing.T) {
|
|
logger := NewLogger()
|
|
|
|
isFalse(t, logger.state.IsInitialized.Load(), "IsInitialized")
|
|
isFalse(t, logger.state.LoggerDisabled.Load(), "LoggerDisabled")
|
|
isFalse(t, logger.state.Started.Load(), "Started")
|
|
isTrue(t, logger.state.ProcessorExited.Load(), "ProcessorExited")
|
|
|
|
// A default formatter must exist to avoid nil dereference before ApplyConfig
|
|
if logger.formatter.Load() == nil {
|
|
t.Error("formatter not pre-initialized")
|
|
}
|
|
// Start before ApplyConfig must fail
|
|
errContains(t, logger.Start(), "logger not initialized", "Start")
|
|
}
|
|
|
|
// TestApplyConfig verifies initialization and log file creation.
|
|
func TestApplyConfig(t *testing.T) {
|
|
logger, tmpDir := newTestLogger(t)
|
|
|
|
isTrue(t, logger.state.IsInitialized.Load(), "IsInitialized")
|
|
if _, err := os.Stat(filepath.Join(tmpDir, "log.log")); err != nil {
|
|
t.Errorf("active log file missing: %v", err)
|
|
}
|
|
}
|
|
|
|
// TestApplyConfigRejection verifies invalid configs are rejected without mutating state.
|
|
func TestApplyConfigRejection(t *testing.T) {
|
|
logger, _ := newTestLogger(t)
|
|
before := *logger.GetConfig()
|
|
|
|
errContains(t, logger.ApplyConfig(nil), "cannot be nil", "nil config")
|
|
|
|
bad := logger.GetConfig()
|
|
bad.Format = "yaml"
|
|
errContains(t, logger.ApplyConfig(bad), "invalid format", "invalid format")
|
|
|
|
mustEqual(t, *logger.GetConfig(), before, "config after rejected applies")
|
|
}
|
|
|
|
// TestApplyConfigString covers key-value overrides, error paths, and rollback.
|
|
func TestApplyConfigString(t *testing.T) {
|
|
logger, _ := newTestLogger(t)
|
|
// Dedicated directory target; never point a file-enabled logger at a shared path
|
|
movedDir := filepath.Join(t.TempDir(), "moved")
|
|
|
|
tests := []struct {
|
|
name string
|
|
overrides []string
|
|
wantErr string
|
|
verify func(t *testing.T, cfg *Config)
|
|
}{
|
|
{
|
|
name: "numeric level and directory",
|
|
overrides: []string{"level=-4", "directory=" + movedDir, "format=json"},
|
|
verify: func(t *testing.T, cfg *Config) {
|
|
equal(t, cfg.Level, LevelDebug, "Level")
|
|
equal(t, cfg.Directory, movedDir, "Directory")
|
|
equal(t, cfg.Format, "json", "Format")
|
|
},
|
|
},
|
|
{
|
|
name: "named level",
|
|
overrides: []string{"level=warn"},
|
|
verify: func(t *testing.T, cfg *Config) { equal(t, cfg.Level, LevelWarn, "Level") },
|
|
},
|
|
{
|
|
name: "boolean values",
|
|
overrides: []string{"enable_console=true", "enable_file=true", "show_timestamp=false"},
|
|
verify: func(t *testing.T, cfg *Config) {
|
|
isTrue(t, cfg.EnableConsole, "EnableConsole")
|
|
isTrue(t, cfg.EnableFile, "EnableFile")
|
|
isFalse(t, cfg.ShowTimestamp, "ShowTimestamp")
|
|
},
|
|
},
|
|
{
|
|
name: "float and policy values",
|
|
overrides: []string{"retention_period_hrs=1.5", "sanitization=txt"},
|
|
verify: func(t *testing.T, cfg *Config) {
|
|
equal(t, cfg.RetentionPeriodHrs, 1.5, "RetentionPeriodHrs")
|
|
equal(t, cfg.Sanitization, PolicyTxt, "Sanitization")
|
|
},
|
|
},
|
|
{name: "missing separator", overrides: []string{"invalid"}, wantErr: "expected key=value"},
|
|
{name: "empty key", overrides: []string{"=value"}, wantErr: "key cannot be empty"},
|
|
{name: "unknown key", overrides: []string{"unknown_key=value"}, wantErr: "unknown configuration key"},
|
|
{name: "bad integer", overrides: []string{"buffer_size=not_a_number"}, wantErr: "invalid integer value"},
|
|
{name: "bad boolean", overrides: []string{"enable_file=yes-please"}, wantErr: "invalid boolean value"},
|
|
{name: "bad level name", overrides: []string{"level=verbose"}, wantErr: "invalid level value"},
|
|
// Field parse succeeds; rejection happens in Validate
|
|
{name: "unvalidated policy", overrides: []string{"sanitization=bogus"}, wantErr: "invalid sanitization policy"},
|
|
{
|
|
name: "multiple errors combined",
|
|
overrides: []string{"unknown_key=1", "buffer_size=x"},
|
|
wantErr: "multiple configuration errors",
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
before := *logger.GetConfig()
|
|
err := logger.ApplyConfigString(tt.overrides...)
|
|
|
|
if tt.wantErr != "" {
|
|
errContains(t, err, tt.wantErr, "ApplyConfigString")
|
|
equal(t, *logger.GetConfig(), before, "config must be unchanged on error")
|
|
return
|
|
}
|
|
mustNoErr(t, err, "ApplyConfigString")
|
|
tt.verify(t, logger.GetConfig())
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestLoggerLoggingLevels checks level-based filtering of emitted records.
|
|
func TestLoggerLoggingLevels(t *testing.T) {
|
|
logger, tmpDir := newTestLogger(t)
|
|
|
|
logger.Debug("debug message")
|
|
logger.Info("info message")
|
|
logger.Warn("warn message")
|
|
logger.Error("error message")
|
|
mustNoErr(t, logger.Flush(time.Second), "Flush")
|
|
|
|
// Writes are asynchronous; poll until all expected records land
|
|
mustEventually(t, time.Second, "log records written", func() bool {
|
|
c := readLog(t, tmpDir)
|
|
return strings.Contains(c, "info message") &&
|
|
strings.Contains(c, "warn message") &&
|
|
strings.Contains(c, "error message")
|
|
})
|
|
|
|
content := readLog(t, tmpDir)
|
|
notContains(t, content, "debug message", "debug below configured level")
|
|
}
|
|
|
|
// TestLoggerTraceDepth verifies trace emission is gated by depth without panicking.
|
|
func TestLoggerTraceDepth(t *testing.T) {
|
|
logger, tmpDir := newTestLogger(t)
|
|
|
|
cfg := logger.GetConfig()
|
|
cfg.Level = LevelDebug
|
|
cfg.Format = "txt"
|
|
cfg.ShowTimestamp = false
|
|
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
|
|
|
|
logger.Info("no trace here") // TraceDepth 0 -> no trace field
|
|
logger.DebugTrace(2, "traced") // explicit depth -> trace present
|
|
mustNoErr(t, logger.Flush(time.Second), "Flush")
|
|
|
|
mustEventually(t, time.Second, "traced record written", func() bool {
|
|
return strings.Contains(readLog(t, tmpDir), "traced")
|
|
})
|
|
|
|
for _, line := range strings.Split(readLog(t, tmpDir), "\n") {
|
|
if strings.Contains(line, "no trace here") && strings.Contains(line, "->") {
|
|
t.Errorf("unexpected trace on zero-depth record: %s", line)
|
|
}
|
|
}
|
|
}
|
|
|
|
// TestLoggerConcurrency exercises concurrent producers against a single processor.
|
|
func TestLoggerConcurrency(t *testing.T) {
|
|
logger, _ := newTestLogger(t)
|
|
|
|
const goroutines, perGoroutine = 10, 100
|
|
var wg sync.WaitGroup
|
|
for i := range goroutines {
|
|
wg.Add(1)
|
|
go func(i int) {
|
|
defer wg.Done()
|
|
for j := range perGoroutine {
|
|
logger.Info("goroutine", i, "log", j)
|
|
}
|
|
}(i)
|
|
}
|
|
wg.Wait()
|
|
noErr(t, logger.Flush(time.Second), "Flush")
|
|
|
|
// Upper bound only: processor-side write/rotation failures increment
|
|
// DroppedLogs without TotalDroppedLogs, so an exact identity is unsafe
|
|
processed := logger.state.TotalLogsProcessed.Load()
|
|
dropped := logger.state.TotalDroppedLogs.Load()
|
|
if total := uint64(goroutines * perGoroutine); processed+dropped > total {
|
|
t.Errorf("counters exceed submitted records: processed=%d dropped=%d total=%d",
|
|
processed, dropped, total)
|
|
}
|
|
if processed == 0 {
|
|
t.Error("no records processed")
|
|
}
|
|
}
|
|
|
|
// TestConsoleWriterTakesEveryTarget verifies each console target, split's warn
|
|
// records included, writes to a console writer set before ApplyConfig.
|
|
func TestConsoleWriterTakesEveryTarget(t *testing.T) {
|
|
for _, target := range []string{"stdout", "stderr", "split"} {
|
|
t.Run(target, func(t *testing.T) {
|
|
var out bytes.Buffer
|
|
logger := NewLogger()
|
|
logger.SetConsoleWriter(&out)
|
|
cfg := DefaultConfig()
|
|
cfg.EnableConsole = true
|
|
cfg.EnableFile = false
|
|
cfg.ConsoleTarget = target
|
|
|
|
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
|
|
mustNoErr(t, logger.Start(), "Start")
|
|
logger.Info("console info")
|
|
logger.Error("console error")
|
|
mustNoErr(t, logger.Shutdown(time.Second), "Shutdown")
|
|
contains(t, out.String(), "console info", "info record")
|
|
contains(t, out.String(), "console error", "error record")
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestConsoleWriterNilRestoresTarget verifies nil returns a running logger's
|
|
// console output to the configured stdout and stderr.
|
|
func TestConsoleWriterNilRestoresTarget(t *testing.T) {
|
|
var out bytes.Buffer
|
|
logger := NewLogger()
|
|
cfg := DefaultConfig()
|
|
cfg.EnableFile = false
|
|
cfg.ConsoleTarget = "split"
|
|
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
|
|
mustNoErr(t, logger.Start(), "Start")
|
|
t.Cleanup(func() { _ = logger.Shutdown() })
|
|
|
|
logger.SetConsoleWriter(&out)
|
|
logger.Info("redirected")
|
|
mustNoErr(t, logger.Flush(time.Second), "Flush")
|
|
contains(t, out.String(), "redirected", "record after SetConsoleWriter")
|
|
|
|
logger.SetConsoleWriter(nil)
|
|
got := logger.state.StdoutWriter.Load().(*sink)
|
|
equal(t, got.w, io.Writer(os.Stdout), "below warn")
|
|
equal(t, got.warn, io.Writer(os.Stderr), "warn and above")
|
|
}
|
|
|
|
// TestLoggerWrite verifies Write emits raw bytes with no formatting, framing, or sanitization.
|
|
func TestLoggerWrite(t *testing.T) {
|
|
logger, tmpDir := newTestLogger(t)
|
|
|
|
// PolicyTxt would hex-encode control bytes; FlagRaw must bypass it
|
|
cfg := logger.GetConfig()
|
|
cfg.Sanitization = PolicyTxt
|
|
cfg.Format = "txt"
|
|
mustNoErr(t, logger.ApplyConfig(cfg), "ApplyConfig")
|
|
|
|
logger.Write("raw", "output", 123)
|
|
logger.Write("\x1b[31m")
|
|
mustNoErr(t, logger.Flush(time.Second), "Flush")
|
|
|
|
mustEventually(t, time.Second, "raw record written", func() bool {
|
|
return strings.Contains(readLog(t, tmpDir), "\x1b[31m")
|
|
})
|
|
|
|
content := readLog(t, tmpDir)
|
|
contains(t, content, "raw output 123", "space-joined raw args")
|
|
notContains(t, content, "<1b>", "sanitizer must be bypassed under FlagRaw")
|
|
if strings.HasSuffix(content, "\n") {
|
|
t.Error("Write must not append a trailing newline")
|
|
}
|
|
}
|