From f7393b6cdbdc06b745cc9585054f0efbf0bc74b6 Mon Sep 17 00:00:00 2001 From: Steve Yegge Date: Mon, 29 Dec 2025 17:12:18 -0800 Subject: [PATCH] refactor: Remove in-memory swarms map, make Manager stateless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The swarm Manager was maintaining an in-memory map of swarms that was never persisted and duplicated state from beads. This caused stale state after restarts and confusion about source of truth. Changes: - Remove swarms map from Manager (now stateless) - Add LoadSwarm() that queries beads for swarm state - Refactor all methods to use LoadSwarm() instead of in-memory lookup - Discover workers from assigned tasks in beads - Remove obsolete unit tests that tested in-memory behavior - Keep type/state tests that do not need beads The E2E test (gt-kc7yj.4) now covers the beads integration. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- internal/cmd/swarm.go | 9 +- internal/swarm/integration.go | 32 ++-- internal/swarm/integration_test.go | 86 +-------- internal/swarm/landing.go | 6 +- internal/swarm/manager.go | 297 +++++++++++------------------ internal/swarm/manager_test.go | 177 +---------------- 6 files changed, 147 insertions(+), 460 deletions(-) diff --git a/internal/cmd/swarm.go b/internal/cmd/swarm.go index 4e84eab5..57dc82ab 100644 --- a/internal/cmd/swarm.go +++ b/internal/cmd/swarm.go @@ -658,14 +658,9 @@ func runSwarmLand(cmd *cobra.Command, args []string) error { // Use swarm manager for the actual landing (git operations) mgr := swarm.NewManager(foundRig) - sw, err := mgr.Create(swarmID, nil, "main") + sw, err := mgr.LoadSwarm(swarmID) if err != nil { - return fmt.Errorf("loading swarm for landing: %w", err) - } - - // Execute landing to main - if err := mgr.LandToMain(swarmID); err != nil { - return fmt.Errorf("landing swarm: %w", err) + return fmt.Errorf("loading swarm from beads: %w", err) } // Execute full landing protocol diff --git a/internal/swarm/integration.go b/internal/swarm/integration.go index 875a9fd1..262dd0dc 100644 --- a/internal/swarm/integration.go +++ b/internal/swarm/integration.go @@ -19,9 +19,9 @@ var ( // CreateIntegrationBranch creates the integration branch for a swarm. // The branch is created from the swarm's BaseCommit and pushed to origin. func (m *Manager) CreateIntegrationBranch(swarmID string) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound + swarm, err := m.LoadSwarm(swarmID) + if err != nil { + return err } branchName := swarm.Integration @@ -45,9 +45,9 @@ func (m *Manager) CreateIntegrationBranch(swarmID string) error { // MergeToIntegration merges a worker branch into the integration branch. // Returns ErrMergeConflict if the merge has conflicts. func (m *Manager) MergeToIntegration(swarmID, workerBranch string) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound + swarm, err := m.LoadSwarm(swarmID) + if err != nil { + return err } // Ensure we're on the integration branch @@ -87,9 +87,9 @@ func (m *Manager) AbortMerge() error { // LandToMain merges the integration branch to the target branch (usually main). func (m *Manager) LandToMain(swarmID string) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound + swarm, err := m.LoadSwarm(swarmID) + if err != nil { + return err } // Checkout target branch @@ -101,7 +101,7 @@ func (m *Manager) LandToMain(swarmID string) error { _ = m.gitRun("pull", "origin", swarm.TargetBranch) // Merge integration branch - err := m.gitRun("merge", "--no-ff", "-m", + err = m.gitRun("merge", "--no-ff", "-m", fmt.Sprintf("Land swarm %s", swarmID), swarm.Integration) if err != nil { @@ -121,9 +121,9 @@ func (m *Manager) LandToMain(swarmID string) error { // CleanupBranches removes all branches associated with a swarm. func (m *Manager) CleanupBranches(swarmID string) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound + swarm, err := m.LoadSwarm(swarmID) + if err != nil { + return err } var lastErr error @@ -151,9 +151,9 @@ func (m *Manager) CleanupBranches(swarmID string) error { // GetIntegrationBranch returns the integration branch name for a swarm. func (m *Manager) GetIntegrationBranch(swarmID string) (string, error) { - swarm, ok := m.swarms[swarmID] - if !ok { - return "", ErrSwarmNotFound + swarm, err := m.LoadSwarm(swarmID) + if err != nil { + return "", err } return swarm.Integration, nil } diff --git a/internal/swarm/integration_test.go b/internal/swarm/integration_test.go index 571f0d60..ff7d1ffa 100644 --- a/internal/swarm/integration_test.go +++ b/internal/swarm/integration_test.go @@ -6,39 +6,6 @@ import ( "github.com/steveyegge/gastown/internal/rig" ) -func TestGetIntegrationBranch(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") - - branch, err := m.GetIntegrationBranch(swarm.ID) - if err != nil { - t.Fatalf("GetIntegrationBranch failed: %v", err) - } - - expected := "swarm/epic-1" - if branch != expected { - t.Errorf("branch = %q, want %q", branch, expected) - } -} - -func TestGetIntegrationBranchNotFound(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - _, err := m.GetIntegrationBranch("nonexistent") - if err != ErrSwarmNotFound { - t.Errorf("GetIntegrationBranch = %v, want ErrSwarmNotFound", err) - } -} - func TestGetWorkerBranch(t *testing.T) { r := &rig.Rig{ Name: "test-rig", @@ -53,54 +20,5 @@ func TestGetWorkerBranch(t *testing.T) { } } -func TestCreateIntegrationBranchSwarmNotFound(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - err := m.CreateIntegrationBranch("nonexistent") - if err != ErrSwarmNotFound { - t.Errorf("CreateIntegrationBranch = %v, want ErrSwarmNotFound", err) - } -} - -func TestMergeToIntegrationSwarmNotFound(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - err := m.MergeToIntegration("nonexistent", "branch") - if err != ErrSwarmNotFound { - t.Errorf("MergeToIntegration = %v, want ErrSwarmNotFound", err) - } -} - -func TestLandToMainSwarmNotFound(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - err := m.LandToMain("nonexistent") - if err != ErrSwarmNotFound { - t.Errorf("LandToMain = %v, want ErrSwarmNotFound", err) - } -} - -func TestCleanupBranchesSwarmNotFound(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - err := m.CleanupBranches("nonexistent") - if err != ErrSwarmNotFound { - t.Errorf("CleanupBranches = %v, want ErrSwarmNotFound", err) - } -} +// Note: Integration tests that require git operations and beads +// are covered by the E2E test (gt-kc7yj.4). diff --git a/internal/swarm/landing.go b/internal/swarm/landing.go index f403ba34..24e65072 100644 --- a/internal/swarm/landing.go +++ b/internal/swarm/landing.go @@ -48,9 +48,9 @@ type GitAuditResult struct { // ExecuteLanding performs the witness landing protocol for a swarm. func (m *Manager) ExecuteLanding(swarmID string, config LandingConfig) (*LandingResult, error) { - swarm, ok := m.swarms[swarmID] - if !ok { - return nil, ErrSwarmNotFound + swarm, err := m.LoadSwarm(swarmID) + if err != nil { + return nil, err } result := &LandingResult{ diff --git a/internal/swarm/manager.go b/internal/swarm/manager.go index 68836d5e..bba99339 100644 --- a/internal/swarm/manager.go +++ b/internal/swarm/manager.go @@ -7,7 +7,6 @@ import ( "fmt" "os/exec" "strings" - "time" "github.com/steveyegge/gastown/internal/rig" ) @@ -22,9 +21,9 @@ var ( ) // Manager handles swarm lifecycle operations. +// Manager is stateless - all swarm state is discovered from beads. type Manager struct { rig *rig.Rig - swarms map[string]*Swarm workDir string } @@ -32,224 +31,158 @@ type Manager struct { func NewManager(r *rig.Rig) *Manager { return &Manager{ rig: r, - swarms: make(map[string]*Swarm), workDir: r.Path, } } -// Create creates a new swarm from an epic. -func (m *Manager) Create(epicID string, workers []string, targetBranch string) (*Swarm, error) { - if _, exists := m.swarms[epicID]; exists { - return nil, ErrSwarmExists +// LoadSwarm loads swarm state from beads by querying the epic. +// This is the canonical way to get swarm state - no in-memory caching. +func (m *Manager) LoadSwarm(epicID string) (*Swarm, error) { + // Query beads for the epic + cmd := exec.Command("bd", "show", epicID, "--json") + cmd.Dir = m.workDir + + var stdout, stderr bytes.Buffer + cmd.Stdout = &stdout + cmd.Stderr = &stderr + + if err := cmd.Run(); err != nil { + return nil, fmt.Errorf("bd show: %s", strings.TrimSpace(stderr.String())) } - // Get current git commit as base (optional - may not have git) + // Parse the epic + var epic struct { + ID string `json:"id"` + Title string `json:"title"` + Status string `json:"status"` + MolType string `json:"mol_type"` + CreatedAt string `json:"created_at"` + UpdatedAt string `json:"updated_at"` + } + if err := json.Unmarshal(stdout.Bytes(), &epic); err != nil { + return nil, fmt.Errorf("parsing epic: %w", err) + } + + // Verify it's a swarm molecule + if epic.MolType != "swarm" { + return nil, fmt.Errorf("epic %s is not a swarm (mol_type=%s)", epicID, epic.MolType) + } + + // Get current git commit as base baseCommit, _ := m.getGitHead() if baseCommit == "" { baseCommit = "unknown" } - now := time.Now() + // Map status to swarm state + state := SwarmActive + if epic.Status == "closed" { + state = SwarmLanded + } + swarm := &Swarm{ ID: epicID, RigName: m.rig.Name, EpicID: epicID, BaseCommit: baseCommit, Integration: fmt.Sprintf("swarm/%s", epicID), - TargetBranch: targetBranch, - State: SwarmCreated, - CreatedAt: now, - UpdatedAt: now, - Workers: workers, + TargetBranch: "main", + State: state, + Workers: []string{}, // Discovered from active tasks Tasks: []SwarmTask{}, } - // Load tasks from beads + // Load tasks from beads (children of the epic) tasks, err := m.loadTasksFromBeads(epicID) - if err != nil { - // Non-fatal - swarm can start without tasks loaded - } else { + if err == nil { swarm.Tasks = tasks - } - - m.swarms[epicID] = swarm - return swarm, nil -} - -// Start activates a swarm, transitioning from Created to Active. -func (m *Manager) Start(swarmID string) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound - } - - if swarm.State != SwarmCreated { - return fmt.Errorf("%w: cannot start from state %s", ErrInvalidState, swarm.State) - } - - swarm.State = SwarmActive - swarm.UpdatedAt = time.Now() - return nil -} - -// UpdateState transitions the swarm to a new state. -func (m *Manager) UpdateState(swarmID string, state SwarmState) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound - } - - // Validate state transition - if !isValidTransition(swarm.State, state) { - return fmt.Errorf("%w: cannot transition from %s to %s", - ErrInvalidState, swarm.State, state) - } - - swarm.State = state - swarm.UpdatedAt = time.Now() - return nil -} - -// Cancel cancels a swarm with a reason. -func (m *Manager) Cancel(swarmID string, reason string) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound - } - - if swarm.State.IsTerminal() { - return fmt.Errorf("%w: swarm already in terminal state %s", - ErrInvalidState, swarm.State) - } - - swarm.State = SwarmCancelled - swarm.Error = reason - swarm.UpdatedAt = time.Now() - return nil -} - -// GetSwarm returns a swarm by ID. -func (m *Manager) GetSwarm(id string) (*Swarm, error) { - swarm, ok := m.swarms[id] - if !ok { - return nil, ErrSwarmNotFound - } - return swarm, nil -} - -// GetReadyTasks returns tasks ready to be assigned. -func (m *Manager) GetReadyTasks(swarmID string) ([]SwarmTask, error) { - swarm, ok := m.swarms[swarmID] - if !ok { - return nil, ErrSwarmNotFound - } - - var ready []SwarmTask - for _, task := range swarm.Tasks { - if task.State == TaskPending { - ready = append(ready, task) + // Discover workers from assigned tasks + for _, task := range tasks { + if task.Assignee != "" { + swarm.Workers = appendUnique(swarm.Workers, task.Assignee) + } } } - if len(ready) == 0 { + return swarm, nil +} + +// appendUnique appends s to slice if not already present. +func appendUnique(slice []string, s string) []string { + for _, v := range slice { + if v == s { + return slice + } + } + return append(slice, s) +} + +// GetSwarm loads a swarm from beads. Alias for LoadSwarm for compatibility. +func (m *Manager) GetSwarm(id string) (*Swarm, error) { + return m.LoadSwarm(id) +} + +// GetReadyTasks returns tasks ready to be assigned by querying beads. +func (m *Manager) GetReadyTasks(swarmID string) ([]SwarmTask, error) { + // Use bd swarm status to get ready front + cmd := exec.Command("bd", "swarm", "status", swarmID, "--json") + cmd.Dir = m.workDir + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { + return nil, ErrSwarmNotFound + } + + var status struct { + Ready []struct { + ID string `json:"id"` + Title string `json:"title"` + } `json:"ready"` + } + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { + return nil, fmt.Errorf("parsing status: %w", err) + } + + if len(status.Ready) == 0 { return nil, ErrNoReadyTasks } - return ready, nil -} -// GetActiveTasks returns tasks currently in progress. -func (m *Manager) GetActiveTasks(swarmID string) ([]SwarmTask, error) { - swarm, ok := m.swarms[swarmID] - if !ok { - return nil, ErrSwarmNotFound - } - - var active []SwarmTask - for _, task := range swarm.Tasks { - if task.State == TaskInProgress || task.State == TaskAssigned { - active = append(active, task) + tasks := make([]SwarmTask, len(status.Ready)) + for i, r := range status.Ready { + tasks[i] = SwarmTask{ + IssueID: r.ID, + Title: r.Title, + State: TaskPending, } } - return active, nil + return tasks, nil } -// IsComplete checks if all tasks are in terminal states. +// IsComplete checks if all tasks are closed by querying beads. func (m *Manager) IsComplete(swarmID string) (bool, error) { - swarm, ok := m.swarms[swarmID] - if !ok { + cmd := exec.Command("bd", "swarm", "status", swarmID, "--json") + cmd.Dir = m.workDir + + var stdout bytes.Buffer + cmd.Stdout = &stdout + + if err := cmd.Run(); err != nil { return false, ErrSwarmNotFound } - if len(swarm.Tasks) == 0 { - return false, nil + var status struct { + Ready []struct{ ID string } `json:"ready"` + Active []struct{ ID string } `json:"active"` + Blocked []struct{ ID string } `json:"blocked"` + } + if err := json.Unmarshal(stdout.Bytes(), &status); err != nil { + return false, fmt.Errorf("parsing status: %w", err) } - for _, task := range swarm.Tasks { - if !task.State.IsComplete() { - return false, nil - } - } - return true, nil -} - -// AssignTask assigns a task to a worker. -func (m *Manager) AssignTask(swarmID, taskID, worker string) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound - } - - for i, task := range swarm.Tasks { - if task.IssueID == taskID { - swarm.Tasks[i].Assignee = worker - swarm.Tasks[i].State = TaskAssigned - swarm.Tasks[i].Branch = fmt.Sprintf("polecat/%s/%s", worker, taskID) - swarm.UpdatedAt = time.Now() - return nil - } - } - return fmt.Errorf("task %s not found in swarm", taskID) -} - -// UpdateTaskState updates a task's state. -func (m *Manager) UpdateTaskState(swarmID, taskID string, state TaskState) error { - swarm, ok := m.swarms[swarmID] - if !ok { - return ErrSwarmNotFound - } - - for i, task := range swarm.Tasks { - if task.IssueID == taskID { - swarm.Tasks[i].State = state - if state == TaskMerged { - now := time.Now() - swarm.Tasks[i].MergedAt = &now - } - swarm.UpdatedAt = time.Now() - return nil - } - } - return fmt.Errorf("task %s not found in swarm", taskID) -} - -// ListSwarms returns all swarms in the manager. -func (m *Manager) ListSwarms() []*Swarm { - swarms := make([]*Swarm, 0, len(m.swarms)) - for _, s := range m.swarms { - swarms = append(swarms, s) - } - return swarms -} - -// ListActiveSwarms returns non-terminal swarms. -func (m *Manager) ListActiveSwarms() []*Swarm { - var active []*Swarm - for _, s := range m.swarms { - if s.State.IsActive() { - active = append(active, s) - } - } - return active + // Complete if nothing is ready, active, or blocked + return len(status.Ready) == 0 && len(status.Active) == 0 && len(status.Blocked) == 0, nil } // isValidTransition checks if a state transition is allowed. diff --git a/internal/swarm/manager_test.go b/internal/swarm/manager_test.go index 7111953e..08a1060f 100644 --- a/internal/swarm/manager_test.go +++ b/internal/swarm/manager_test.go @@ -6,182 +6,23 @@ import ( "github.com/steveyegge/gastown/internal/rig" ) -func TestManagerCreate(t *testing.T) { +func TestNewManager(t *testing.T) { r := &rig.Rig{ Name: "test-rig", Path: "/tmp/test-rig", } m := NewManager(r) - swarm, err := m.Create("epic-1", []string{"Toast", "Nux"}, "main") - if err != nil { - t.Fatalf("Create failed: %v", err) + if m == nil { + t.Fatal("NewManager returned nil") } - - if swarm.ID != "epic-1" { - t.Errorf("ID = %q, want %q", swarm.ID, "epic-1") + if m.rig != r { + t.Error("Manager rig not set correctly") } - if swarm.State != SwarmCreated { - t.Errorf("State = %q, want %q", swarm.State, SwarmCreated) - } - if len(swarm.Workers) != 2 { - t.Errorf("Workers = %d, want 2", len(swarm.Workers)) + if m.workDir != r.Path { + t.Errorf("workDir = %q, want %q", m.workDir, r.Path) } } -func TestManagerCreateDuplicate(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - _, err := m.Create("epic-1", []string{"Toast"}, "main") - if err != nil { - t.Fatalf("First Create failed: %v", err) - } - - _, err = m.Create("epic-1", []string{"Nux"}, "main") - if err != ErrSwarmExists { - t.Errorf("Create duplicate = %v, want ErrSwarmExists", err) - } -} - -func TestManagerStateTransitions(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") - - // Start - if err := m.Start(swarm.ID); err != nil { - t.Errorf("Start failed: %v", err) - } - s, _ := m.GetSwarm(swarm.ID) - if s.State != SwarmActive { - t.Errorf("State after Start = %q, want %q", s.State, SwarmActive) - } - - // Can't start again - if err := m.Start(swarm.ID); err == nil { - t.Error("Start from Active should fail") - } - - // Transition to Merging - if err := m.UpdateState(swarm.ID, SwarmMerging); err != nil { - t.Errorf("UpdateState to Merging failed: %v", err) - } - - // Transition to Landed - if err := m.UpdateState(swarm.ID, SwarmLanded); err != nil { - t.Errorf("UpdateState to Landed failed: %v", err) - } - - // Can't transition from terminal - if err := m.UpdateState(swarm.ID, SwarmActive); err == nil { - t.Error("UpdateState from Landed should fail") - } -} - -func TestManagerCancel(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") - _ = m.Start(swarm.ID) - - if err := m.Cancel(swarm.ID, "user requested"); err != nil { - t.Errorf("Cancel failed: %v", err) - } - - s, _ := m.GetSwarm(swarm.ID) - if s.State != SwarmCancelled { - t.Errorf("State after Cancel = %q, want %q", s.State, SwarmCancelled) - } - if s.Error != "user requested" { - t.Errorf("Error = %q, want %q", s.Error, "user requested") - } -} - -func TestManagerTaskOperations(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") - - // Manually add tasks (normally loaded from beads) - swarm.Tasks = []SwarmTask{ - {IssueID: "task-1", Title: "Task 1", State: TaskPending}, - {IssueID: "task-2", Title: "Task 2", State: TaskPending}, - } - - // Get ready tasks - ready, err := m.GetReadyTasks(swarm.ID) - if err != nil { - t.Errorf("GetReadyTasks failed: %v", err) - } - if len(ready) != 2 { - t.Errorf("GetReadyTasks = %d, want 2", len(ready)) - } - - // Assign task - if err := m.AssignTask(swarm.ID, "task-1", "Toast"); err != nil { - t.Errorf("AssignTask failed: %v", err) - } - - // Check assignment - s, _ := m.GetSwarm(swarm.ID) - if s.Tasks[0].Assignee != "Toast" { - t.Errorf("Assignee = %q, want %q", s.Tasks[0].Assignee, "Toast") - } - if s.Tasks[0].State != TaskAssigned { - t.Errorf("State = %q, want %q", s.Tasks[0].State, TaskAssigned) - } - - // Update state - if err := m.UpdateTaskState(swarm.ID, "task-1", TaskMerged); err != nil { - t.Errorf("UpdateTaskState failed: %v", err) - } - s, _ = m.GetSwarm(swarm.ID) - if s.Tasks[0].State != TaskMerged { - t.Errorf("State = %q, want %q", s.Tasks[0].State, TaskMerged) - } - if s.Tasks[0].MergedAt == nil { - t.Error("MergedAt should be set") - } -} - -func TestManagerIsComplete(t *testing.T) { - r := &rig.Rig{ - Name: "test-rig", - Path: "/tmp/test-rig", - } - m := NewManager(r) - - swarm, _ := m.Create("epic-1", []string{"Toast"}, "main") - swarm.Tasks = []SwarmTask{ - {IssueID: "task-1", State: TaskPending}, - {IssueID: "task-2", State: TaskMerged}, - } - - complete, _ := m.IsComplete(swarm.ID) - if complete { - t.Error("IsComplete should be false with pending task") - } - - // Complete the pending task - _ = m.UpdateTaskState(swarm.ID, "task-1", TaskMerged) - complete, _ = m.IsComplete(swarm.ID) - if !complete { - t.Error("IsComplete should be true when all tasks merged") - } -} +// Note: Most swarm tests require integration with beads. +// See gt-kc7yj.4 for the E2E integration test.