Files
antidrift/internal/web/settings_handlers_test.go
T
felixm 7d69a1f320 Generalize the focus controller into a harness hosting swappable modes
Loosen AntiDrift's session controller into Keel's general collect→brain→act
loop. A new internal/harness runs at most one mode.Mode at a time, fanning
async completions out to the web SSE and status-bar surfaces.

- internal/mode: the Mode contract (Kind/Command/View/Active) plus optional
  EvidenceConsumer and Expirer ports, and the surfacing Envelope.
- internal/mode/focus: the former session/domain/statemachine packages moved
  under the mode, now satisfying the harness contracts unchanged.
- internal/mode/offscreen: a one-shot away-from-desk mode built on the new
  ai.Proposer, which turns a life-domain brief into one off-screen action.
- cmd/keeld replaces cmd/antidriftd; daemon wires focus + offscreen factories
  with per-mode persistence under ~/.keel/modes/<kind>.
- Finish the rename: KEEL_* env refs in the README, /keeld build artifact
  ignored, stale antidriftd binary removed.

Include the design + implementation plan this refactor was built from under
docs/superpowers/{specs,plans}/2026-06-04-controller-refactor*.md.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-05 18:00:49 -04:00

192 lines
5.4 KiB
Go

package web
import (
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"keel/internal/settings"
)
// fakeApplier records the last settings it was asked to apply and can be told to
// reject with ErrInvalidBackend.
type fakeApplier struct {
called int
last settings.Settings
reject bool
}
func (f *fakeApplier) apply(s settings.Settings) error {
if f.reject {
return settings.ErrInvalidBackend
}
f.called++
f.last = s
return nil
}
func TestGetSettingsReturnsCurrent(t *testing.T) {
s, _ := newTestServer(t)
cur := settings.Settings{AIBackend: "claude", MarvinCmd: "am", KnowledgePath: "/tmp/k.md"}
fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), cur, fa.apply)
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/settings", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var got settings.Settings
if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil {
t.Fatalf("decode: %v", err)
}
if got != cur {
t.Errorf("got %+v, want %+v", got, cur)
}
}
func TestPostSettingsValidAppliesAndSaves(t *testing.T) {
s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{}
s.SetSettings(path, settings.Settings{}, fa.apply)
r := s.Router()
body := `{"ai_backend":"codex","marvin_cmd":"uv run am","knowledge_path":"/tmp/k.md"}`
w := post(t, r, "/settings", body)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body %s)", w.Code, w.Body.String())
}
if fa.called != 1 {
t.Fatalf("applier called %d times, want 1", fa.called)
}
if fa.last.AIBackend != "codex" {
t.Errorf("applied backend = %q, want codex", fa.last.AIBackend)
}
saved, err := settings.Load(path)
if err != nil {
t.Fatalf("settings file not loadable: %v", err)
}
if saved.AIBackend != "codex" || saved.MarvinCmd != "uv run am" || saved.KnowledgePath != "/tmp/k.md" {
t.Errorf("saved settings = %+v, want codex/uv run am//tmp/k.md", saved)
}
}
func TestPostSettingsInvalidBackendIs400AndNoSave(t *testing.T) {
s, _ := newTestServer(t)
path := filepath.Join(t.TempDir(), "settings.json")
fa := &fakeApplier{reject: true}
s.SetSettings(path, settings.Settings{}, fa.apply)
r := s.Router()
w := post(t, r, "/settings", `{"ai_backend":"bogus"}`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("settings file should not exist after rejected save, stat err = %v", err)
}
if fa.called != 0 {
t.Errorf("applier recorded %d successful applies, want 0", fa.called)
}
}
func TestPostSettingsInvalidJSONIs400(t *testing.T) {
s, _ := newTestServer(t)
fa := &fakeApplier{}
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, fa.apply)
r := s.Router()
w := post(t, r, "/settings", `{not json`)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
if fa.called != 0 {
t.Errorf("applier called on invalid json; want 0, got %d", fa.called)
}
}
func TestBrowseListsDirsAndMarkdown(t *testing.T) {
dir := t.TempDir()
if err := os.Mkdir(filepath.Join(dir, "sub"), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "profile.md"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("x"), 0o644); err != nil {
t.Fatal(err)
}
s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir="+dir, nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200", w.Code)
}
var resp struct {
Dir string `json:"dir"`
Parent string `json:"parent"`
Entries []struct {
Name string `json:"name"`
Path string `json:"path"`
IsDir bool `json:"is_dir"`
} `json:"entries"`
}
if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil {
t.Fatalf("decode: %v", err)
}
if resp.Parent != filepath.Dir(dir) {
t.Errorf("parent = %q, want %q", resp.Parent, filepath.Dir(dir))
}
if resp.Dir != dir {
t.Errorf("dir = %q, want %q", resp.Dir, dir)
}
var names []string
hasSub, hasMd, hasTxt := false, false, false
for _, e := range resp.Entries {
names = append(names, e.Name)
switch e.Name {
case "sub":
hasSub = e.IsDir
if e.Path != filepath.Join(dir, e.Name) {
t.Errorf("sub path = %q, want %q", e.Path, filepath.Join(dir, e.Name))
}
case "profile.md":
hasMd = !e.IsDir
if e.Path != filepath.Join(dir, e.Name) {
t.Errorf("profile.md path = %q, want %q", e.Path, filepath.Join(dir, e.Name))
}
case "notes.txt":
hasTxt = true
}
}
if !hasSub || !hasMd {
t.Errorf("missing dir or .md entry; names = %v", names)
}
if hasTxt {
t.Errorf(".txt should be filtered out; names = %v", names)
}
}
func TestBrowseBadDirIs400(t *testing.T) {
s, _ := newTestServer(t)
s.SetSettings(filepath.Join(t.TempDir(), "settings.json"), settings.Settings{}, func(settings.Settings) error { return nil })
r := s.Router()
req := httptest.NewRequest(http.MethodGet, "/fs/browse?dir=/no/such/dir/xyz", nil)
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400", w.Code)
}
}