loop/cmd/loop/session_recorder_test.go
2026-05-20 03:12:03 -05:00

343 lines
8.6 KiB
Go

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)
}