feat: improve local terminal workflows

This commit is contained in:
Andy Dixon
2026-07-22 11:57:52 +01:00
parent 4fc3b19441
commit 5d5993d9c4
20 changed files with 967 additions and 160 deletions
+6
View File
@@ -430,6 +430,12 @@ func (s *Service) ConnectLocal() (models.SessionInfo, error) {
return *info, nil
}
// RestartLocalSession starts a new shell in an exited local terminal while
// preserving its tab, scrollback, terminal geometry, and best-effort cwd.
func (s *Service) RestartLocalSession(sessionID string, cols int, rows int) error {
return s.sessions.RestartLocal(sessionID, cols, rows)
}
// DisconnectSession closes a session by ID.
func (s *Service) DisconnectSession(sessionID string) error {
return s.sessions.Disconnect(sessionID)
+144 -38
View File
@@ -3,11 +3,18 @@
package localshell
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/exec"
"runtime"
"strconv"
"strings"
"sync"
"syscall"
"time"
"github.com/andydixon/nexus/internal/terminalenv"
"github.com/creack/pty"
@@ -15,26 +22,28 @@ import (
// Client manages a local shell PTY session.
type Client struct {
mu sync.Mutex
cmd *exec.Cmd
ptmx *os.File
shell string
onData func([]byte)
onError func(error)
onClose func()
closed bool
cols uint16
rows uint16
mu sync.Mutex
cmd *exec.Cmd
ptmx *os.File
shell string
startDir string
onData func([]byte)
onError func(error)
onClose func()
closed bool
cols uint16
rows uint16
}
// Config holds configuration for a local shell session.
type Config struct {
Shell string // Path to the shell binary (e.g. /bin/zsh)
Cols uint16
Rows uint16
OnData func([]byte)
OnError func(error)
OnClose func()
Shell string // Path to the shell binary (e.g. /bin/zsh)
Cols uint16
Rows uint16
StartDir string // Initial working directory; defaults to the user's home directory.
OnData func([]byte)
OnError func(error)
OnClose func()
}
// DefaultShell returns the system default shell.
@@ -67,13 +76,20 @@ func NewClient(cfg Config) *Client {
if rows == 0 {
rows = 24
}
startDir := cfg.StartDir
if startDir == "" {
if home, err := os.UserHomeDir(); err == nil {
startDir = home
}
}
return &Client{
shell: shell,
cols: cols,
rows: rows,
onData: cfg.OnData,
onError: cfg.OnError,
onClose: cfg.OnClose,
shell: shell,
startDir: startDir,
cols: cols,
rows: rows,
onData: cfg.OnData,
onError: cfg.OnError,
onClose: cfg.OnClose,
}
}
@@ -82,17 +98,16 @@ func (c *Client) Start() error {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
return fmt.Errorf("shell already closed")
}
if c.ptmx != nil {
return fmt.Errorf("shell already running")
}
cmd := exec.Command(c.shell)
cmd.Env = terminalenv.LocalShellEnv(os.Environ())
// Start in the user's home directory.
if home, err := os.UserHomeDir(); err == nil {
cmd.Dir = home
}
cmd.Dir = c.startDir
ptmx, err := pty.StartWithSize(cmd, &pty.Winsize{
Cols: c.cols,
@@ -106,7 +121,7 @@ func (c *Client) Start() error {
c.ptmx = ptmx
// Read loop — forward PTY output to the frontend.
go c.readLoop()
go c.readLoop(ptmx)
// Wait for process exit.
go func() {
@@ -114,6 +129,9 @@ func (c *Client) Start() error {
c.mu.Lock()
wasClosed := c.closed
c.closed = true
if c.ptmx == ptmx {
c.ptmx = nil
}
c.mu.Unlock()
if !wasClosed && c.onClose != nil {
@@ -126,6 +144,13 @@ func (c *Client) Start() error {
// Write sends data to the shell's stdin (PTY).
func (c *Client) Write(data []byte) error {
// Capture the shell's current directory before it handles a command or
// EOF. This lets a replacement shell resume in the same directory after
// an ordinary `exit`, without injecting shell-specific prompt hooks.
if bytesContainCommandBoundary(data) {
c.captureWorkingDirectory()
}
c.mu.Lock()
ptmx := c.ptmx
c.mu.Unlock()
@@ -140,7 +165,15 @@ func (c *Client) Write(data []byte) error {
// Resize updates the PTY window size.
func (c *Client) Resize(cols, rows uint16) error {
c.mu.Lock()
if cols > 0 {
c.cols = cols
}
if rows > 0 {
c.rows = rows
}
ptmx := c.ptmx
cols = c.cols
rows = c.rows
c.mu.Unlock()
if ptmx == nil {
@@ -152,26 +185,36 @@ func (c *Client) Resize(cols, rows uint16) error {
// Close terminates the shell process and PTY.
func (c *Client) Close() {
c.mu.Lock()
defer c.mu.Unlock()
if c.closed {
c.mu.Unlock()
return
}
c.closed = true
ptmx := c.ptmx
cmd := c.cmd
c.ptmx = nil
c.mu.Unlock()
if c.ptmx != nil {
c.ptmx.Close()
c.ptmx = nil
if ptmx != nil {
_ = ptmx.Close()
}
if c.cmd != nil && c.cmd.Process != nil {
c.cmd.Process.Kill()
if cmd != nil && cmd.Process != nil {
_ = cmd.Process.Kill()
}
}
func (c *Client) readLoop() {
// WorkingDirectory returns the last directory observed for the shell.
func (c *Client) WorkingDirectory() string {
c.mu.Lock()
defer c.mu.Unlock()
return c.startDir
}
func (c *Client) readLoop(ptmx *os.File) {
defer ptmx.Close()
buf := make([]byte, 32*1024)
for {
n, err := c.ptmx.Read(buf)
n, err := ptmx.Read(buf)
if n > 0 && c.onData != nil {
c.onData(buf[:n])
}
@@ -179,10 +222,73 @@ func (c *Client) readLoop() {
c.mu.Lock()
wasClosed := c.closed
c.mu.Unlock()
if !wasClosed && c.onError != nil {
if !wasClosed && !isNormalPTYClosure(err) && c.onError != nil {
c.onError(err)
}
return
}
}
}
func bytesContainCommandBoundary(data []byte) bool {
for _, b := range data {
if b == '\r' || b == '\n' || b == 0x04 {
return true
}
}
return false
}
func isNormalPTYClosure(err error) bool {
return errors.Is(err, io.EOF) || errors.Is(err, os.ErrClosed) || errors.Is(err, syscall.EIO)
}
func (c *Client) captureWorkingDirectory() {
c.mu.Lock()
cmd := c.cmd
c.mu.Unlock()
if cmd == nil || cmd.Process == nil {
return
}
dir := processWorkingDirectory(cmd.Process.Pid)
if dir == "" || !isDirectory(dir) {
return
}
c.mu.Lock()
if c.cmd == cmd {
c.startDir = dir
}
c.mu.Unlock()
}
func processWorkingDirectory(pid int) string {
switch runtime.GOOS {
case "linux":
dir, err := os.Readlink("/proc/" + strconv.Itoa(pid) + "/cwd")
if err == nil {
return dir
}
case "darwin":
// macOS does not expose /proc. lsof is part of the base system and its
// field output gives us the cwd without parsing a human-readable table.
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
defer cancel()
out, err := exec.CommandContext(ctx, "/usr/sbin/lsof", "-a", "-p", strconv.Itoa(pid), "-d", "cwd", "-Fn").Output()
if err != nil {
return ""
}
for _, line := range strings.Split(string(out), "\n") {
if strings.HasPrefix(line, "n") {
return strings.TrimPrefix(line, "n")
}
}
}
return ""
}
func isDirectory(path string) bool {
info, err := os.Stat(path)
return err == nil && info.IsDir()
}
+146
View File
@@ -0,0 +1,146 @@
package localshell
import (
"bytes"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
)
func TestClientUsesConfiguredDirectoryAndPreStartSize(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("local PTY test requires a Unix shell")
}
dir := t.TempDir()
startDir := filepath.Join(dir, "working")
if err := os.Mkdir(startDir, 0o700); err != nil {
t.Fatal(err)
}
script := filepath.Join(dir, "report-terminal.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\npwd\n"), 0o700); err != nil {
t.Fatal(err)
}
var output bytes.Buffer
var outputMu sync.Mutex
closed := make(chan struct{})
errors := make(chan error, 1)
client := NewClient(Config{
Shell: script,
StartDir: startDir,
Cols: 80,
Rows: 24,
OnData: func(data []byte) {
outputMu.Lock()
_, _ = output.Write(data)
outputMu.Unlock()
},
OnError: func(err error) {
errors <- err
},
OnClose: func() {
close(closed)
},
})
// A frontend fit can arrive before the process starts. The stored size
// must become the initial PTY geometry rather than being discarded.
if err := client.Resize(132, 43); err != nil {
t.Fatalf("Resize() before Start() error = %v", err)
}
if err := client.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
select {
case err := <-errors:
t.Fatalf("unexpected PTY read error: %v", err)
case <-closed:
case <-time.After(5 * time.Second):
client.Close()
t.Fatal("timed out waiting for local shell to exit")
}
outputMu.Lock()
got := strings.ReplaceAll(output.String(), "\r", "")
outputMu.Unlock()
if !strings.Contains(got, "43 132\n") {
t.Fatalf("PTY output %q does not contain configured size 43 132", got)
}
if !strings.Contains(got, startDir+"\n") {
t.Fatalf("PTY output %q does not contain start directory %q", got, startDir)
}
}
func TestBytesContainCommandBoundary(t *testing.T) {
for _, data := range [][]byte{[]byte("pwd\r"), []byte("pwd\n"), {0x04}} {
if !bytesContainCommandBoundary(data) {
t.Fatalf("bytesContainCommandBoundary(%q) = false, want true", data)
}
}
if bytesContainCommandBoundary([]byte("pwd")) {
t.Fatal("bytesContainCommandBoundary without enter/EOF = true, want false")
}
}
func TestClientCapturesWorkingDirectoryBeforeExit(t *testing.T) {
if runtime.GOOS != "darwin" && runtime.GOOS != "linux" {
t.Skip("process cwd lookup is implemented on macOS and Linux")
}
dir := t.TempDir()
startDir := filepath.Join(dir, "start")
nextDir := filepath.Join(startDir, "next")
if err := os.MkdirAll(nextDir, 0o700); err != nil {
t.Fatal(err)
}
expectedDir, err := filepath.EvalSymlinks(nextDir)
if err != nil {
t.Fatal(err)
}
closed := make(chan struct{})
client := NewClient(Config{
Shell: "/bin/sh",
StartDir: startDir,
OnClose: func() {
close(closed)
},
})
if err := client.Start(); err != nil {
t.Fatalf("Start() error = %v", err)
}
defer client.Close()
if err := client.Write([]byte("cd next\r")); err != nil {
t.Fatalf("Write(cd) error = %v", err)
}
deadline := time.Now().Add(5 * time.Second)
for client.WorkingDirectory() != expectedDir && time.Now().Before(deadline) {
time.Sleep(20 * time.Millisecond)
// The empty command boundary snapshots the directory after cd has run.
if err := client.Write([]byte("\r")); err != nil {
t.Fatalf("Write(snapshot) error = %v", err)
}
}
if got := client.WorkingDirectory(); got != expectedDir {
t.Fatalf("WorkingDirectory() = %q, want %q", got, expectedDir)
}
if err := client.Write([]byte("exit\r")); err != nil {
t.Fatalf("Write(exit) error = %v", err)
}
select {
case <-closed:
case <-time.After(5 * time.Second):
t.Fatal("timed out waiting for local shell to exit")
}
if got := client.WorkingDirectory(); got != expectedDir {
t.Fatalf("WorkingDirectory() after exit = %q, want %q", got, expectedDir)
}
}
+1
View File
@@ -54,6 +54,7 @@ func DefaultAppPreferences() AppPreferences {
DefaultCursorStyle: DefaultCursorStyle,
UseNativePaste: false,
LocalShell: "", // empty means use system default
LeviathanEnabled: false,
}
}
+10
View File
@@ -0,0 +1,10 @@
package models
import "testing"
func TestDefaultAppPreferencesHideLeviathan(t *testing.T) {
prefs := DefaultAppPreferences()
if prefs.LeviathanEnabled {
t.Fatal("LeviathanEnabled = true, want false by default")
}
}
+1
View File
@@ -346,6 +346,7 @@ type AppPreferences struct {
DefaultCursorStyle string `json:"defaultCursorStyle"`
UseNativePaste bool `json:"useNativePaste"`
LocalShell string `json:"localShell"` // path to shell binary for local terminal
LeviathanEnabled bool `json:"leviathanEnabled"`
}
// AuditEntry records an auditable action within the application.
+168 -71
View File
@@ -46,6 +46,8 @@ type Session struct {
vncClient *vncclient.Client // VNC proxy client, nil for non-VNC sessions
ircClient *ircclient.Client // IRC client, nil for non-IRC sessions
localClient *localshell.Client // local shell PTY, nil for non-local sessions
localShell string // configured shell path, retained for restart
localCwd string // last known local working directory
sftpClient *sftpclient.Client // SFTP subsystem, nil until requested
profile *models.Profile
recorder *recording.Recorder // active recording, nil if not recording
@@ -214,7 +216,8 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
}
sess := &Session{
Info: info,
Info: info,
localShell: shell,
}
m.mu.Lock()
@@ -226,43 +229,76 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
"state": string(models.SessionConnecting),
})
go func() {
client := localshell.NewClient(localshell.Config{
Shell: shell,
Cols: 80,
Rows: 24,
OnData: func(data []byte) {
m.emitEvent("session:data", map[string]interface{}{
"sessionID": sessionID,
"data": base64.StdEncoding.EncodeToString(data),
})
// Write to active recording if present.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(data)
}
},
OnError: func(err error) {
m.mu.Lock()
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
go m.startLocalSession(sess, 80, 24)
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
},
OnClose: func() {
m.handleSessionClosed(sessionID)
},
})
return sessionID, nil
}
if err := client.Start(); err != nil {
// RestartLocal starts a replacement shell inside an exited local session.
// Keeping the session ID lets the frontend retain the tab and terminal buffer.
func (m *Manager) RestartLocal(sessionID string, cols, rows int) error {
m.mu.Lock()
sess, ok := m.sessions[sessionID]
if !ok {
m.mu.Unlock()
return fmt.Errorf("sessions: session %q not found", sessionID)
}
if sess.Info.Protocol != models.ProtocolLocal {
m.mu.Unlock()
return fmt.Errorf("sessions: session %q is not local", sessionID)
}
if sess.localClient != nil || sess.Info.State == models.SessionConnecting || sess.Info.State == models.SessionConnected {
m.mu.Unlock()
return fmt.Errorf("sessions: local session %q is still running", sessionID)
}
if cols <= 0 {
cols = 80
}
if rows <= 0 {
rows = 24
}
sess.Info.State = models.SessionConnecting
sess.Info.Error = ""
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnecting),
})
go m.startLocalSession(sess, cols, rows)
return nil
}
func (m *Manager) startLocalSession(sess *Session, cols, rows int) {
m.mu.RLock()
sessionID := sess.Info.ID
shell := sess.localShell
startDir := sess.localCwd
m.mu.RUnlock()
var client *localshell.Client
client = localshell.NewClient(localshell.Config{
Shell: shell,
StartDir: startDir,
Cols: terminalDimension(cols, 80),
Rows: terminalDimension(rows, 24),
OnData: func(data []byte) {
m.emitEvent("session:data", map[string]interface{}{
"sessionID": sessionID,
"data": base64.StdEncoding.EncodeToString(data),
})
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteOutput(data)
}
},
OnError: func(err error) {
m.mu.Lock()
if sess.localClient != client {
m.mu.Unlock()
return
}
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
m.mu.Unlock()
@@ -272,29 +308,85 @@ func (m *Manager) ConnectLocal(shell string) (string, error) {
"state": string(models.SessionError),
"error": err.Error(),
})
return
},
OnClose: func() {
m.handleLocalSessionClosed(sessionID, client)
},
})
// Publish the client before Start so a resize arriving during process
// creation is retained by Client.Resize and used for the initial PTY size.
m.mu.Lock()
if sess.Info.State != models.SessionConnecting {
m.mu.Unlock()
return
}
sess.localClient = client
pendingCols, pendingRows := sess.consumePendingResizeLocked()
m.mu.Unlock()
applyLocalResize(client, pendingCols, pendingRows)
if err := client.Start(); err != nil {
m.mu.Lock()
emitError := false
if sess.localClient == client {
sess.localClient = nil
sess.Info.State = models.SessionError
sess.Info.Error = err.Error()
emitError = true
}
m.mu.Lock()
sess.localClient = client
cols, rows := sess.consumePendingResizeLocked()
m.mu.Unlock()
applyLocalResize(client, cols, rows)
m.mu.Lock()
sess.Info.State = models.SessionConnected
sess.Info.ConnectedAt = time.Now()
m.mu.Unlock()
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnected),
})
if emitError {
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionError),
"error": err.Error(),
})
}
return
}
m.auditLog.LogConnect("", sessionID, "local", "localhost")
}()
m.mu.Lock()
if sess.localClient != client || sess.Info.State != models.SessionConnecting {
m.mu.Unlock()
return
}
sess.Info.State = models.SessionConnected
sess.Info.ConnectedAt = time.Now()
m.mu.Unlock()
return sessionID, nil
m.emitEvent("session:state", map[string]interface{}{
"sessionID": sessionID,
"state": string(models.SessionConnected),
})
m.auditLog.LogConnect("", sessionID, "local", "localhost")
}
func (m *Manager) handleLocalSessionClosed(sessionID string, client *localshell.Client) {
m.mu.Lock()
sess, ok := m.sessions[sessionID]
if !ok || sess.localClient != client {
m.mu.Unlock()
return
}
sess.localClient = nil
if cwd := client.WorkingDirectory(); cwd != "" {
sess.localCwd = cwd
}
m.mu.Unlock()
m.handleSessionClosed(sessionID)
}
func terminalDimension(value int, fallback uint16) uint16 {
if value <= 0 {
return fallback
}
if value > int(^uint16(0)) {
return ^uint16(0)
}
return uint16(value)
}
// Disconnect closes a specific session.
@@ -353,29 +445,31 @@ func (m *Manager) Reconnect(sessionID string, getSecret func(prompt string) (str
func (m *Manager) WriteToSession(sessionID string, data []byte) error {
m.mu.RLock()
sess, ok := m.sessions[sessionID]
m.mu.RUnlock()
if !ok {
m.mu.RUnlock()
return fmt.Errorf("sessions: session %q not found", sessionID)
}
rec := sess.recorder
sshClient := sess.sshClient
telnetClient := sess.telnetClient
rawClient := sess.rawClient
localClient := sess.localClient
m.mu.RUnlock()
// Record input if recording is active.
m.mu.RLock()
rec := sess.recorder
m.mu.RUnlock()
if rec != nil {
rec.WriteInput(data)
}
switch {
case sess.sshClient != nil:
return sess.sshClient.Write(data)
case sess.telnetClient != nil:
return sess.telnetClient.Write(data)
case sess.rawClient != nil:
return sess.rawClient.Write(data)
case sess.localClient != nil:
return sess.localClient.Write(data)
case sshClient != nil:
return sshClient.Write(data)
case telnetClient != nil:
return telnetClient.Write(data)
case rawClient != nil:
return rawClient.Write(data)
case localClient != nil:
return localClient.Write(data)
}
return fmt.Errorf("sessions: no active client for session %q", sessionID)
}
@@ -405,7 +499,7 @@ func (m *Manager) ResizeSession(sessionID string, cols, rows int) error {
case telnetClient != nil:
return telnetClient.Resize(cols, rows)
case localClient != nil:
return localClient.Resize(uint16(cols), uint16(rows))
return localClient.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24))
}
// RAW sessions have no concept of terminal size.
return nil
@@ -439,7 +533,7 @@ func applyLocalResize(client *localshell.Client, cols, rows int) {
if cols <= 0 || rows <= 0 {
return
}
_ = client.Resize(uint16(cols), uint16(rows))
_ = client.Resize(terminalDimension(cols, 80), terminalDimension(rows, 24))
}
// GetSessionCwd returns a best-effort working directory for SSH sessions.
@@ -2021,9 +2115,12 @@ func (m *Manager) closeSession(sess *Session) {
sess.ircClient.Close()
sess.ircClient = nil
}
if sess.localClient != nil {
sess.localClient.Close()
sess.localClient = nil
m.mu.Lock()
localClient := sess.localClient
sess.localClient = nil
m.mu.Unlock()
if localClient != nil {
localClient.Close()
}
m.mu.Lock()
+104
View File
@@ -3,9 +3,16 @@ package sessions
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"os"
"path/filepath"
"runtime"
"strings"
"sync"
"testing"
"time"
"github.com/andydixon/nexus/internal/audit"
"github.com/andydixon/nexus/internal/models"
"github.com/andydixon/nexus/internal/storage"
"golang.org/x/crypto/ssh"
@@ -154,6 +161,103 @@ func TestCloseAllEmpty(t *testing.T) {
}
}
func TestTerminalDimension(t *testing.T) {
if got := terminalDimension(0, 80); got != 80 {
t.Fatalf("terminalDimension(0, 80) = %d, want 80", got)
}
if got := terminalDimension(132, 80); got != 132 {
t.Fatalf("terminalDimension(132, 80) = %d, want 132", got)
}
if got := terminalDimension(100000, 80); got != ^uint16(0) {
t.Fatalf("terminalDimension(100000, 80) = %d, want %d", got, ^uint16(0))
}
}
func TestRestartLocalReusesSessionAndAppliesRequestedSize(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("local PTY test requires a Unix shell")
}
dir := t.TempDir()
script := filepath.Join(dir, "local-shell.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\nstty size\n"), 0o700); err != nil {
t.Fatal(err)
}
auditStore := &sessionAuditStore{}
mgr := NewManager(nil, nil, nil, audit.NewLogger(auditStore), nil)
var outputMu sync.Mutex
var output strings.Builder
disconnected := make(chan string, 2)
mgr.SetEventSink(func(name string, payload interface{}) {
values, ok := payload.(map[string]interface{})
if !ok {
return
}
if name == "session:data" {
encoded, _ := values["data"].(string)
decoded, _ := base64.StdEncoding.DecodeString(encoded)
outputMu.Lock()
_, _ = output.Write(decoded)
outputMu.Unlock()
}
if name == "session:state" && values["state"] == string(models.SessionDisconnected) {
disconnected <- values["sessionID"].(string)
}
})
sessionID, err := mgr.ConnectLocal(script)
if err != nil {
t.Fatalf("ConnectLocal() error = %v", err)
}
waitForDisconnectedSession(t, disconnected, sessionID)
if err := mgr.RestartLocal(sessionID, 132, 43); err != nil {
t.Fatalf("RestartLocal() error = %v", err)
}
waitForDisconnectedSession(t, disconnected, sessionID)
info := mgr.GetSessionInfo(sessionID)
if info == nil || info.ID != sessionID || info.State != models.SessionDisconnected {
t.Fatalf("session after restart = %#v, want same disconnected session", info)
}
outputMu.Lock()
got := strings.ReplaceAll(output.String(), "\r", "")
outputMu.Unlock()
if !strings.Contains(got, "43 132\n") {
t.Fatalf("local output %q does not contain restarted size 43 132", got)
}
}
func waitForDisconnectedSession(t *testing.T, states <-chan string, want string) {
t.Helper()
select {
case got := <-states:
if got != want {
t.Fatalf("disconnected session = %q, want %q", got, want)
}
case <-time.After(5 * time.Second):
t.Fatalf("timed out waiting for session %q to disconnect", want)
}
}
type sessionAuditStore struct {
mu sync.Mutex
entries []*models.AuditEntry
}
func (s *sessionAuditStore) AddAuditEntry(entry *models.AuditEntry) error {
s.mu.Lock()
s.entries = append(s.entries, entry)
s.mu.Unlock()
return nil
}
func (s *sessionAuditStore) ListAuditEntries(limit int) ([]*models.AuditEntry, error) {
s.mu.Lock()
defer s.mu.Unlock()
return append([]*models.AuditEntry(nil), s.entries...), nil
}
func TestHostKeyCallbackAcceptNewStoresHost(t *testing.T) {
store := newSessionTestStore(t)
mgr := NewManager(store, nil, nil, nil, nil)
+4
View File
@@ -306,6 +306,7 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
prefs.ConfirmOnClose = true
prefs.DefaultFontSize = 16
prefs.Theme = "custom-dark"
prefs.LeviathanEnabled = true
if err := store.SavePreferences(&prefs); err != nil {
t.Fatalf("SavePreferences() error = %v", err)
@@ -327,6 +328,9 @@ func TestSavePreferencesDoesNotError(t *testing.T) {
if got.Theme != "custom-dark" {
t.Errorf("Theme = %q, want %q", got.Theme, "custom-dark")
}
if !got.LeviathanEnabled {
t.Error("LeviathanEnabled = false, want true")
}
}
// ---------------------------------------------------------------------------