feat: add shell interface, registry, and bash shell emulator
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>
This commit is contained in:
107
internal/shell/registry_test.go
Normal file
107
internal/shell/registry_test.go
Normal file
@@ -0,0 +1,107 @@
|
||||
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"])
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user