mirror of
https://github.com/lightninglabs/loop.git
synced 2026-08-15 12:50:28 +02:00
343 lines
8.6 KiB
Go
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)
|
|
}
|