From 137097915cc69ce6c3d99ca768023133ea3de592 Mon Sep 17 00:00:00 2001 From: Boris Nagaev Date: Wed, 4 Feb 2026 19:01:44 -0500 Subject: [PATCH] cmd/loop: add session recorder plumbing --- cmd/loop/main.go | 136 +++++- cmd/loop/session_recorder.go | 696 ++++++++++++++++++++++++++++++ cmd/loop/session_recorder_test.go | 343 +++++++++++++++ 3 files changed, 1168 insertions(+), 7 deletions(-) create mode 100644 cmd/loop/session_recorder.go create mode 100644 cmd/loop/session_recorder_test.go diff --git a/cmd/loop/main.go b/cmd/loop/main.go index f6726b1d..113404fd 100644 --- a/cmd/loop/main.go +++ b/cmd/loop/main.go @@ -8,9 +8,11 @@ import ( "fmt" "io/ioutil" "os" + "os/signal" "path/filepath" "strconv" "strings" + "syscall" "time" "github.com/btcsuite/btcd/btcutil" @@ -103,8 +105,50 @@ var ( // cliClock provides the time source used by CLI commands. cliClock clock.Clock = clock.NewDefaultClock() + + // sessionRec is the active recorder when session capture is enabled. + sessionRec *sessionRecorder + + // forceDeterministicJSON is enabled by tests to obtain stable JSON + // output. + forceDeterministicJSON bool ) +// installSessionSignalHandler records signals and cancels the root context. +func installSessionSignalHandler(cancel context.CancelFunc) func() { + if sessionRec == nil { + return func() {} + } + + sigCh := make(chan os.Signal, 1) + signal.Notify(sigCh, os.Interrupt, syscall.SIGTERM) + + done := make(chan struct{}) + go func() { + defer close(done) + interrupted := false + for sig := range sigCh { + sessionRec.LogSignal(sig) + if !interrupted { + interrupted = true + cancel() + continue + } + + // A second signal requests immediate shutdown. Finalize the + // session first because os.Exit skips deferred cleanup. + _ = sessionRec.Finalize(fmt.Errorf("signal: %s", sig)) + os.Exit(130) + } + }() + + return func() { + signal.Stop(sigCh) + close(sigCh) + <-done + } +} + const ( // satAmtFmt formats a satoshi value into a one line string, intended to @@ -186,7 +230,8 @@ func printJSON(resp any) { fatal(err) } out.WriteString("\n") - _, _ = out.WriteTo(os.Stdout) + printBytes := maybeNormalizeJSON(out.Bytes()) + _, _ = os.Stdout.Write(printBytes) } func printRespJSON(resp proto.Message) { @@ -196,19 +241,71 @@ func printRespJSON(resp proto.Message) { return } - fmt.Println(string(jsonBytes)) + fmt.Println(string(maybeNormalizeJSON(jsonBytes))) } func fatal(err error) { fmt.Fprintf(os.Stderr, "[loop] %v\n", err) + if sessionRec != nil { + if finalizeErr := sessionRec.Finalize(err); finalizeErr != nil { + fmt.Fprintf(os.Stderr, "[loop] unable to finalize "+ + "session: %v\n", finalizeErr) + } + } os.Exit(1) } func main() { - rootCmd := newRootCommand() - if err := rootCmd.Run(context.Background(), os.Args); err != nil { + var err error + sessionRec, err = newSessionRecorder(os.Args) + if err != nil { fatal(err) } + + // Intercept clock and stdio if needed. + if sessionRec != nil { + restoreClock := hookClock( + clock.NewTestClock(sessionRec.ClockStart()), + ) + defer restoreClock() + + if err := sessionRec.Start(nil, nil, nil); err != nil { + fatal(err) + } + } + + // Intercept clock calls if needed. + var restoreTransport func() + if sessionRec != nil { + restoreTransport = hookGrpc(sessionRec) + defer restoreTransport() + } + + rootCmd := newRootCommand() + + ctx := context.Background() + ctx, cancel := context.WithCancel(ctx) + defer cancel() + + if sessionRec != nil { + ctx = sessionRec.InjectContext(ctx) + } + + if sessionRec != nil { + signalStop := installSessionSignalHandler(cancel) + defer signalStop() + } + + if err := rootCmd.Run(ctx, os.Args); err != nil { + fatal(err) + } + + if sessionRec != nil { + if err := sessionRec.Finalize(nil); err != nil { + fmt.Fprintf(os.Stderr, "[loop] unable to finalize "+ + "session: %v\n", err) + } + } } // newRootCommand constructs the CLI root command for loop. @@ -238,9 +335,7 @@ func newRootCommand() *cli.Command { // getClient establishes a SwapClient RPC connection and returns the client and // a cleanup handler. -func getClient(cmd *cli.Command) (looprpc.SwapClientClient, - func(), error) { - +func getClient(cmd *cli.Command) (looprpc.SwapClientClient, func(), error) { client, _, cleanup, err := getClientWithConn(cmd) if err != nil { return nil, nil, err @@ -273,6 +368,33 @@ func hookClock(c clock.Clock) func() { } } +// maybeNormalizeJSON rewrites JSON output to avoid the build-dependent spacing +// introduced by google.golang.org/protobuf/internal/encoding/json (see +// WriteName in protobuf-go-hex-display/internal/encoding/json/encode.go, which +// uses internal/detrand.Bool). When recording or replaying sessions we ensure +// stable output by re-encoding with the standard library. +func maybeNormalizeJSON(raw []byte) []byte { + if sessionRec == nil && !forceDeterministicJSON { + return raw + } + + var parsed any + if err := json.Unmarshal(raw, &parsed); err != nil { + return raw + } + + normalized, err := json.MarshalIndent(parsed, "", " ") + if err != nil { + return raw + } + + if len(raw) > 0 && raw[len(raw)-1] == '\n' { + normalized = append(normalized, '\n') + } + + return normalized +} + func getMaxRoutingFee(amt btcutil.Amount) btcutil.Amount { return swap.CalcFee(amt, maxRoutingFeeBase, maxRoutingFeeRate) } diff --git a/cmd/loop/session_recorder.go b/cmd/loop/session_recorder.go new file mode 100644 index 00000000..ddb43dc6 --- /dev/null +++ b/cmd/loop/session_recorder.go @@ -0,0 +1,696 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/lightninglabs/loop" + "github.com/urfave/cli/v3" + statuspb "google.golang.org/genproto/googleapis/rpc/status" + "google.golang.org/grpc" + "google.golang.org/grpc/metadata" + grpcstatus "google.golang.org/grpc/status" + "google.golang.org/protobuf/encoding/protojson" + "google.golang.org/protobuf/proto" +) + +const ( + sessionEnvVar = "LOOP_SESSION_RECORD" + sessionDefaultDir = "cmd/loop/testdata/sessions" + sessionFileExt = ".json" + defaultSessionSlug = "session" +) + +// Event identifiers in recorded sessions. +const ( + eventStdout = "stdout" + eventStderr = "stderr" + eventStdin = "stdin" + eventGrpc = "grpc" + eventExit = "exit" + eventSignal = "signal" +) + +// grpcMarshalOptions is marshalling options to encode gRPC messages in recorded +// sessions. +var grpcMarshalOptions = protojson.MarshalOptions{ + UseProtoNames: true, + EmitUnpopulated: true, +} + +// sessionRecorder captures CLI IO and gRPC traffic for replay. +type sessionRecorder struct { + mu sync.Mutex + started time.Time + filePath string + slug string + + metadata sessionMetadata + events []sessionEvent + eventErr error + + finalizeOnce sync.Once + + hooksMu sync.Mutex + hooksStarted bool + stdoutUnhook func() error + stderrUnhook func() error + stdinUnhook func() error +} + +// sessionFile is a single recorded CLI session in JSON format. They are +// stored inside sessionDefaultDir. +type sessionFile struct { + Metadata sessionMetadata `json:"metadata"` + Events []sessionEvent `json:"events"` +} + +// sessionMetadata stores static session details and runtime metadata. +type sessionMetadata struct { + Args []string `json:"args"` + Env map[string]string `json:"env"` + Version string `json:"version"` + ClockStartUnix int64 `json:"clock_start_unix"` + RunError *string `json:"run_error,omitempty"` + Duration *time.Duration `json:"duration,omitempty"` +} + +// sessionEvent records a single timestamped payload entry. +type sessionEvent struct { + TimeMS int64 `json:"time_ms"` + Kind string `json:"kind"` + Data json.RawMessage `json:"data"` +} + +// textPayload records stdout/stderr text chunks as newline-preserving lines. +type textPayload struct { + Lines []string `json:"lines"` +} + +// newTextPayload splits text into newline-preserving lines for fixture output. +func newTextPayload(text string) textPayload { + return textPayload{ + Lines: splitTextLines(text), + } +} + +// text joins a recorded text payload back into the original output. +func (p textPayload) text() string { + return joinTextLines(p.Lines) +} + +// stdinPayload records stdin chunks. +type stdinPayload struct { + Text string `json:"text"` +} + +// splitTextLines preserves line endings while splitting text for fixture +// storage. +func splitTextLines(text string) []string { + if text == "" { + return nil + } + + lines := strings.SplitAfter(text, "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + + return lines +} + +// joinTextLines reconstructs text stored as newline-preserving lines. +func joinTextLines(lines []string) string { + return strings.Join(lines, "") +} + +// grpcPayload records a gRPC message or error event. +type grpcPayload struct { + Method string `json:"method"` + Event string `json:"event"` + MessageType string `json:"message_type,omitempty"` + Payload json.RawMessage `json:"payload,omitempty"` + Error string `json:"error,omitempty"` + Status *statuspb.Status `json:"status,omitempty"` +} + +// exitPayload records the final run error, if any. +type exitPayload struct { + RunError *string `json:"run_error,omitempty"` +} + +// signalPayload records handled signals. +type signalPayload struct { + Signal string `json:"signal"` +} + +// newSessionRecorder creates a recorder when session recording is enabled. +func newSessionRecorder(args []string) (*sessionRecorder, error) { + // Session recording is disabled unless the env var is set. + envValue, ok := os.LookupEnv(sessionEnvVar) + if !ok { + return nil, nil + } + + // Allow explicit disable by setting the env var to false. + enabled, err := strconv.ParseBool(envValue) + if err != nil { + return nil, fmt.Errorf("invalid %s value %q", sessionEnvVar, + envValue) + } + if !enabled { + return nil, nil + } + + // Initialize the recorder before collecting metadata. + recorder := &sessionRecorder{ + started: time.Now(), + } + + // Derive the slug before resolving the output file path. + recorder.slug = deriveSessionSlug(args) + + // Capture metadata that remains stable for the session. + metadata := sessionMetadata{ + Args: append([]string(nil), args...), + Env: collectSessionEnv(), + Version: loop.RichVersion(), + ClockStartUnix: recorder.started.Unix(), + } + recorder.metadata = metadata + + // Resolve the session file location. + baseDir, fileName, err := recorder.resolveFilePath() + if err != nil { + return nil, err + } + recorder.filePath = filepath.Join(baseDir, fileName) + + return recorder, nil +} + +// collectSessionEnv extracts the environment variables recorded in sessions. +func collectSessionEnv() map[string]string { + env := make(map[string]string) + + // Record only LOOPCLI_ variables, excluding the recording toggle. + for _, kv := range os.Environ() { + parts := strings.SplitN(kv, "=", 2) + if len(parts) != 2 { + continue + } + key := parts[0] + value := parts[1] + if key == sessionEnvVar { + continue + } + if strings.HasPrefix(key, "LOOPCLI_") { + env[key] = value + } + } + + return env +} + +// resolveFilePath chooses the output directory and filename. +func (r *sessionRecorder) resolveFilePath() (string, string, error) { + if err := ensureSessionBaseDir(sessionDefaultDir); err != nil { + return "", "", err + } + + counter, err := nextSessionCounter(sessionDefaultDir) + if err != nil { + return "", "", err + } + + slug := r.slug + if slug == "" { + slug = defaultSessionSlug + } + + name := fmt.Sprintf("%02d_%s%s", counter, slug, sessionFileExt) + + return sessionDefaultDir, name, nil +} + +// ensureSessionBaseDir verifies that recording is running from the repo root. +func ensureSessionBaseDir(baseDir string) error { + info, err := os.Stat(baseDir) + if errors.Is(err, fs.ErrNotExist) { + return fmt.Errorf("%s does not exist; run session recording "+ + "from the repository root", baseDir) + } + if err != nil { + return err + } + if !info.IsDir() { + return fmt.Errorf("%s is not a directory", baseDir) + } + + return nil +} + +// logEvent records a new event with the elapsed timestamp. +func (r *sessionRecorder) logEvent(kind string, payload any) { + data, err := json.Marshal(payload) + if err != nil { + r.mu.Lock() + if r.eventErr == nil { + r.eventErr = fmt.Errorf("marshal session %s event: %w", + kind, err) + } + r.mu.Unlock() + + return + } + + event := sessionEvent{ + TimeMS: time.Since(r.started).Milliseconds(), + Kind: kind, + Data: data, + } + + r.mu.Lock() + defer r.mu.Unlock() + + r.events = append(r.events, event) +} + +// Start attaches stdin/stdout/stderr hooks for session recording. +func (r *sessionRecorder) Start(stdinSource io.Reader, + stdoutForward, stderrForward io.Writer) error { + + r.hooksMu.Lock() + defer r.hooksMu.Unlock() + + if r.hooksStarted { + return nil + } + + // Capture stdout and stderr first, then stdin. + origStdout := os.Stdout + if stdoutForward == nil { + stdoutForward = origStdout + } + outHook, err := hookStdout(origStdout, stdoutForward, func(p []byte) { + r.logEvent(eventStdout, newTextPayload(string(p))) + }) + if err != nil { + return err + } + + origStderr := os.Stderr + if stderrForward == nil { + stderrForward = origStderr + } + errHook, err := hookStderr(origStderr, stderrForward, func(p []byte) { + r.logEvent(eventStderr, newTextPayload(string(p))) + }) + if err != nil { + _ = outHook() + + return err + } + + origStdin := os.Stdin + if stdinSource == nil { + stdinSource = origStdin + } + stdinHook, err := hookStdin(origStdin, stdinSource, func(p []byte) { + r.logEvent(eventStdin, stdinPayload{Text: string(p)}) + }) + if err != nil { + _ = errHook() + _ = outHook() + + return err + } + + r.stdoutUnhook = outHook + r.stderrUnhook = errHook + r.stdinUnhook = stdinHook + r.hooksStarted = true + + return nil +} + +// stopHooks detaches any active IO hooks. +func (r *sessionRecorder) stopHooks() error { + r.hooksMu.Lock() + defer r.hooksMu.Unlock() + + var firstErr error + if r.stdoutUnhook != nil { + if err := r.stdoutUnhook(); err != nil && firstErr == nil { + firstErr = err + } + r.stdoutUnhook = nil + } + if r.stderrUnhook != nil { + if err := r.stderrUnhook(); err != nil && firstErr == nil { + firstErr = err + } + r.stderrUnhook = nil + } + if r.stdinUnhook != nil { + if err := r.stdinUnhook(); err != nil && firstErr == nil { + firstErr = err + } + r.stdinUnhook = nil + } + r.hooksStarted = false + + return firstErr +} + +// logExit records the final outcome and duration. +func (r *sessionRecorder) logExit(runErr error) { + var payload exitPayload + if runErr != nil { + msg := runErr.Error() + payload.RunError = &msg + } + + // Store the exit event first for the event stream. + r.logEvent(eventExit, payload) + + // Update metadata with the final run state. + duration := time.Since(r.started) + + r.mu.Lock() + defer r.mu.Unlock() + + if runErr != nil { + msg := runErr.Error() + r.metadata.RunError = &msg + } else { + r.metadata.RunError = nil + } + r.metadata.Duration = &duration +} + +// finalize writes the recorded session to disk once. +func (r *sessionRecorder) finalize(runErr error) error { + var finalizeErr error + r.finalizeOnce.Do(func() { + hookErr := r.stopHooks() + + r.logExit(runErr) + + r.mu.Lock() + metadata := r.metadata + events := append([]sessionEvent(nil), r.events...) + eventErr := r.eventErr + r.mu.Unlock() + + fileContent := sessionFile{ + Metadata: metadata, + Events: events, + } + + err := os.MkdirAll(filepath.Dir(r.filePath), 0o755) + if err != nil { + finalizeErr = errors.Join(err, eventErr, hookErr) + + return + } + + file, err := os.Create(r.filePath) + if err != nil { + finalizeErr = errors.Join(err, eventErr, hookErr) + + return + } + defer file.Close() + + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(fileContent); err != nil { + finalizeErr = errors.Join(err, eventErr, hookErr) + + return + } + + finalizeErr = errors.Join(eventErr, hookErr) + }) + + return finalizeErr +} + +// Finalize records the exit event and flushes the session to disk. +func (r *sessionRecorder) Finalize(runErr error) error { + return r.finalize(runErr) +} + +// ClockStart returns the fixed session clock used while recording CLI actions. +func (r *sessionRecorder) ClockStart() time.Time { + return time.Unix(r.metadata.ClockStartUnix, 0) +} + +// Dial uses the direct gRPC connection for recording. +func (r *sessionRecorder) Dial(cmd *cli.Command) (daemonConn, func(), error) { + return dialDirectConn(cmd) +} + +// UnaryInterceptor captures unary RPCs for session playback. +func (r *sessionRecorder) UnaryInterceptor() grpc.UnaryClientInterceptor { + return func(ctx context.Context, method string, req, reply any, + cc *grpc.ClientConn, invoker grpc.UnaryInvoker, + opts ...grpc.CallOption) error { + + r.logGRPCMessage(method, "request", req, nil) + + err := invoker(ctx, method, req, reply, cc, opts...) + if err != nil { + r.logGRPCMessage(method, "error", nil, err) + + return err + } + + r.logGRPCMessage(method, "response", reply, nil) + + return nil + } +} + +// StreamInterceptor captures stream RPCs for session playback. +func (r *sessionRecorder) StreamInterceptor() grpc.StreamClientInterceptor { + return func(ctx context.Context, desc *grpc.StreamDesc, + cc *grpc.ClientConn, method string, streamer grpc.Streamer, + opts ...grpc.CallOption) (grpc.ClientStream, error) { + + clientStream, err := streamer(ctx, desc, cc, method, opts...) + if err != nil { + r.logGRPCMessage(method, "error", nil, err) + + return nil, err + } + + return &recordingClientStream{ + ClientStream: clientStream, + recorder: r, + method: method, + }, nil + } +} + +// recordingClientStream wraps a gRPC stream and logs message events. +type recordingClientStream struct { + grpc.ClientStream + + recorder *sessionRecorder + method string +} + +// SendMsg records the outgoing stream message. +func (s *recordingClientStream) SendMsg(m any) error { + s.recorder.logGRPCMessage(s.method, "send", m, nil) + err := s.ClientStream.SendMsg(m) + if err != nil { + s.recorder.logGRPCMessage(s.method, "error", nil, err) + } + + return err +} + +// RecvMsg records the incoming stream message. +func (s *recordingClientStream) RecvMsg(m any) error { + err := s.ClientStream.RecvMsg(m) + if err != nil { + s.recorder.logGRPCMessage(s.method, "error", nil, err) + + return err + } + + s.recorder.logGRPCMessage(s.method, "recv", m, nil) + + return nil +} + +// logGRPCMessage captures gRPC request/response data in the event stream. +func (r *sessionRecorder) logGRPCMessage(method, event string, msg any, + receptionErr error) { + + payload := grpcPayload{Method: method, Event: event} + + if receptionErr != nil { + payload.Error = receptionErr.Error() + if grpcStatus, ok := grpcstatus.FromError(receptionErr); ok { + payload.Status = grpcStatus.Proto() + } + r.logEvent(eventGrpc, payload) + + return + } + + if msg != nil { + if protoMsg, ok := msg.(proto.Message); ok { + payload.MessageType = string( + proto.MessageName(protoMsg), + ) + data, err := grpcMarshalOptions.Marshal(protoMsg) + if err == nil { + payload.Payload = data + } + } else { + data, err := json.Marshal(msg) + if err == nil { + payload.Payload = data + } + } + } + + r.logEvent(eventGrpc, payload) +} + +// LogSignal records an incoming signal event. +func (r *sessionRecorder) LogSignal(sig os.Signal) { + r.logEvent(eventSignal, signalPayload{Signal: sig.String()}) +} + +// InjectContext tags the outgoing context with the session name. +func (r *sessionRecorder) InjectContext(ctx context.Context) context.Context { + return metadata.AppendToOutgoingContext( + ctx, "loop-session", filepath.Base(r.filePath), + ) +} + +// deriveSessionSlug builds a stable slug from the command arguments. +func deriveSessionSlug(args []string) string { + if len(args) == 0 { + return "" + } + + base := filepath.Base(args[0]) + tokens := []string{base} + + for _, arg := range args[1:] { + if strings.HasPrefix(arg, "-") { + break + } + if arg == "" { + continue + } + tokens = append(tokens, arg) + } + + return sanitizeSlug(strings.Join(tokens, "-")) +} + +// sanitizeSlug normalizes a session slug to a safe filename. +func sanitizeSlug(value string) string { + value = strings.ToLower(value) + var builder strings.Builder + lastDash := false + for _, r := range value { + if (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9') { + builder.WriteRune(r) + lastDash = false + + continue + } + + if !lastDash { + builder.WriteRune('-') + lastDash = true + } + } + + slug := strings.Trim(builder.String(), "-") + if slug == "" { + return defaultSessionSlug + } + + return slug +} + +// nextSessionCounter finds the next available session number. +func nextSessionCounter(baseDir string) (int, error) { + maxCounter := 0 + entries, err := os.ReadDir(baseDir) + if errors.Is(err, fs.ErrNotExist) { + return 1, nil + } + if err != nil { + return 0, err + } + + for _, entry := range entries { + if entry.IsDir() { + continue + } + if filepath.Ext(entry.Name()) != sessionFileExt { + continue + } + + counter, ok := parseSessionCounter(entry.Name()) + if !ok { + continue + } + if counter > maxCounter { + maxCounter = counter + } + } + + return maxCounter + 1, nil +} + +// parseSessionCounter extracts the numeric prefix from a session filename. +func parseSessionCounter(name string) (int, bool) { + base := strings.TrimSuffix(name, filepath.Ext(name)) + if base == "" { + return 0, false + } + + parts := strings.SplitN(base, "_", 2) + if parts[0] == "" { + return 0, false + } + + for _, r := range parts[0] { + if r < '0' || r > '9' { + return 0, false + } + } + + value, err := strconv.Atoi(parts[0]) + if err != nil { + return 0, false + } + + if value < 0 { + return 0, false + } + + return value, true +} diff --git a/cmd/loop/session_recorder_test.go b/cmd/loop/session_recorder_test.go new file mode 100644 index 00000000..be201b45 --- /dev/null +++ b/cmd/loop/session_recorder_test.go @@ -0,0 +1,343 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" + grpcstatus "google.golang.org/grpc/status" +) + +const ( + testLoopArg = "loop" + testOutArg = "out" + testLoopOutSlug = "loop-out" + testVersion = "test-version" + testClockStart = int64(1769407086) +) + +// TestDeriveSessionSlug verifies slug derivation from CLI arguments. +func TestDeriveSessionSlug(t *testing.T) { + tests := []struct { + name string + args []string + want string + }{ + { + name: "empty_args", + want: "", + }, + { + name: "binary_only", + args: []string{"/usr/local/bin/loop"}, + want: testLoopArg, + }, + { + name: "with_subcommand", + args: []string{testLoopArg, testOutArg}, + want: testLoopOutSlug, + }, + { + name: "stops_at_flag", + args: []string{testLoopArg, testOutArg, "--network", "regtest"}, + want: testLoopOutSlug, + }, + { + name: "skips_empty_args", + args: []string{testLoopArg, "", "quote", testOutArg}, + want: "loop-quote-out", + }, + { + name: "sanitizes_tokens", + args: []string{testLoopArg, "Quote", "Out"}, + want: "loop-quote-out", + }, + { + name: "sanitizes_path_base", + args: []string{"/tmp/loop-cli", testOutArg}, + want: "loop-cli-out", + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + slug := deriveSessionSlug(test.args) + require.Equal(t, test.want, slug) + }) + } +} + +// TestSanitizeSlug verifies slug normalization behavior. +func TestSanitizeSlug(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "already_clean", + input: testLoopOutSlug, + want: testLoopOutSlug, + }, + { + name: "upper_and_spaces", + input: "Loop Out", + want: testLoopOutSlug, + }, + { + name: "symbols_collapsed", + input: "loop@@@out", + want: testLoopOutSlug, + }, + { + name: "trims_dashes", + input: "--loop-out--", + want: testLoopOutSlug, + }, + { + name: "empty_to_default", + input: "!!!", + want: defaultSessionSlug, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + slug := sanitizeSlug(test.input) + require.Equal(t, test.want, slug) + }) + } +} + +// TestParseSessionCounter verifies session counter parsing. +func TestParseSessionCounter(t *testing.T) { + tests := []struct { + name string + input string + want int + ok bool + }{ + { + name: "with_suffix", + input: "01_loop-out.json", + want: 1, + ok: true, + }, + { + name: "with_extra_underscores", + input: "12_loop_in.json", + want: 12, + ok: true, + }, + { + name: "no_suffix", + input: "99", + want: 99, + ok: true, + }, + { + name: "no_extension", + input: "7_loop-out", + want: 7, + ok: true, + }, + { + name: "empty_name", + input: "", + ok: false, + }, + { + name: "missing_prefix", + input: "_loop.json", + ok: false, + }, + { + name: "non_numeric_prefix", + input: "loop.json", + ok: false, + }, + { + name: "mixed_prefix", + input: "1a_loop.json", + ok: false, + }, + { + name: "negative_prefix", + input: "-1_loop.json", + ok: false, + }, + { + name: "plus_prefix", + input: "+1_loop.json", + ok: false, + }, + } + + for _, test := range tests { + t.Run(test.name, func(t *testing.T) { + value, ok := parseSessionCounter(test.input) + require.Equal(t, test.ok, ok) + if test.ok { + require.Equal(t, test.want, value) + } + }) + } +} + +// TestSessionFinalizeWritesFileWhenHookRestoreFails verifies that session +// data is still written to disk when hook restoration returns an error. +func TestSessionFinalizeWritesFileWhenHookRestoreFails(t *testing.T) { + recorder := &sessionRecorder{ + started: time.Now().Add(-time.Second), + filePath: filepath.Join(t.TempDir(), "session.json"), + metadata: sessionMetadata{ + Args: []string{testLoopArg, testOutArg}, + Env: map[string]string{}, + Version: testVersion, + ClockStartUnix: testClockStart, + }, + hooksStarted: true, + stdoutUnhook: func() error { + return errors.New("stdout restore failed") + }, + } + + err := recorder.Finalize(nil) + require.EqualError(t, err, "stdout restore failed") + + blob, err := os.ReadFile(recorder.filePath) + require.NoError(t, err) + + var data sessionFile + require.NoError(t, json.Unmarshal(blob, &data)) + require.Equal(t, recorder.metadata.Args, data.Metadata.Args) + require.Equal(t, recorder.metadata.Version, data.Metadata.Version) + require.NotNil(t, data.Metadata.Duration) + require.Nil(t, data.Metadata.RunError) + require.Len(t, data.Events, 1) + require.Equal(t, eventExit, data.Events[0].Kind) +} + +// TestSessionFinalizeReportsWriteAndHookErrors verifies that a write failure +// and hook restoration failure are both returned. +func TestSessionFinalizeReportsWriteAndHookErrors(t *testing.T) { + recorder := &sessionRecorder{ + started: time.Now().Add(-time.Second), + filePath: t.TempDir(), + metadata: sessionMetadata{ + Args: []string{testLoopArg, testOutArg}, + Env: map[string]string{}, + Version: testVersion, + ClockStartUnix: testClockStart, + }, + hooksStarted: true, + stdoutUnhook: func() error { + return errors.New("stdout restore failed") + }, + } + + err := recorder.Finalize(nil) + require.ErrorContains(t, err, "is a directory") + require.ErrorContains(t, err, "stdout restore failed") +} + +// TestSessionFinalizeReportsDeferredMarshalError verifies that marshal +// failures encountered while recording are reported from Finalize while the +// session file is still written to disk. +func TestSessionFinalizeReportsDeferredMarshalError(t *testing.T) { + recorder := &sessionRecorder{ + started: time.Now().Add(-time.Second), + filePath: filepath.Join(t.TempDir(), "session.json"), + metadata: sessionMetadata{ + Args: []string{testLoopArg, testOutArg}, + Env: map[string]string{}, + Version: testVersion, + ClockStartUnix: testClockStart, + }, + } + + // This logEvent will fail, because chan int is not JSON marshallable. + recorder.logEvent(eventStdout, make(chan int)) + + err := recorder.Finalize(nil) + require.ErrorContains(t, err, "marshal session stdout event") + require.ErrorContains(t, err, "unsupported type: chan int") + + blob, err := os.ReadFile(recorder.filePath) + require.NoError(t, err) + + var data sessionFile + require.NoError(t, json.Unmarshal(blob, &data)) + require.Equal(t, recorder.metadata.Args, data.Metadata.Args) + require.Equal(t, recorder.metadata.Version, data.Metadata.Version) + require.NotNil(t, data.Metadata.Duration) + require.Nil(t, data.Metadata.RunError) + require.Len(t, data.Events, 1) + require.Equal(t, eventExit, data.Events[0].Kind) +} + +// TestNewSessionRecorderRequiresRepoRoot verifies that recording fails clearly +// when the session fixture directory is not available from the current working +// directory. +func TestNewSessionRecorderRequiresRepoRoot(t *testing.T) { + t.Chdir(t.TempDir()) + t.Setenv(sessionEnvVar, "true") + + recorder, err := newSessionRecorder([]string{testLoopArg, testOutArg}) + require.Nil(t, recorder) + require.EqualError(t, err, "cmd/loop/testdata/sessions does not "+ + "exist; run session recording from the repository root") +} + +// TestNewSessionRecorderCapturesClockStart verifies that new recordings store +// the clock start used for deterministic replay. +func TestNewSessionRecorderCapturesClockStart(t *testing.T) { + repoRoot := t.TempDir() + require.NoError( + t, os.MkdirAll(filepath.Join(repoRoot, sessionDefaultDir), 0o755), + ) + t.Chdir(repoRoot) + t.Setenv(sessionEnvVar, "true") + + before := time.Now().Unix() + recorder, err := newSessionRecorder([]string{testLoopArg, testOutArg}) + after := time.Now().Unix() + require.NoError(t, err) + require.NotNil(t, recorder) + require.GreaterOrEqual(t, recorder.metadata.ClockStartUnix, before) + require.LessOrEqual(t, recorder.metadata.ClockStartUnix, after) + require.Equal( + t, time.Unix(recorder.metadata.ClockStartUnix, 0), + recorder.ClockStart(), + ) +} + +// TestLogGRPCMessageRecordsStatus verifies that gRPC status errors retain +// their structured status details in recorded sessions. +func TestLogGRPCMessageRecordsStatus(t *testing.T) { + recorder := &sessionRecorder{ + started: time.Now(), + } + + statusErr := grpcstatus.Error( + codes.FailedPrecondition, "no rules set", + ) + recorder.logGRPCMessage( + "/looprpc.SwapClient/SuggestSwaps", "error", nil, statusErr, + ) + + require.Len(t, recorder.events, 1) + require.Equal(t, eventGrpc, recorder.events[0].Kind) + + var payload grpcPayload + require.NoError(t, json.Unmarshal(recorder.events[0].Data, &payload)) + require.Equal(t, statusErr.Error(), payload.Error) + require.NotNil(t, payload.Status) + require.Equal(t, int32(codes.FailedPrecondition), payload.Status.Code) + require.Equal(t, "no rules set", payload.Status.Message) +}