package ui import ( "bytes" "encoding/json" "net/http" "net/http/httptest" "orchestra/internal/domain" "orchestra/internal/federation" "orchestra/internal/store" "strings" "testing" "time" ) func TestOverviewAndTaskDetailHTTP(t *testing.T) { s, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } h := Server{Store: s}.Handler() req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"one","project":"demo","title":"UI task"}`)) r := httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("create status=%d body=%s", r.Code, r.Body.String()) } req = httptest.NewRequest(http.MethodGet, "/v1/ui/overview", nil) r = httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusOK || !strings.Contains(r.Body.String(), "UI task") { t.Fatalf("overview status=%d body=%s", r.Code, r.Body.String()) } } func TestParsePendingApprovalPreservesExactShellCommand(t *testing.T) { text := "Permission required\n$ git status --short && go test ./..." p := ParsePendingApproval(text, "p1", 7, time.Unix(1, 0)) if p == nil || p.Kind != "shell" || p.Command != "git status --short && go test ./..." { t.Fatalf("got %#v", p) } } func TestParsePendingApprovalDoesNotInventActionablePrompt(t *testing.T) { p := ParsePendingApproval("Approval required: choose an option", "p1", 7, time.Now()) if p == nil || p.Kind != "unknown" || !strings.Contains(p.Summary, "review") { t.Fatalf("got %#v", p) } } // TestFederatedApprovalIsQueuedAtTheWorkerRevisionAndAudited guards B19 and // B20 together: the queued command must carry the worker's own capture // counter (not a coordinator timestamp the worker can never match, which // resolved every such approval "stale"), and queueing must leave an // ApprovalRequested event behind — the grant/deny is appended later, when // the worker reports the keystroke was acknowledged. func TestFederatedApprovalIsQueuedAtTheWorkerRevisionAndAudited(t *testing.T) { s, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } workers := &federation.Registry{} if err := workers.Register(federation.Worker{ID: "wpc-claude", Token: "tok"}, ""); err != nil { t.Fatal(err) } h := Server{Store: s, Workers: workers}.Handler() req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"fed","project":"demo","title":"Federated"}`)) r := httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("create status=%d body=%s", r.Code, r.Body.String()) } id := s.Tasks()[0].ID if _, err := s.Lease(id, "wpc-claude", time.Hour); err != nil { t.Fatal(err) } capture, err := workers.PutCapture("wpc-claude", federation.Capture{TaskID: id, PaneID: "wA:p1", Text: "Permission required\n$ rm -rf build"}) if err != nil { t.Fatal(err) } req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/grant_approval", bytes.NewBufferString(`{}`)) r = httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("grant status=%d body=%s", r.Code, r.Body.String()) } pending, err := workers.Commands("wpc-claude") if err != nil || len(pending) != 1 { t.Fatalf("commands=%#v err=%v", pending, err) } if pending[0].CaptureRevision != capture.Revision || pending[0].PaneID != "wA:p1" { t.Fatalf("queued at revision %d pane %q, worker published %d wA:p1", pending[0].CaptureRevision, pending[0].PaneID, capture.Revision) } var requested *domain.Event for _, e := range s.Events(0) { if e.Type == "ApprovalRequested" && e.TaskID == id { x := e requested = &x } if e.Type == "ApprovalGranted" { t.Fatal("queueing a command must not claim an outcome the worker has not reported") } } if requested == nil { t.Fatal("federated approval left no audit trail") } var p struct { SubjectRef string `json:"subject_ref"` Decision string `json:"decision"` } if err := json.Unmarshal(requested.Payload, &p); err != nil { t.Fatal(err) } if p.SubjectRef != pending[0].ID || p.Decision != "grant_approval" { t.Fatalf("audit event does not identify the command: %s", requested.Payload) } } // TestCoordinatorCaptureRevisionTracksContentNotTime guards B20: the local // revision used to be UnixNano, so it changed on every read and told a // caller nothing about whether the pane had changed. func TestCoordinatorCaptureRevisionTracksContentNotTime(t *testing.T) { a := captureRevision("claude", "Permission required\n$ ls") if a != captureRevision("claude", "Permission required\n$ ls") { t.Fatal("revision changed without the pane text changing") } if a == captureRevision("claude", "Permission required\n$ rm -rf /") { t.Fatal("revision did not change when the pane text changed") } if a == 0 { t.Fatal("revision 0 means \"no revision\" to federation.Queue") } } // TestOperatorBlockReachesALeasedTask guards the fence the browser could never // satisfy: the store rejects TaskBlocked on a leased task unless the payload // carries the live harness_id and lease_epoch, and the UI action handler sent // neither. Every block/release/complete the UI advertised on a leased task // returned 409, which left an operator with no lifecycle action at all on the // tasks that most needed one. func TestOperatorBlockReachesALeasedTask(t *testing.T) { s, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } h := Server{Store: s}.Handler() req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"blk","project":"demo","title":"Leased"}`)) r := httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("create status=%d body=%s", r.Code, r.Body.String()) } id := s.Tasks()[0].ID if _, err := s.Lease(id, "wpc-claude", time.Hour); err != nil { t.Fatal(err) } req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/block", bytes.NewBufferString(`{"blocker":"parked by the operator"}`)) r = httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("block status=%d body=%s", r.Code, r.Body.String()) } task, ok := s.Task(id) if !ok || task.State != domain.StateBlocked { t.Fatalf("state=%q ok=%v, want blocked", task.State, ok) } if task.BlockReason != domain.BlockReasonOperator { t.Fatalf("block_reason=%q, want %q", task.BlockReason, domain.BlockReasonOperator) } } // A missing request body must not panic the action handler. Decode leaves the // map nil, and the block path writes into it. func TestActionWithoutABodyDoesNotPanic(t *testing.T) { s, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } h := Server{Store: s}.Handler() req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"nobody","project":"demo","title":"No body"}`)) r := httptest.NewRecorder() h.ServeHTTP(r, req) id := s.Tasks()[0].ID req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/block", nil) r = httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusConflict || !strings.Contains(r.Body.String(), "blocker required") { t.Fatalf("block status=%d body=%s, want 409 blocker required", r.Code, r.Body.String()) } } // TestResubmitQueuesAKeystrokeWithoutTouchingLifecycle covers the gap F33 left // behind: a pane holding unsent input had no recovery short of destroying the // lease and waiting out expiry. Resubmit presses Enter on text Orchestra // already submitted, so it queues a worker command and appends no event. func TestResubmitQueuesAKeystrokeWithoutTouchingLifecycle(t *testing.T) { s, err := store.Open(t.TempDir()) if err != nil { t.Fatal(err) } workers := &federation.Registry{} if err := workers.Register(federation.Worker{ID: "wpc-claude", Token: "tok"}, ""); err != nil { t.Fatal(err) } h := Server{Store: s, Workers: workers}.Handler() req := httptest.NewRequest(http.MethodPost, "/v1/ui/tasks", bytes.NewBufferString(`{"source":"web","external_id":"stuck","project":"demo","title":"Stuck launch"}`)) r := httptest.NewRecorder() h.ServeHTTP(r, req) id := s.Tasks()[0].ID if _, err := s.Lease(id, "wpc-claude", time.Hour); err != nil { t.Fatal(err) } before := len(s.Events(0)) capture, err := workers.PutCapture("wpc-claude", federation.Capture{TaskID: id, PaneID: "wA:p1", Text: "> read .orchestra/launch.md now"}) if err != nil { t.Fatal(err) } req = httptest.NewRequest(http.MethodPost, "/v1/ui/tasks/"+id+"/actions/resubmit", bytes.NewBufferString(`{}`)) r = httptest.NewRecorder() h.ServeHTTP(r, req) if r.Code != http.StatusOK { t.Fatalf("resubmit status=%d body=%s", r.Code, r.Body.String()) } pending, err := workers.Commands("wpc-claude") if err != nil || len(pending) != 1 { t.Fatalf("commands=%#v err=%v", pending, err) } if pending[0].Kind != "resubmit" || pending[0].PaneID != "wA:p1" || pending[0].CaptureRevision != capture.Revision { t.Fatalf("queued %#v, want a resubmit bound to wA:p1 at revision %d", pending[0], capture.Revision) } if got := len(s.Events(0)); got != before { t.Fatalf("events %d -> %d: a keystroke must not append a lifecycle event", before, got) } task, _ := s.Task(id) if task.State != domain.StateLeased { t.Fatalf("state=%q, want the lease untouched", task.State) } }