Implement Phase 1.4: replaces the hardcoded banner/timeout stub with a proper shell system. Adds a Shell interface with weighted registry for shell selection, a RecordingChannel wrapper (pass-through for now, prep for Phase 2.3 replay), and a bash-like shell with fake filesystem, terminal line reader, and command handling (pwd, ls, cd, cat, whoami, hostname, id, uname, exit). Sessions now log command/output pairs to the store and record the shell name. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
108 lines
2.2 KiB
Go
108 lines
2.2 KiB
Go
package shell
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"testing"
|
|
)
|
|
|
|
// stubShell implements Shell for testing.
|
|
type stubShell struct {
|
|
name string
|
|
}
|
|
|
|
func (s *stubShell) Name() string { return s.name }
|
|
func (s *stubShell) Description() string { return "stub" }
|
|
func (s *stubShell) Handle(_ context.Context, _ *SessionContext, _ io.ReadWriteCloser) error {
|
|
return nil
|
|
}
|
|
|
|
func TestRegistryRegisterAndGet(t *testing.T) {
|
|
r := NewRegistry()
|
|
sh := &stubShell{name: "test"}
|
|
|
|
if err := r.Register(sh, 1); err != nil {
|
|
t.Fatalf("Register: %v", err)
|
|
}
|
|
|
|
got, ok := r.Get("test")
|
|
if !ok {
|
|
t.Fatal("Get returned false")
|
|
}
|
|
if got.Name() != "test" {
|
|
t.Errorf("Name = %q, want %q", got.Name(), "test")
|
|
}
|
|
}
|
|
|
|
func TestRegistryGetMissing(t *testing.T) {
|
|
r := NewRegistry()
|
|
_, ok := r.Get("nope")
|
|
if ok {
|
|
t.Fatal("Get returned true for missing shell")
|
|
}
|
|
}
|
|
|
|
func TestRegistryDuplicateName(t *testing.T) {
|
|
r := NewRegistry()
|
|
r.Register(&stubShell{name: "dup"}, 1)
|
|
err := r.Register(&stubShell{name: "dup"}, 1)
|
|
if err == nil {
|
|
t.Fatal("expected error for duplicate name")
|
|
}
|
|
}
|
|
|
|
func TestRegistryInvalidWeight(t *testing.T) {
|
|
r := NewRegistry()
|
|
err := r.Register(&stubShell{name: "a"}, 0)
|
|
if err == nil {
|
|
t.Fatal("expected error for weight 0")
|
|
}
|
|
err = r.Register(&stubShell{name: "b"}, -1)
|
|
if err == nil {
|
|
t.Fatal("expected error for negative weight")
|
|
}
|
|
}
|
|
|
|
func TestRegistrySelectEmpty(t *testing.T) {
|
|
r := NewRegistry()
|
|
_, err := r.Select()
|
|
if err == nil {
|
|
t.Fatal("expected error from empty registry")
|
|
}
|
|
}
|
|
|
|
func TestRegistrySelectSingle(t *testing.T) {
|
|
r := NewRegistry()
|
|
r.Register(&stubShell{name: "only"}, 1)
|
|
|
|
for range 10 {
|
|
sh, err := r.Select()
|
|
if err != nil {
|
|
t.Fatalf("Select: %v", err)
|
|
}
|
|
if sh.Name() != "only" {
|
|
t.Errorf("Name = %q, want %q", sh.Name(), "only")
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestRegistrySelectWeighted(t *testing.T) {
|
|
r := NewRegistry()
|
|
r.Register(&stubShell{name: "heavy"}, 100)
|
|
r.Register(&stubShell{name: "light"}, 1)
|
|
|
|
counts := map[string]int{}
|
|
for range 1000 {
|
|
sh, err := r.Select()
|
|
if err != nil {
|
|
t.Fatalf("Select: %v", err)
|
|
}
|
|
counts[sh.Name()]++
|
|
}
|
|
|
|
// "heavy" has weight 100 vs "light" weight 1, so heavy should get ~99%.
|
|
if counts["heavy"] < 900 {
|
|
t.Errorf("heavy selected %d/1000 times, expected >900", counts["heavy"])
|
|
}
|
|
}
|