You've already forked agentic-coding-workflow
665d029d2b
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
279 lines
9.4 KiB
Go
279 lines
9.4 KiB
Go
//go:build integration
|
|
|
|
// handler_e2e_test.go drives the ACP handler through one full happy-path
|
|
// session lifecycle:
|
|
//
|
|
// 1. POST /api/v1/acp/sessions → 201
|
|
// 2. wait for supervisor handshake → SessionRunning
|
|
// 3. WS /api/v1/acp/sessions/{id}/ws → expect "state" event
|
|
// 4. DELETE /api/v1/acp/sessions/{id} → 204
|
|
// 5. wait for monitor → SessionExited
|
|
//
|
|
// Build-tagged integration: spins up testcontainer PG + go build fake-acp-agent,
|
|
// so it never runs under -short.
|
|
package acp_test
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"net/http"
|
|
"net/http/httptest"
|
|
"strings"
|
|
"testing"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/google/uuid"
|
|
"github.com/stretchr/testify/assert"
|
|
"github.com/stretchr/testify/require"
|
|
|
|
ws "github.com/coder/websocket"
|
|
"github.com/coder/websocket/wsjson"
|
|
|
|
"github.com/yan1h/agent-coding-workflow/internal/acp"
|
|
"github.com/yan1h/agent-coding-workflow/internal/audit"
|
|
"github.com/yan1h/agent-coding-workflow/internal/infra/notify"
|
|
"github.com/yan1h/agent-coding-workflow/internal/project"
|
|
"github.com/yan1h/agent-coding-workflow/internal/workspace"
|
|
)
|
|
|
|
// stubWsService satisfies workspace.WorkspaceService for the e2e test.
|
|
// Only Get is exercised by SessionService.Create; other methods panic.
|
|
type stubWsService struct {
|
|
wsRow *workspace.Workspace
|
|
}
|
|
|
|
func (s *stubWsService) Get(_ context.Context, _ workspace.Caller, _ uuid.UUID) (*workspace.Workspace, error) {
|
|
return s.wsRow, nil
|
|
}
|
|
func (s *stubWsService) Create(context.Context, workspace.Caller, string, workspace.CreateWorkspaceInput) (*workspace.Workspace, error) {
|
|
panic("stubWsService.Create unused")
|
|
}
|
|
func (s *stubWsService) List(context.Context, workspace.Caller, string) ([]*workspace.Workspace, error) {
|
|
panic("stubWsService.List unused")
|
|
}
|
|
func (s *stubWsService) Update(context.Context, workspace.Caller, uuid.UUID, workspace.UpdateWorkspaceInput) (*workspace.Workspace, error) {
|
|
panic("stubWsService.Update unused")
|
|
}
|
|
func (s *stubWsService) Delete(context.Context, workspace.Caller, uuid.UUID) error {
|
|
panic("stubWsService.Delete unused")
|
|
}
|
|
func (s *stubWsService) Sync(context.Context, workspace.Caller, uuid.UUID) (*workspace.Workspace, error) {
|
|
panic("stubWsService.Sync unused")
|
|
}
|
|
func (s *stubWsService) UpsertCredential(context.Context, workspace.Caller, uuid.UUID, workspace.UpsertCredentialInput) (*workspace.Credential, error) {
|
|
panic("stubWsService.UpsertCredential unused")
|
|
}
|
|
func (s *stubWsService) GetCredentialMeta(context.Context, workspace.Caller, uuid.UUID) (*workspace.Credential, error) {
|
|
panic("stubWsService.GetCredentialMeta unused")
|
|
}
|
|
func (s *stubWsService) ListBranches(context.Context, workspace.Caller, uuid.UUID) ([]string, error) {
|
|
panic("stubWsService.ListBranches unused")
|
|
}
|
|
|
|
// stubProjectAccess satisfies acp.ProjectAccess for the e2e test.
|
|
// Only GetProjectByWorkspace is exercised; the rest panic.
|
|
type stubProjectAccess struct {
|
|
pj *project.Project
|
|
}
|
|
|
|
func (s *stubProjectAccess) GetProjectByWorkspace(_ context.Context, _ uuid.UUID) (*project.Project, error) {
|
|
return s.pj, nil
|
|
}
|
|
func (s *stubProjectAccess) GetIssueByNumber(context.Context, uuid.UUID, int) (*project.Issue, error) {
|
|
panic("stubProjectAccess.GetIssueByNumber unused")
|
|
}
|
|
func (s *stubProjectAccess) GetRequirementByNumber(context.Context, uuid.UUID, int) (*project.Requirement, error) {
|
|
panic("stubProjectAccess.GetRequirementByNumber unused")
|
|
}
|
|
|
|
// TestHandler_Session_CreateAndWS_E2E covers the happy path from POST /sessions
|
|
// through WS state event to DELETE — the full request → subprocess → relay →
|
|
// shutdown loop, exercising Handler + SessionService + Supervisor end-to-end.
|
|
func TestHandler_Session_CreateAndWS_E2E(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("e2e: spawns subprocess + testcontainer pg")
|
|
}
|
|
t.Parallel()
|
|
ctx := context.Background()
|
|
|
|
repo, pool := setupRepo(t)
|
|
|
|
uid := mustInsertUser(t, ctx, pool, "wsuser-e2e@local", false)
|
|
pid, wsid := mustInsertProjectWorkspace(t, ctx, pool, uid)
|
|
|
|
// mustInsertProjectWorkspace hardcodes main_path='/tmp'. Override to a real
|
|
// per-test directory so subprocess Spawn can chdir into it.
|
|
cwd := t.TempDir()
|
|
_, err := pool.Exec(ctx,
|
|
`UPDATE workspaces SET main_path = $1 WHERE id = $2`, cwd, wsid)
|
|
require.NoError(t, err)
|
|
|
|
// migrations/0003 sets default_branch DEFAULT 'main', so workspaces row
|
|
// inserted by the helper already has DefaultBranch='main' — no UPDATE needed.
|
|
|
|
bin := fakeAgentBinary(t)
|
|
enc := testEncryptor(t)
|
|
|
|
kind, err := repo.CreateAgentKind(ctx, &acp.AgentKind{
|
|
ID: uuid.New(), Name: "ws-e2e-fake", DisplayName: "Fake",
|
|
BinaryPath: bin, Args: []string{}, Enabled: true, CreatedBy: uid,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
// Real workspace row → seeded into the wsService stub so SessionService.Get
|
|
// returns the same ProjectID + DefaultBranch the DB has.
|
|
wsRow, err := workspace.NewPostgresRepository(pool).GetWorkspaceByID(ctx, wsid)
|
|
require.NoError(t, err)
|
|
|
|
disp := notify.NewDispatcher(notifyRepoStub{}, slogDiscard())
|
|
sup := acp.NewSupervisor(
|
|
repo,
|
|
audit.NewPostgresRecorder(pool),
|
|
disp,
|
|
nil, nil, // wtSvc/wsRepo: IsMainWorktree=true path → not called
|
|
nil, // mcpTokens
|
|
enc,
|
|
acp.SupervisorConfig{
|
|
SpawnTimeout: 5 * time.Second,
|
|
KillGrace: 1 * time.Second,
|
|
StderrBufferLines: 50,
|
|
StderrTailBytes: 1000,
|
|
EventMaxPayload: 65536,
|
|
EventTruncateField: 4096,
|
|
ShutdownGrace: 5 * time.Second,
|
|
},
|
|
slogDiscard(),
|
|
)
|
|
defer sup.ShutdownAll(context.Background())
|
|
|
|
wsStub := &stubWsService{wsRow: wsRow}
|
|
paStub := &stubProjectAccess{pj: &project.Project{ID: pid, Slug: "p-test"}}
|
|
|
|
svc := acp.NewSessionService(
|
|
repo, wsStub, nil, nil, paStub, sup,
|
|
audit.NewPostgresRecorder(pool),
|
|
acp.SessionServiceConfig{MaxActiveGlobal: 10, MaxActivePerUser: 5},
|
|
nil, // mcpTokens — not exercised by handler e2e
|
|
)
|
|
|
|
permSvc := acp.NewPermissionService(repo, nil, 0, nil)
|
|
sup.SetPermissionService(permSvc)
|
|
permSvc.SetRelayLocator(sup)
|
|
h := acp.NewHandler(
|
|
nil, // ak svc — not exercised by sessions endpoints
|
|
svc, sup, repo, permSvc,
|
|
fakeResolver{uid: uid},
|
|
fakeAdminLookup{admin: map[uuid.UUID]bool{uid: false}},
|
|
enc,
|
|
acp.WSConfig{
|
|
PingInterval: 30 * time.Second,
|
|
PongTimeout: 10 * time.Second,
|
|
SendBuffer: 64,
|
|
},
|
|
)
|
|
|
|
r := chi.NewRouter()
|
|
h.Mount(r)
|
|
srv := httptest.NewServer(r)
|
|
defer srv.Close()
|
|
|
|
// 1. POST /api/v1/acp/sessions
|
|
branch := "main"
|
|
body, err := json.Marshal(map[string]any{
|
|
"workspace_id": wsid.String(),
|
|
"agent_kind_id": kind.ID.String(),
|
|
"branch": branch,
|
|
})
|
|
require.NoError(t, err)
|
|
|
|
req, err := http.NewRequest("POST", srv.URL+"/api/v1/acp/sessions", bytes.NewReader(body))
|
|
require.NoError(t, err)
|
|
req.Header.Set("Content-Type", "application/json")
|
|
req.Header.Set("Authorization", "Bearer valid")
|
|
|
|
resp, err := http.DefaultClient.Do(req)
|
|
require.NoError(t, err)
|
|
defer resp.Body.Close()
|
|
|
|
var created map[string]any
|
|
require.NoError(t, json.NewDecoder(resp.Body).Decode(&created))
|
|
require.Equal(t, http.StatusCreated, resp.StatusCode, "body=%v", created)
|
|
|
|
sidStr, _ := created["id"].(string)
|
|
require.NotEmpty(t, sidStr)
|
|
sid := uuid.MustParse(sidStr)
|
|
|
|
// 2. Wait for handshake → SessionRunning (supervisor.Spawn runs in a goroutine
|
|
// inside SessionService.Create, so the row starts as 'starting'). 10s budget
|
|
// covers fake-agent stdin/stdout handshake on slow CI.
|
|
deadline := time.After(10 * time.Second)
|
|
for {
|
|
s, _ := repo.GetSessionByID(ctx, sid)
|
|
if s != nil && s.Status == acp.SessionRunning {
|
|
break
|
|
}
|
|
select {
|
|
case <-deadline:
|
|
t.Fatalf("session never reached SessionRunning")
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
|
|
// 3. WS connect — Authorization header is honored by the Auth middleware.
|
|
wsURL := strings.Replace(srv.URL, "http", "ws", 1) +
|
|
"/api/v1/acp/sessions/" + sid.String() + "/ws"
|
|
wsCtx, wsCancel := context.WithTimeout(ctx, 5*time.Second)
|
|
defer wsCancel()
|
|
|
|
wsConn, _, err := ws.Dial(wsCtx, wsURL, &ws.DialOptions{
|
|
HTTPHeader: http.Header{"Authorization": []string{"Bearer valid"}},
|
|
})
|
|
require.NoError(t, err)
|
|
defer wsConn.Close(ws.StatusInternalError, "")
|
|
|
|
var stateEv map[string]any
|
|
require.NoError(t, wsjson.Read(wsCtx, wsConn, &stateEv))
|
|
assert.Equal(t, "state", stateEv["kind"])
|
|
assert.Equal(t, sid.String(), stateEv["session_id"])
|
|
assert.Equal(t, "running", stateEv["status"])
|
|
assert.Equal(t, "main", stateEv["branch"])
|
|
|
|
// Close WS before DELETE so the subscriber drains and Kill doesn't race
|
|
// the WS goroutine.
|
|
_ = wsConn.Close(ws.StatusNormalClosure, "test done")
|
|
|
|
// 4. DELETE /api/v1/acp/sessions/{id}
|
|
delReq, err := http.NewRequest("DELETE",
|
|
srv.URL+"/api/v1/acp/sessions/"+sid.String(), nil)
|
|
require.NoError(t, err)
|
|
delReq.Header.Set("Authorization", "Bearer valid")
|
|
|
|
delResp, err := http.DefaultClient.Do(delReq)
|
|
require.NoError(t, err)
|
|
defer delResp.Body.Close()
|
|
assert.Equal(t, http.StatusNoContent, delResp.StatusCode)
|
|
|
|
// 5. Wait for terminal status. Kill blocks on monitor.done internally,
|
|
// so by the time DELETE returns the row should already be terminal —
|
|
// poll briefly to guard against scheduler latency.
|
|
deadline2 := time.After(5 * time.Second)
|
|
for {
|
|
s, _ := repo.GetSessionByID(ctx, sid)
|
|
if s != nil && (s.Status == acp.SessionExited || s.Status == acp.SessionCrashed) {
|
|
return
|
|
}
|
|
select {
|
|
case <-deadline2:
|
|
s, _ := repo.GetSessionByID(ctx, sid)
|
|
lastStatus := acp.SessionStatus("?")
|
|
if s != nil {
|
|
lastStatus = s.Status
|
|
}
|
|
t.Fatalf("session not terminated, last status=%s", lastStatus)
|
|
case <-time.After(100 * time.Millisecond):
|
|
}
|
|
}
|
|
}
|