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 }