diff --git a/internal/ui/commands/deploy.go b/internal/ui/commands/deploy.go index dcd4a9c..ff2d561 100644 --- a/internal/ui/commands/deploy.go +++ b/internal/ui/commands/deploy.go @@ -295,7 +295,7 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.state = StateBuildingApp if m.conf.SimpleOutput() { - fmt.Printf("✓ Created app (Build ID: %s)\n", msg.response.BuildID) + fmt.Printf("✓ Build pending (ID: %s)\n", msg.response.BuildID) } // Handle detach mode @@ -306,7 +306,7 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { fmt.Println(" Check the dashboard for build status.") } else { return m, tea.Sequence( - tea.Println(ui.SuccessStyle.Render(fmt.Sprintf("✓ Created app (Build ID: %s)", msg.response.BuildID))), + tea.Println(ui.SuccessStyle.Render(fmt.Sprintf("✓ Build pending (ID: %s)", msg.response.BuildID))), tea.Println(ui.SuccessStyle.Render("✓ Build started in detached mode")), tea.Println(fmt.Sprintf(" Build ID: %s", m.buildID)), tea.Println(" Check the dashboard for build status."), @@ -319,38 +319,14 @@ func (m *DeployView) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.conf.SimpleOutput() { fmt.Println("Building app...") + return m, m.pollBuildStatus } - // Initialize log viewer with streaming provider - provider := logging.NewStreamingBuildLogProvider(logging.StreamingBuildLogProviderConfig{ - Client: m.conf.WSClient, - ProjectID: m.conf.ProjectID, - BuildID: m.buildID, - }) - tickInterval := 50 * time.Millisecond - - m.logViewer = logging.NewLogViewer(m.ctx, logging.LogViewerConfig{ - DisplayConfig: m.conf.DisplayConfig, - Provider: provider, - TickInterval: tickInterval, - ShowHelp: true, - AutoExpand: true, - }) - - if !m.conf.SimpleOutput() { - printCmds := tea.Sequence( - tea.Println(ui.SuccessStyle.Render(fmt.Sprintf("✓ Created app (Build ID: %s)", msg.response.BuildID))), - tea.Println(""), - ) - return m, tea.Batch( - printCmds, - m.logViewer.Init(), - m.pollBuildStatus, - ) - } - + // Partner services don't emit build logs - skip the log viewer + // and just poll status until the build completes. return m, tea.Batch( - m.logViewer.Init(), + tea.Println(ui.SuccessStyle.Render(fmt.Sprintf("✓ Build pending (ID: %s)", msg.response.BuildID))), + tea.Println(""), m.pollBuildStatus, ) } diff --git a/internal/ui/commands/deploy_test.go b/internal/ui/commands/deploy_test.go index ab373a3..96258f0 100644 --- a/internal/ui/commands/deploy_test.go +++ b/internal/ui/commands/deploy_test.go @@ -14,10 +14,31 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" ) //go:generate go test -v -run TestDeployView -update +func partnerConfig(name string) *projectconfig.ProjectConfig { + return &projectconfig.ProjectConfig{ + Deployment: projectconfig.DeploymentConfig{ + Name: name, + }, + PartnerService: &projectconfig.PartnerServiceConfig{ + Name: "somepartner", + }, + } +} + +func partnerAppResponse(buildID string) *api.CreateAppResponse { + return &api.CreateAppResponse{ + BuildID: buildID, + Status: "pending", + InternalEndpoint: "https://partner-app.internal", + DashboardURL: "https://dashboard.cerebrium.ai/app/partner-app", + } +} + func TestDeployView(t *testing.T) { t.Run("confirmation state when not disabled", func(t *testing.T) { mockClient := apimock.NewMockClient(t) @@ -364,21 +385,126 @@ func TestDeployView(t *testing.T) { harness := uitesting.NewTestHarness(t, model) harness. - // appCreatedMsg triggers uploadZip command, so use Finally to stop before it executes - Finally(uitesting.TestStep[*DeployView]{ - Name: "app_created", - Msg: appCreatedMsg{response: response}, - ViewGolden: "deploy_app_created", + Step(uitesting.TestStep[*DeployView]{ + Name: "app_created", + Msg: appCreatedMsg{response: response}, + ViewAssert: func(t *testing.T, view string) { + uitesting.AssertContains(t, view, "Uploading to Cerebrium") + }, ModelAssert: func(t *testing.T, m *DeployView) { assert.Equal(t, StateUploadingZip, m.state) assert.Equal(t, "build-abc123", m.buildID) assert.Equal(t, "building", m.buildStatus) assert.NotNil(t, m.appResponse) + assert.Nil(t, m.logViewer, "standard deploys build the log viewer on upload, not on create") + }, + }). + Run(t) + }) + + t.Run("partner app created - no log viewer", func(t *testing.T) { + mockClient := apimock.NewMockClient(t) + // A strict websocket mock: any attempt to stream build logs fails the test. + mockWsClient := wsmock.NewMockClient(t) + + model := NewDeployView(context.Background(), DeployConfig{ + DisplayConfig: ui.DisplayConfig{ + IsInteractive: true, + DisableAnimation: false, + }, + Config: partnerConfig("partner-app"), + ProjectID: "test-project", + Client: mockClient, + WSClient: mockWsClient, + }) + + model.state = StateCreatingApp + + harness := uitesting.NewTestHarness(t, model) + harness. + Step(uitesting.TestStep[*DeployView]{ + Name: "partner_app_created", + Msg: appCreatedMsg{response: partnerAppResponse("build-partner")}, + ViewAssert: func(t *testing.T, view string) { + uitesting.AssertContains(t, view, "Building app") + uitesting.AssertNotContains(t, view, "Waiting for logs") + }, + ModelAssert: func(t *testing.T, m *DeployView) { + assert.Equal(t, StateBuildingApp, m.state) + assert.Equal(t, "build-partner", m.buildID) + assert.Nil(t, m.logViewer, "partner services emit no build logs, so no viewer should be created") }, }). Run(t) }) + t.Run("partner app created - simple mode starts polling", func(t *testing.T) { + mockClient := apimock.NewMockClient(t) + mockClient.EXPECT(). + GetBuild(mock.Anything, "test-project", "test-project-partner-app", "build-partner"). + Return(&api.AppBuild{Id: "build-partner", Status: "building"}, nil). + Once() + + model := NewDeployView(context.Background(), DeployConfig{ + DisplayConfig: ui.DisplayConfig{ + IsInteractive: false, + DisableAnimation: true, + }, + Config: partnerConfig("partner-app"), + ProjectID: "test-project", + Client: mockClient, + WSClient: wsmock.NewMockClient(t), + }) + + model.state = StateCreatingApp + + var cmd tea.Cmd + stdout := captureStdout(t, func() { + _, cmd = model.Update(appCreatedMsg{response: partnerAppResponse("build-partner")}) + }) + + assert.Contains(t, stdout, "✓ Build pending (ID: build-partner)") + assert.Contains(t, stdout, "Building app...") + assert.Equal(t, StateBuildingApp, model.state) + assert.Nil(t, model.logViewer) + + // Without a log viewer, polling is the only thing driving the build to a + // terminal state - a nil command here would hang the deploy forever. + require.NotNil(t, cmd, "simple mode must return the build status poll") + assert.IsType(t, buildStatusUpdateMsg{}, cmd()) + }) + + t.Run("partner app created - detach does not poll", func(t *testing.T) { + mockClient := apimock.NewMockClient(t) + + model := NewDeployView(context.Background(), DeployConfig{ + DisplayConfig: ui.DisplayConfig{ + IsInteractive: false, + DisableAnimation: true, + }, + Config: partnerConfig("partner-app"), + ProjectID: "test-project", + Client: mockClient, + WSClient: wsmock.NewMockClient(t), + Detach: true, + }) + + model.state = StateCreatingApp + + var cmd tea.Cmd + stdout := captureStdout(t, func() { + _, cmd = model.Update(appCreatedMsg{response: partnerAppResponse("build-partner")}) + }) + + assert.Contains(t, stdout, "✓ Build pending (ID: build-partner)") + assert.Contains(t, stdout, "✓ Build started in detached mode") + assert.Equal(t, StateDeploySuccess, model.state) + assert.Nil(t, model.logViewer) + + require.NotNil(t, cmd) + assert.IsType(t, tea.QuitMsg{}, cmd(), "detach should exit instead of waiting on the build") + }) + t.Run("zip uploaded transition", func(t *testing.T) { mockClient := apimock.NewMockClient(t) mockWsClient := wsmock.NewMockClient(t)