c326853929
Co-authored-by: Kandev Agent <agent@kandev.dev>
390 lines
13 KiB
Go
390 lines
13 KiB
Go
package client
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
)
|
|
|
|
func TestStartProcess_MarshalsRequestAndDecodesProcessInfo(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusOK, `{
|
|
"process":{
|
|
"id":"proc-1","session_id":"sess-1","kind":"script","script_name":"dev",
|
|
"command":"pnpm dev","working_dir":"/workspace/task-1","status":"running",
|
|
"exit_code":null,
|
|
"started_at":"2026-08-10T10:00:00Z","updated_at":"2026-08-10T10:00:05Z",
|
|
"output":[
|
|
{"stream":"stdout","data":"listening\n","timestamp":"2026-08-10T10:00:01Z"},
|
|
{"stream":"stderr","data":"warn\n","timestamp":"2026-08-10T10:00:02Z"}
|
|
]
|
|
}
|
|
}`))
|
|
|
|
req := StartProcessRequest{
|
|
SessionID: "sess-1",
|
|
Kind: "script",
|
|
ScriptName: "dev",
|
|
Command: "pnpm dev",
|
|
WorkingDir: "/workspace/task-1",
|
|
Env: map[string]string{"PORT": "3000"},
|
|
BufferMaxBytes: 65536,
|
|
}
|
|
info, err := newHTTPOnlyClient(srv.URL).StartProcess(context.Background(), req)
|
|
if err != nil {
|
|
t.Fatalf("StartProcess: %v", err)
|
|
}
|
|
|
|
if got.Method != http.MethodPost || got.Path != "/api/v1/processes/start" {
|
|
t.Errorf("request = %s %s, want POST /api/v1/processes/start", got.Method, got.Path)
|
|
}
|
|
if got.ContentType != "application/json" {
|
|
t.Errorf("content-type = %q, want application/json", got.ContentType)
|
|
}
|
|
|
|
var sent StartProcessRequest
|
|
if err := json.Unmarshal(got.Body, &sent); err != nil {
|
|
t.Fatalf("decode sent body: %v", err)
|
|
}
|
|
if sent.SessionID != "sess-1" || sent.Kind != "script" || sent.ScriptName != "dev" {
|
|
t.Errorf("sent session/kind/script = %q / %q / %q", sent.SessionID, sent.Kind, sent.ScriptName)
|
|
}
|
|
if sent.Command != "pnpm dev" || sent.WorkingDir != "/workspace/task-1" {
|
|
t.Errorf("sent command/dir = %q / %q", sent.Command, sent.WorkingDir)
|
|
}
|
|
if sent.Env["PORT"] != "3000" || sent.BufferMaxBytes != 65536 {
|
|
t.Errorf("sent env/buffer = %v / %d", sent.Env, sent.BufferMaxBytes)
|
|
}
|
|
|
|
if info.ID != "proc-1" || info.SessionID != "sess-1" {
|
|
t.Errorf("id/session = %q / %q", info.ID, info.SessionID)
|
|
}
|
|
if info.Kind != "script" || info.ScriptName != "dev" {
|
|
t.Errorf("kind/script = %q / %q", info.Kind, info.ScriptName)
|
|
}
|
|
if info.Command != "pnpm dev" || info.WorkingDir != "/workspace/task-1" {
|
|
t.Errorf("command/dir = %q / %q", info.Command, info.WorkingDir)
|
|
}
|
|
if info.Status != "running" {
|
|
t.Errorf("Status = %q, want running", info.Status)
|
|
}
|
|
if info.ExitCode != nil {
|
|
t.Errorf("ExitCode = %v, want nil while running", info.ExitCode)
|
|
}
|
|
if want := time.Date(2026, 8, 10, 10, 0, 0, 0, time.UTC); !info.StartedAt.Equal(want) {
|
|
t.Errorf("StartedAt = %v, want %v", info.StartedAt, want)
|
|
}
|
|
if want := time.Date(2026, 8, 10, 10, 0, 5, 0, time.UTC); !info.UpdatedAt.Equal(want) {
|
|
t.Errorf("UpdatedAt = %v, want %v", info.UpdatedAt, want)
|
|
}
|
|
if len(info.Output) != 2 {
|
|
t.Fatalf("Output = %d chunks, want 2", len(info.Output))
|
|
}
|
|
if info.Output[0].Stream != "stdout" || info.Output[0].Data != "listening\n" {
|
|
t.Errorf("Output[0] = %+v, want the stdout chunk", info.Output[0])
|
|
}
|
|
if want := time.Date(2026, 8, 10, 10, 0, 1, 0, time.UTC); !info.Output[0].Timestamp.Equal(want) {
|
|
t.Errorf("Output[0].Timestamp = %v, want %v", info.Output[0].Timestamp, want)
|
|
}
|
|
if info.Output[1].Stream != "stderr" || info.Output[1].Data != "warn\n" {
|
|
t.Errorf("Output[1] = %+v, want the stderr chunk", info.Output[1])
|
|
}
|
|
}
|
|
|
|
func TestStartProcess_DecodesExitCodeOnFinishedProcess(t *testing.T) {
|
|
srv, _ := captureServer(t, jsonResponder(http.StatusOK,
|
|
`{"process":{"id":"p","status":"exited","exit_code":137}}`))
|
|
|
|
info, err := newHTTPOnlyClient(srv.URL).StartProcess(
|
|
context.Background(), StartProcessRequest{SessionID: "s"})
|
|
if err != nil {
|
|
t.Fatalf("StartProcess: %v", err)
|
|
}
|
|
if info.ExitCode == nil {
|
|
t.Fatal("ExitCode = nil, want a decoded 137")
|
|
}
|
|
if *info.ExitCode != 137 {
|
|
t.Errorf("ExitCode = %d, want 137", *info.ExitCode)
|
|
}
|
|
}
|
|
|
|
func TestStartProcess_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{
|
|
name: "non-2xx status echoes body",
|
|
status: http.StatusBadRequest,
|
|
body: `{"error":"working_dir is required"}`,
|
|
wantErr: "start process failed with status 400",
|
|
},
|
|
{
|
|
name: "malformed body",
|
|
status: http.StatusOK,
|
|
body: `{"process":`,
|
|
wantErr: "failed to parse start process response",
|
|
},
|
|
{
|
|
name: "nil process with error field",
|
|
status: http.StatusOK,
|
|
body: `{"error":"spawn failed"}`,
|
|
wantErr: "start process failed: spawn failed",
|
|
},
|
|
{
|
|
name: "nil process without error field",
|
|
status: http.StatusOK,
|
|
body: `{}`,
|
|
wantErr: "start process failed: no process returned",
|
|
},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv, _ := captureServer(t, jsonResponder(tc.status, tc.body))
|
|
|
|
info, err := newHTTPOnlyClient(srv.URL).StartProcess(
|
|
context.Background(), StartProcessRequest{SessionID: "s"})
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if info != nil {
|
|
t.Errorf("info = %+v, want nil on failure", info)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestStartProcess_EchoesServerBodyInStatusError(t *testing.T) {
|
|
srv, _ := captureServer(t, jsonResponder(http.StatusBadRequest, `{"error":"working_dir is required"}`))
|
|
|
|
_, err := newHTTPOnlyClient(srv.URL).StartProcess(
|
|
context.Background(), StartProcessRequest{SessionID: "s"})
|
|
if err == nil || !strings.Contains(err.Error(), "working_dir is required") {
|
|
t.Fatalf("error = %v, want the server body included for diagnosis", err)
|
|
}
|
|
}
|
|
|
|
func TestStopProcess_PostsProcessIDInBody(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusOK, `{"success":true}`))
|
|
|
|
if err := newHTTPOnlyClient(srv.URL).StopProcess(context.Background(), "proc-7"); err != nil {
|
|
t.Fatalf("StopProcess: %v", err)
|
|
}
|
|
|
|
if got.Method != http.MethodPost || got.Path != "/api/v1/processes/stop" {
|
|
t.Errorf("request = %s %s, want POST /api/v1/processes/stop", got.Method, got.Path)
|
|
}
|
|
if got.ContentType != "application/json" {
|
|
t.Errorf("content-type = %q, want application/json", got.ContentType)
|
|
}
|
|
|
|
var sent map[string]string
|
|
if err := json.Unmarshal(got.Body, &sent); err != nil {
|
|
t.Fatalf("decode sent body: %v", err)
|
|
}
|
|
if sent["process_id"] != "proc-7" {
|
|
t.Errorf("sent process_id = %q, want proc-7", sent["process_id"])
|
|
}
|
|
}
|
|
|
|
func TestStopProcess_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{"non-2xx status", http.StatusNotFound, `no such process`, "stop process failed with status 404"},
|
|
{"malformed body", http.StatusOK, `{"success":`, "failed to parse stop process response"},
|
|
{"unsuccessful", http.StatusOK, `{"success":false,"error":"already exited"}`, "stop process failed: already exited"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv, _ := captureServer(t, jsonResponder(tc.status, tc.body))
|
|
|
|
err := newHTTPOnlyClient(srv.URL).StopProcess(context.Background(), "proc-7")
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestListProcesses_FiltersBySessionIDAndDecodesList(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusOK, `[
|
|
{"id":"p1","session_id":"sess-1","kind":"script","command":"a","status":"running"},
|
|
{"id":"p2","session_id":"sess-1","kind":"user_shell","command":"b","status":"exited","exit_code":0}
|
|
]`))
|
|
|
|
processes, err := newHTTPOnlyClient(srv.URL).ListProcesses(context.Background(), "sess-1")
|
|
if err != nil {
|
|
t.Fatalf("ListProcesses: %v", err)
|
|
}
|
|
|
|
if got.Method != http.MethodGet || got.Path != "/api/v1/processes" {
|
|
t.Errorf("request = %s %s, want GET /api/v1/processes", got.Method, got.Path)
|
|
}
|
|
if sid := got.Query.Get("session_id"); sid != "sess-1" {
|
|
t.Errorf("session_id = %q, want sess-1", sid)
|
|
}
|
|
|
|
if len(processes) != 2 {
|
|
t.Fatalf("processes = %d, want 2", len(processes))
|
|
}
|
|
if processes[0].ID != "p1" || processes[0].Kind != "script" || processes[0].Status != "running" {
|
|
t.Errorf("processes[0] = %+v", processes[0])
|
|
}
|
|
if processes[1].ID != "p2" || processes[1].Kind != "user_shell" || processes[1].Status != "exited" {
|
|
t.Errorf("processes[1] = %+v", processes[1])
|
|
}
|
|
if processes[1].ExitCode == nil || *processes[1].ExitCode != 0 {
|
|
t.Errorf("processes[1].ExitCode = %v, want pointer to 0", processes[1].ExitCode)
|
|
}
|
|
}
|
|
|
|
func TestListProcesses_OmitsQueryWhenSessionIDIsEmpty(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusOK, `[]`))
|
|
|
|
processes, err := newHTTPOnlyClient(srv.URL).ListProcesses(context.Background(), "")
|
|
if err != nil {
|
|
t.Fatalf("ListProcesses: %v", err)
|
|
}
|
|
if got.RawQuery != "" {
|
|
t.Errorf("raw query = %q, want empty when unfiltered", got.RawQuery)
|
|
}
|
|
if len(processes) != 0 {
|
|
t.Errorf("processes = %+v, want empty", processes)
|
|
}
|
|
}
|
|
|
|
func TestListProcesses_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{"non-2xx status", http.StatusInternalServerError, `[]`, "list processes failed with status 500"},
|
|
{"malformed body", http.StatusOK, `[{"id":`, "unexpected EOF"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv, _ := captureServer(t, jsonResponder(tc.status, tc.body))
|
|
|
|
processes, err := newHTTPOnlyClient(srv.URL).ListProcesses(context.Background(), "")
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if processes != nil {
|
|
t.Errorf("processes = %+v, want nil on failure", processes)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetProcess_PutsIDInPathAndRequestsOutputOnDemand(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
includeOutput bool
|
|
wantQuery string
|
|
}{
|
|
{"without output", false, ""},
|
|
{"with output", true, "include_output=true"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv, got := captureServer(t, jsonResponder(http.StatusOK, `{
|
|
"id":"proc-4","session_id":"sess-2","kind":"script","command":"go test",
|
|
"working_dir":"/w","status":"running",
|
|
"output":[{"stream":"stdout","data":"ok\n","timestamp":"2026-08-10T10:00:00Z"}]
|
|
}`))
|
|
|
|
info, err := newHTTPOnlyClient(srv.URL).GetProcess(
|
|
context.Background(), "proc-4", tc.includeOutput)
|
|
if err != nil {
|
|
t.Fatalf("GetProcess: %v", err)
|
|
}
|
|
|
|
if got.Method != http.MethodGet {
|
|
t.Errorf("method = %q, want GET", got.Method)
|
|
}
|
|
if got.Path != "/api/v1/processes/proc-4" {
|
|
t.Errorf("path = %q, want the ID as the last segment", got.Path)
|
|
}
|
|
if got.RawQuery != tc.wantQuery {
|
|
t.Errorf("raw query = %q, want %q", got.RawQuery, tc.wantQuery)
|
|
}
|
|
|
|
if info.ID != "proc-4" || info.SessionID != "sess-2" {
|
|
t.Errorf("id/session = %q / %q", info.ID, info.SessionID)
|
|
}
|
|
if info.Command != "go test" || info.WorkingDir != "/w" || info.Status != "running" {
|
|
t.Errorf("command/dir/status = %q / %q / %q", info.Command, info.WorkingDir, info.Status)
|
|
}
|
|
if len(info.Output) != 1 || info.Output[0].Data != "ok\n" {
|
|
t.Errorf("Output = %+v, want one stdout chunk", info.Output)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestGetProcess_FailureModes(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
status int
|
|
body string
|
|
wantErr string
|
|
}{
|
|
{"non-2xx status", http.StatusNotFound, `{}`, "get process failed with status 404"},
|
|
{"malformed body", http.StatusOK, `{"id":`, "unexpected EOF"},
|
|
}
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
srv, _ := captureServer(t, jsonResponder(tc.status, tc.body))
|
|
|
|
info, err := newHTTPOnlyClient(srv.URL).GetProcess(context.Background(), "p", false)
|
|
if err == nil || !strings.Contains(err.Error(), tc.wantErr) {
|
|
t.Fatalf("error = %v, want %q", err, tc.wantErr)
|
|
}
|
|
if info != nil {
|
|
t.Errorf("info = %+v, want nil on failure", info)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestProcessEndpoints_HonourContextCancellation(t *testing.T) {
|
|
srv, _ := captureServer(t, jsonResponder(http.StatusOK, `{"success":true}`))
|
|
c := newHTTPOnlyClient(srv.URL)
|
|
|
|
ctx, cancel := context.WithCancel(context.Background())
|
|
cancel()
|
|
|
|
calls := map[string]func() error{
|
|
"StartProcess": func() error {
|
|
_, err := c.StartProcess(ctx, StartProcessRequest{SessionID: "s"})
|
|
return err
|
|
},
|
|
"StopProcess": func() error { return c.StopProcess(ctx, "p") },
|
|
"ListProcesses": func() error {
|
|
_, err := c.ListProcesses(ctx, "")
|
|
return err
|
|
},
|
|
"GetProcess": func() error {
|
|
_, err := c.GetProcess(ctx, "p", false)
|
|
return err
|
|
},
|
|
}
|
|
for name, call := range calls {
|
|
t.Run(name, func(t *testing.T) {
|
|
if err := call(); err == nil || !strings.Contains(err.Error(), "context canceled") {
|
|
t.Fatalf("error = %v, want context canceled", err)
|
|
}
|
|
})
|
|
}
|
|
}
|