Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
38 changes: 7 additions & 31 deletions internal/ui/commands/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."),
Expand All @@ -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,
)
}
Expand Down
136 changes: 131 additions & 5 deletions internal/ui/commands/deploy_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Loading