From 66db2a00f18708b834de48670d9fd5f087b5a7ae Mon Sep 17 00:00:00 2001 From: Ilmars Janis Bluzmanis <9987548+darksworm@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:20:32 +0200 Subject: [PATCH 1/4] feat: add a force option to the sync modal --- cmd/app/api_integration.go | 8 +- cmd/app/input_handlers.go | 22 +++- cmd/app/sync_force_test.go | 106 ++++++++++++++++++ .../snapshots/modal_confirm_sync.golden | 1 + .../modal_confirm_sync_prune_on.golden | 1 + cmd/app/view.go | 5 + cmd/app/view_modals.go | 17 +++ cmd/app/view_modals_sync_test.go | 38 +++++++ pkg/model/state.go | 5 + pkg/services/argo.go | 19 ++-- pkg/services/argo_sync_retry_test.go | 3 +- 11 files changed, 207 insertions(+), 18 deletions(-) create mode 100644 cmd/app/sync_force_test.go diff --git a/cmd/app/api_integration.go b/cmd/app/api_integration.go index 68daf896..8971edd4 100644 --- a/cmd/app/api_integration.go +++ b/cmd/app/api_integration.go @@ -804,7 +804,7 @@ func stripDiffHeader(out string) string { } // syncSelectedApplications syncs the currently selected applications -func (m *Model) syncSelectedApplications(prune bool) tea.Cmd { +func (m *Model) syncSelectedApplications(prune, force bool) tea.Cmd { if m.state.Server == nil { return func() tea.Msg { return model.ApiErrorMsg{Message: "No server configured"} @@ -830,7 +830,7 @@ func (m *Model) syncSelectedApplications(prune bool) tea.Cmd { for _, appName := range selectedApps { ctx, cancel := appcontext.WithAPITimeout(context.Background()) // Multi-app sync doesn't track per-app namespaces; pass nil (uses Argo CD default) - err := apiService.SyncApplication(ctx, server, appName, nil, prune) + err := apiService.SyncApplication(ctx, server, appName, nil, api.SyncOptions{Prune: prune, Force: force}) cancel() if err != nil { // Convert to structured error and return via TUI error handling @@ -918,7 +918,7 @@ func (m *Model) deleteApplication(req model.AppDeleteRequestMsg) tea.Cmd { } // syncSingleApplication syncs a specific application -func (m *Model) syncSingleApplication(appName string, appNamespace *string, prune bool) tea.Cmd { +func (m *Model) syncSingleApplication(appName string, appNamespace *string, prune, force bool) tea.Cmd { if m.state.Server == nil { return func() tea.Msg { return model.ApiErrorMsg{Message: "No server configured"} @@ -934,7 +934,7 @@ func (m *Model) syncSingleApplication(appName string, appNamespace *string, prun apiService := services.NewArgoApiService(server) cblog.With("component", "api").Info("Starting sync", "app", appName) - err := apiService.SyncApplication(ctx, server, appName, appNamespace, prune) + err := apiService.SyncApplication(ctx, server, appName, appNamespace, api.SyncOptions{Prune: prune, Force: force}) if err != nil { cblog.With("component", "api").Error("Sync failed", "app", appName, "err", err) // Convert to structured error and return via TUI error handling diff --git a/cmd/app/input_handlers.go b/cmd/app/input_handlers.go index d879fd99..c1e4ab97 100644 --- a/cmd/app/input_handlers.go +++ b/cmd/app/input_handlers.go @@ -666,6 +666,12 @@ func (m *Model) diffPageSize() int { func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "q": + // Backing out of the force confirmation returns to the options + // rather than abandoning the sync the user was setting up. + if m.state.Modals.ConfirmSyncForcePending { + m.state.Modals.ConfirmSyncForcePending = false + return m, nil + } m.state.Mode = model.ModeNormal m.state.Modals.ConfirmTarget = nil m.state.Modals.ConfirmTargetNamespace = nil @@ -690,10 +696,19 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } fallthrough case "y": + // Force deletes and recreates live resources, so it gets its own + // confirmation rather than riding along on this one. + if m.state.Modals.ConfirmSyncForce && !m.state.Modals.ConfirmSyncForcePending { + m.state.Modals.ConfirmSyncForcePending = true + return m, nil + } + m.state.Modals.ConfirmSyncForcePending = false + // Confirm sync - keep modal open and show loading overlay target := m.state.Modals.ConfirmTarget targetNamespace := m.state.Modals.ConfirmTargetNamespace prune := m.state.Modals.ConfirmSyncPrune + force := m.state.Modals.ConfirmSyncForce m.state.Modals.ConfirmSyncLoading = true m.state.Mode = model.ModeConfirmSync @@ -702,9 +717,9 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { "target", *target, "isMulti", *target == "__MULTI__") if *target == "__MULTI__" { - return m, m.syncSelectedApplications(prune) + return m, m.syncSelectedApplications(prune, force) } else { - return m, m.syncSingleApplication(*target, targetNamespace, prune) + return m, m.syncSingleApplication(*target, targetNamespace, prune, force) } } return m, nil @@ -712,6 +727,9 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { // Toggle prune option m.state.Modals.ConfirmSyncPrune = !m.state.Modals.ConfirmSyncPrune return m, nil + case "f": + m.state.Modals.ConfirmSyncForce = !m.state.Modals.ConfirmSyncForce + return m, nil case "w": // Toggle watch option (single or multi) m.state.Modals.ConfirmSyncWatch = !m.state.Modals.ConfirmSyncWatch diff --git a/cmd/app/sync_force_test.go b/cmd/app/sync_force_test.go new file mode 100644 index 00000000..75179df6 --- /dev/null +++ b/cmd/app/sync_force_test.go @@ -0,0 +1,106 @@ +package main + +import ( + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/darksworm/argonaut/pkg/model" +) + +func syncModalModel(t *testing.T) *Model { + t.Helper() + m := buildBaseModel(100, 30) + m.state.Mode = model.ModeConfirmSync + target := "demo-app" + m.state.Modals.ConfirmTarget = &target + return m +} + +func press(t *testing.T, m *Model, key string) *Model { + t.Helper() + msg := tea.KeyPressMsg{Code: rune(key[0]), Text: key} + if key == "esc" { + msg = tea.KeyPressMsg{Code: tea.KeyEscape} + } + updated, _ := m.handleConfirmSyncKeys(msg) + next, ok := updated.(*Model) + if !ok { + t.Fatalf("expected the handler to return a *Model, got %T", updated) + } + return next +} + +func TestSyncModal_FKeyTogglesForce(t *testing.T) { + m := syncModalModel(t) + + m = press(t, m, "f") + if !m.state.Modals.ConfirmSyncForce { + t.Error("expected f to turn force on") + } + + m = press(t, m, "f") + if m.state.Modals.ConfirmSyncForce { + t.Error("expected f to turn force off again") + } +} + +func TestSyncModal_ConfirmingWithForceAsksAgainBeforeSyncing(t *testing.T) { + m := syncModalModel(t) + m.state.Modals.ConfirmSyncForce = true + + m = press(t, m, "y") + + if !m.state.Modals.ConfirmSyncForcePending { + t.Error("expected a force sync to ask for confirmation first") + } + if m.state.Modals.ConfirmSyncLoading { + t.Error("expected the sync not to start until the force confirmation is answered") + } +} + +func TestSyncModal_ConfirmingWithoutForceSyncsStraightAway(t *testing.T) { + m := syncModalModel(t) + + m = press(t, m, "y") + + if m.state.Modals.ConfirmSyncForcePending { + t.Error("expected no force confirmation when force is off") + } + if !m.state.Modals.ConfirmSyncLoading { + t.Error("expected the sync to start immediately") + } +} + +func TestSyncModal_AnsweringTheForceConfirmationStartsTheSync(t *testing.T) { + m := syncModalModel(t) + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncForcePending = true + + m = press(t, m, "y") + + if m.state.Modals.ConfirmSyncForcePending { + t.Error("expected the force confirmation to close once answered") + } + if !m.state.Modals.ConfirmSyncLoading { + t.Error("expected the sync to start after confirming the force") + } +} + +func TestSyncModal_CancellingTheForceConfirmationKeepsTheOptions(t *testing.T) { + m := syncModalModel(t) + m.state.Modals.ConfirmSyncPrune = true + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncForcePending = true + + m = press(t, m, "esc") + + if m.state.Modals.ConfirmSyncForcePending { + t.Error("expected esc to leave the force confirmation") + } + if m.state.Mode != model.ModeConfirmSync { + t.Errorf("expected to return to the sync modal, got mode %q", m.state.Mode) + } + if !m.state.Modals.ConfirmSyncForce || !m.state.Modals.ConfirmSyncPrune { + t.Error("expected the chosen options to survive cancelling the force confirmation") + } +} diff --git a/cmd/app/testdata/snapshots/modal_confirm_sync.golden b/cmd/app/testdata/snapshots/modal_confirm_sync.golden index 0b96495d..9bccb217 100644 --- a/cmd/app/testdata/snapshots/modal_confirm_sync.golden +++ b/cmd/app/testdata/snapshots/modal_confirm_sync.golden @@ -4,6 +4,7 @@ │ Sync demo-app? │ │ │ │ p Prune Off │ + │ f Force Off │ │ w Watch On │ │ │ │ Sync Cancel │ diff --git a/cmd/app/testdata/snapshots/modal_confirm_sync_prune_on.golden b/cmd/app/testdata/snapshots/modal_confirm_sync_prune_on.golden index 105eee7c..9b4624f8 100644 --- a/cmd/app/testdata/snapshots/modal_confirm_sync_prune_on.golden +++ b/cmd/app/testdata/snapshots/modal_confirm_sync_prune_on.golden @@ -4,6 +4,7 @@ │ Sync demo-app? │ │ │ │ p Prune On removes extras │ + │ f Force Off │ │ w Watch On │ │ │ │ Sync Cancel │ diff --git a/cmd/app/view.go b/cmd/app/view.go index cebb4d31..b6a33185 100644 --- a/cmd/app/view.go +++ b/cmd/app/view.go @@ -719,6 +719,10 @@ func (m *Model) renderConfirmSyncModal() string { target := *m.state.Modals.ConfirmTarget isMulti := target == "__MULTI__" + if m.state.Modals.ConfirmSyncForcePending { + return m.renderForceSyncConfirm(target, isMulti) + } + // Modal width: compact and centered half := m.state.Terminal.Cols / 2 modalWidth := min(max(36, half), m.state.Terminal.Cols-6) @@ -774,6 +778,7 @@ func (m *Model) renderConfirmSyncModal() string { // buttons stay centered, because they are not a list. aux := renderSyncOptions([]syncOption{ {Key: "p", Label: "Prune", On: m.state.Modals.ConfirmSyncPrune, Clause: "removes extras", Danger: true}, + {Key: "f", Label: "Force", On: m.state.Modals.ConfirmSyncForce, Clause: "delete & recreate", Danger: true}, {Key: "w", Label: "Watch", On: m.state.Modals.ConfirmSyncWatch}, }, innerWidth) diff --git a/cmd/app/view_modals.go b/cmd/app/view_modals.go index f7458694..d0e8a03b 100644 --- a/cmd/app/view_modals.go +++ b/cmd/app/view_modals.go @@ -817,6 +817,23 @@ func (m *Model) renderTwoButtonConfirm(title string, accent color.Color, confirm return outer.Render(wrapper.Render(body)) } +// renderForceSyncConfirm is the second step of a forced sync. Force bypasses +// graceful deletion, so it is confirmed on its own rather than riding along +// on the sync confirmation. +func (m *Model) renderForceSyncConfirm(target string, isMulti bool) string { + subject := target + if isMulti { + subject = fmt.Sprintf("%d applications", len(m.state.Selections.SelectedApps)) + } + + bright := lipgloss.NewStyle().Foreground(whiteBright) + title := bright.Render("Force sync deletes and recreates resources in ") + + bright.Bold(true).Render(subject) + bright.Render(".") + + return m.renderTwoButtonConfirm(title, outOfSyncColor, "Force sync", + m.state.Modals.ConfirmSyncSelected, "") +} + // renderTerminateConfirmModal asks whether to cancel the app's running operation func (m *Model) renderTerminateConfirmModal() string { st := m.state.Modals.Terminate diff --git a/cmd/app/view_modals_sync_test.go b/cmd/app/view_modals_sync_test.go index eeb6598c..d6f7b3e8 100644 --- a/cmd/app/view_modals_sync_test.go +++ b/cmd/app/view_modals_sync_test.go @@ -117,3 +117,41 @@ func TestSyncModal_PutsOptionsAboveTheButtons(t *testing.T) { options, buttons, out) } } + +func TestSyncModal_ForceConfirmationSpellsOutWhatForceDoes(t *testing.T) { + m := buildBaseModel(100, 30) + m.state.Mode = model.ModeConfirmSync + target := "demo-app" + m.state.Modals.ConfirmTarget = &target + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncForcePending = true + + out := stripANSI(m.renderConfirmSyncModal()) + + for _, want := range []string{"demo-app", "recreates", "Force sync", "Cancel"} { + if !strings.Contains(out, want) { + t.Errorf("expected the force confirmation to mention %q, got:\n%s", want, out) + } + } + if strings.Contains(out, "Prune") { + t.Errorf("expected the options replaced by the confirmation, got:\n%s", out) + } +} + +func TestSyncModal_ShowsForceAmongTheOptions(t *testing.T) { + m := buildBaseModel(100, 30) + m.state.Mode = model.ModeConfirmSync + target := "demo-app" + m.state.Modals.ConfirmTarget = &target + m.state.Modals.ConfirmSyncForce = true + + out := stripANSI(m.renderConfirmSyncModal()) + + line := optionLine(t, out, "Force") + if !strings.Contains(line, "On") { + t.Errorf("expected Force to read On, got %q", line) + } + if !strings.Contains(line, "delete") { + t.Errorf("expected Force to spell out its consequence, got %q", line) + } +} diff --git a/pkg/model/state.go b/pkg/model/state.go index 45f6cd15..93a64cf6 100644 --- a/pkg/model/state.go +++ b/pkg/model/state.go @@ -141,6 +141,11 @@ type ModalState struct { ConfirmTargetNamespace *string `json:"confirmTargetNamespace,omitempty"` ConfirmSyncPrune bool `json:"confirmSyncPrune"` ConfirmSyncWatch bool `json:"confirmSyncWatch"` + ConfirmSyncForce bool `json:"confirmSyncForce"` + // ConfirmSyncForcePending shows the force confirmation in place of the + // options: force deletes and recreates live resources, so it is not a + // thing to hand over on a single keypress. + ConfirmSyncForcePending bool `json:"confirmSyncForcePending"` // Which button is selected in confirm modal: 0 = Yes, 1 = Cancel ConfirmSyncSelected int `json:"confirmSyncSelected"` // When true, show a small syncing overlay instead of the confirm UI diff --git a/pkg/services/argo.go b/pkg/services/argo.go index cd72b0ce..a430525b 100644 --- a/pkg/services/argo.go +++ b/pkg/services/argo.go @@ -30,7 +30,7 @@ type ArgoApiService interface { WatchApplicationsWithOptions(ctx context.Context, server *model.Server, opts *api.WatchOptions) (<-chan ArgoApiEvent, func(), error) // SyncApplication syncs a specific application - SyncApplication(ctx context.Context, server *model.Server, appName string, appNamespace *string, prune bool) error + SyncApplication(ctx context.Context, server *model.Server, appName string, appNamespace *string, opts api.SyncOptions) error // GetResourceDiffs gets resource diffs for an application GetResourceDiffs(ctx context.Context, server *model.Server, appName string, appNamespace *string) ([]ResourceDiff, error) @@ -198,7 +198,7 @@ func (s *ArgoApiServiceImpl) WatchApplicationsWithOptions(ctx context.Context, s } // SyncApplication implements ArgoApiService.SyncApplication -func (s *ArgoApiServiceImpl) SyncApplication(ctx context.Context, server *model.Server, appName string, appNamespace *string, prune bool) error { +func (s *ArgoApiServiceImpl) SyncApplication(ctx context.Context, server *model.Server, appName string, appNamespace *string, opts api.SyncOptions) error { if server == nil { return apperrors.ConfigError("SERVER_MISSING", "Server configuration is required"). @@ -218,32 +218,29 @@ func (s *ArgoApiServiceImpl) SyncApplication(ctx context.Context, server *model. ctx, cancel := appcontext.WithSyncTimeout(ctx) defer cancel() - ns := "" if appNamespace != nil { - ns = *appNamespace - } - opts := &api.SyncOptions{ - Prune: prune, - AppNamespace: ns, + opts.AppNamespace = *appNamespace } // No retries: a network error can occur after the server has already // started the sync, so re-sending could run it twice. - err := s.appService.SyncApplication(ctx, appName, opts) + err := s.appService.SyncApplication(ctx, appName, &opts) if err != nil { // Convert API errors to structured format if needed if argErr, ok := err.(*apperrors.ArgonautError); ok { return argErr.WithContext("operation", "SyncApplication"). WithContext("appName", appName). - WithContext("prune", prune) + WithContext("prune", opts.Prune). + WithContext("force", opts.Force) } return apperrors.Wrap(err, apperrors.ErrorAPI, "SYNC_FAILED", "Failed to sync application"). WithContext("server", server.BaseURL). WithContext("appName", appName). - WithContext("prune", prune). + WithContext("prune", opts.Prune). + WithContext("force", opts.Force). AsRecoverable(). WithUserAction("Check the application status and try syncing again") } diff --git a/pkg/services/argo_sync_retry_test.go b/pkg/services/argo_sync_retry_test.go index 93cf0d48..d20235f0 100644 --- a/pkg/services/argo_sync_retry_test.go +++ b/pkg/services/argo_sync_retry_test.go @@ -2,6 +2,7 @@ package services import ( "context" + "github.com/darksworm/argonaut/pkg/api" "net/http" "net/http/httptest" "sync/atomic" @@ -26,7 +27,7 @@ func TestSyncApplication_NetworkError_IsNotRetried(t *testing.T) { srv := &model.Server{BaseURL: server.URL, Token: "test-token"} svc := NewArgoApiService(srv) - err := svc.SyncApplication(context.Background(), srv, "my-app", nil, false) + err := svc.SyncApplication(context.Background(), srv, "my-app", nil, api.SyncOptions{}) if err == nil { t.Fatal("expected an error from a dropped connection, got nil") } From 48d2c6aefc77c1c1617f55e8ac1ff536815bfed8 Mon Sep 17 00:00:00 2001 From: Ilmars Janis Bluzmanis <9987548+darksworm@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:08:08 +0200 Subject: [PATCH 2/4] fix: treat the force confirmation as its own input state --- cmd/app/input_handlers.go | 66 +++++++++++++++++++++++++++++++------- cmd/app/sync_force_test.go | 55 +++++++++++++++++++++++++++++++ 2 files changed, 109 insertions(+), 12 deletions(-) diff --git a/cmd/app/input_handlers.go b/cmd/app/input_handlers.go index c1e4ab97..8ec9bd15 100644 --- a/cmd/app/input_handlers.go +++ b/cmd/app/input_handlers.go @@ -664,6 +664,14 @@ func (m *Model) diffPageSize() int { // handleConfirmSyncKeys handles input when in sync confirmation mode func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // The force confirmation is its own input state. The options are not on + // screen behind it, so keys that would change them must not: toggling + // force off there would run a plain sync from a dialog that said "Force + // sync". + if m.state.Modals.ConfirmSyncForcePending { + return m.handleForceSyncConfirmKeys(msg) + } + switch msg.String() { case "esc", "q": // Backing out of the force confirmation returns to the options @@ -698,12 +706,57 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "y": // Force deletes and recreates live resources, so it gets its own // confirmation rather than riding along on this one. - if m.state.Modals.ConfirmSyncForce && !m.state.Modals.ConfirmSyncForcePending { + if m.state.Modals.ConfirmSyncForce { m.state.Modals.ConfirmSyncForcePending = true return m, nil } + + return m.startConfirmedSync() + case "p": + // Toggle prune option + m.state.Modals.ConfirmSyncPrune = !m.state.Modals.ConfirmSyncPrune + return m, nil + case "f": + m.state.Modals.ConfirmSyncForce = !m.state.Modals.ConfirmSyncForce + return m, nil + case "w": + // Toggle watch option (single or multi) + m.state.Modals.ConfirmSyncWatch = !m.state.Modals.ConfirmSyncWatch + return m, nil + } + return m, nil +} + +// handleForceSyncConfirmKeys handles the second step of a forced sync. Only +// the two buttons and the ways out are live here. +func (m *Model) handleForceSyncConfirmKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc", "q": + m.state.Modals.ConfirmSyncForcePending = false + return m, nil + case "left", "h": + m.state.Modals.ConfirmSyncSelected = 0 + return m, nil + case "right", "l": + m.state.Modals.ConfirmSyncSelected = 1 + return m, nil + case "enter": + if m.state.Modals.ConfirmSyncSelected == 1 { + // Back to the options, with everything the user chose intact. + m.state.Modals.ConfirmSyncForcePending = false + return m, nil + } + fallthrough + case "y": m.state.Modals.ConfirmSyncForcePending = false + return m.startConfirmedSync() + } + return m, nil +} +// startConfirmedSync kicks off the sync the modal has been configuring. +func (m *Model) startConfirmedSync() (tea.Model, tea.Cmd) { + { // Confirm sync - keep modal open and show loading overlay target := m.state.Modals.ConfirmTarget targetNamespace := m.state.Modals.ConfirmTargetNamespace @@ -723,17 +776,6 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } return m, nil - case "p": - // Toggle prune option - m.state.Modals.ConfirmSyncPrune = !m.state.Modals.ConfirmSyncPrune - return m, nil - case "f": - m.state.Modals.ConfirmSyncForce = !m.state.Modals.ConfirmSyncForce - return m, nil - case "w": - // Toggle watch option (single or multi) - m.state.Modals.ConfirmSyncWatch = !m.state.Modals.ConfirmSyncWatch - return m, nil } return m, nil } diff --git a/cmd/app/sync_force_test.go b/cmd/app/sync_force_test.go index 75179df6..6e897115 100644 --- a/cmd/app/sync_force_test.go +++ b/cmd/app/sync_force_test.go @@ -104,3 +104,58 @@ func TestSyncModal_CancellingTheForceConfirmationKeepsTheOptions(t *testing.T) { t.Error("expected the chosen options to survive cancelling the force confirmation") } } + +func TestSyncModal_CancelButtonOnTheForceConfirmationKeepsTheOptions(t *testing.T) { + m := syncModalModel(t) + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncForcePending = true + m.state.Modals.ConfirmSyncSelected = 1 // Cancel + + updated, _ := m.handleConfirmSyncKeys(tea.KeyPressMsg{Code: tea.KeyEnter}) + m = updated.(*Model) + + if m.state.Mode != model.ModeConfirmSync { + t.Errorf("expected cancelling the force confirmation to return to the options, got mode %q", m.state.Mode) + } + if m.state.Modals.ConfirmSyncForcePending { + t.Error("expected the force confirmation to close") + } + if !m.state.Modals.ConfirmSyncForce { + t.Error("expected force to survive cancelling its confirmation") + } +} + +func TestSyncModal_OptionKeysDoNothingWhileTheForceConfirmationIsUp(t *testing.T) { + for _, key := range []string{"p", "f", "w", "d"} { + t.Run(key, func(t *testing.T) { + m := syncModalModel(t) + m.state.Modals.ConfirmSyncPrune = true + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncWatch = true + m.state.Modals.ConfirmSyncForcePending = true + before := m.state.Modals + + m = press(t, m, key) + + if m.state.Modals.ConfirmSyncPrune != before.ConfirmSyncPrune || + m.state.Modals.ConfirmSyncForce != before.ConfirmSyncForce || + m.state.Modals.ConfirmSyncWatch != before.ConfirmSyncWatch { + t.Errorf("expected %q to be ignored while the force confirmation is up", key) + } + }) + } +} + +func TestSyncModal_ConfirmingTheForceDialogAlwaysForces(t *testing.T) { + m := syncModalModel(t) + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncForcePending = true + + // A stray f must not disarm the force the dialog is asking about. + m = press(t, m, "f") + m = press(t, m, "y") + + if !m.state.Modals.ConfirmSyncForce { + t.Error("expected the sync to force after confirming a dialog that said Force sync") + } +} From c8dc12b1676304f9479cab058fe5b3e82352badd Mon Sep 17 00:00:00 2001 From: Ilmars Janis Bluzmanis <9987548+darksworm@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:15:20 +0200 Subject: [PATCH 3/4] test: cover forced sync request bodies end to end --- e2e/sync_options_test.go | 158 +++++++++++++++++++++++++++++++++++++++ 1 file changed, 158 insertions(+) create mode 100644 e2e/sync_options_test.go diff --git a/e2e/sync_options_test.go b/e2e/sync_options_test.go new file mode 100644 index 00000000..10a5227d --- /dev/null +++ b/e2e/sync_options_test.go @@ -0,0 +1,158 @@ +//go:build e2e && unix + +package main + +import ( + "encoding/json" + "testing" + "time" +) + +// startAppsView boots the TUI against a sync-recording mock and navigates to +// the applications list, which is where the sync modal is opened from. +func startAppsView(t *testing.T) (*TUITestFramework, *SyncRecorder) { + t.Helper() + tf := NewTUITest(t) + t.Cleanup(tf.Cleanup) + + srv, rec, err := MockArgoServerSync("valid-token") + if err != nil { + t.Fatalf("mock server: %v", err) + } + t.Cleanup(srv.Close) + + cfgPath, err := tf.SetupWorkspace() + if err != nil { + t.Fatalf("setup workspace: %v", err) + } + if err := WriteArgoConfigWithToken(cfgPath, srv.URL, "valid-token"); err != nil { + t.Fatalf("write config: %v", err) + } + if err := tf.StartAppArgs([]string{"-argocd-config=" + cfgPath}); err != nil { + t.Fatalf("start app: %v", err) + } + + if !tf.WaitForPlain("cluster-a", 3*time.Second) { + t.Fatal("clusters not ready") + } + if err := tf.OpenCommand(); err != nil { + t.Fatal(err) + } + _ = tf.Send("ns default") + _ = tf.Enter() + if !tf.WaitForPlain("demo", 3*time.Second) { + t.Fatal("namespaces not ready") + } + if err := tf.OpenCommand(); err != nil { + t.Fatal(err) + } + _ = tf.Send("apps") + _ = tf.Enter() + if !tf.WaitForPlain("demo2", 3*time.Second) { + t.Fatal("apps not ready") + } + return tf, rec +} + +// syncBody is the request body argonaut posted to /sync, decoded. +func syncBody(t *testing.T, rec *SyncRecorder, want int) []map[string]any { + t.Helper() + if !waitUntil(t, func() bool { return rec.len() == want }, 3*time.Second) { + t.Fatalf("expected %d sync calls, got %d", want, rec.len()) + } + bodies := make([]map[string]any, 0, want) + for _, call := range rec.Calls { + var body map[string]any + if err := json.Unmarshal([]byte(call.Body), &body); err != nil { + t.Fatalf("decoding the sync body for %q: %v (%s)", call.Name, err, call.Body) + } + bodies = append(bodies, body) + } + return bodies +} + +// hookForce reports the force flag Argo CD will read, and whether the request +// used the apply strategy — which would silently skip the app's sync hooks. +func hookForce(t *testing.T, body map[string]any) (force, applyOnly bool) { + t.Helper() + strategy, ok := body["strategy"].(map[string]any) + if !ok { + return false, false + } + if _, applyOnly = strategy["apply"]; applyOnly { + return false, true + } + hook, _ := strategy["hook"].(map[string]any) + force, _ = hook["force"].(bool) + return force, false +} + +func TestSyncSingleApp_WithForce_SendsHookForce(t *testing.T) { + t.Parallel() + tf, rec := startAppsView(t) + + _ = tf.Send("s") // sync modal for the app under the cursor + if !tf.WaitForScreen("Force", 3*time.Second) { + t.Fatalf("sync modal never showed the force option:\n%s", tf.Screen()) + } + _ = tf.Send("f") + _ = tf.Send("y") + if !tf.WaitForScreen("Force sync", 3*time.Second) { + t.Fatalf("force confirmation never appeared:\n%s", tf.Screen()) + } + _ = tf.Send("y") + + force, applyOnly := hookForce(t, syncBody(t, rec, 1)[0]) + if applyOnly { + t.Error("a forced sync used the apply strategy, which skips sync hooks") + } + if !force { + t.Errorf("expected strategy.hook.force=true, got %s", rec.Calls[0].Body) + } +} + +func TestSyncMultipleApps_WithForce_SendsHookForceForEach(t *testing.T) { + t.Parallel() + tf, rec := startAppsView(t) + + // Select both apps: space, down, space + _ = tf.Send(" ") + _ = tf.Send("j") + _ = tf.Send(" ") + + _ = tf.Send("s") + if !tf.WaitForScreen("Force", 3*time.Second) { + t.Fatalf("sync modal never showed the force option:\n%s", tf.Screen()) + } + _ = tf.Send("f") + _ = tf.Send("y") + if !tf.WaitForScreen("Force sync", 3*time.Second) { + t.Fatalf("force confirmation never appeared:\n%s", tf.Screen()) + } + _ = tf.Send("y") + + for i, body := range syncBody(t, rec, 2) { + force, applyOnly := hookForce(t, body) + if applyOnly { + t.Errorf("sync call %d used the apply strategy, which skips sync hooks", i) + } + if !force { + t.Errorf("expected strategy.hook.force=true on call %d, got %s", i, rec.Calls[i].Body) + } + } +} + +func TestSyncApp_WithoutForce_SendsNoStrategy(t *testing.T) { + t.Parallel() + tf, rec := startAppsView(t) + + _ = tf.Send("s") + if !tf.WaitForScreen("Sync", 3*time.Second) { + t.Fatalf("sync modal never opened:\n%s", tf.Screen()) + } + _ = tf.Send("y") + + if strategy, ok := syncBody(t, rec, 1)[0]["strategy"]; ok { + t.Errorf("expected no strategy on an ordinary sync, got %v", strategy) + } +} From dc727e36875aec504e8a309f4fb38305af056793 Mon Sep 17 00:00:00 2001 From: darksworm <9987548+darksworm@users.noreply.github.com> Date: Sun, 6 Sep 2026 11:11:58 +0200 Subject: [PATCH 4/4] test: add force sync confirmation golden --- .../snapshots/modal_confirm_sync_force.golden | 10 ++++++++ cmd/app/view_modals_sync_test.go | 24 ++++++++++++------- 2 files changed, 26 insertions(+), 8 deletions(-) create mode 100644 cmd/app/testdata/snapshots/modal_confirm_sync_force.golden diff --git a/cmd/app/testdata/snapshots/modal_confirm_sync_force.golden b/cmd/app/testdata/snapshots/modal_confirm_sync_force.golden new file mode 100644 index 00000000..70cad2aa --- /dev/null +++ b/cmd/app/testdata/snapshots/modal_confirm_sync_force.golden @@ -0,0 +1,10 @@ + + ╭────────────────────────────────────────────────╮ + │ │ + │ Force sync deletes and recreates resources │ + │ in demo-app. │ + │ │ + │ Force sync Cancel │ + │ │ + ╰────────────────────────────────────────────────╯ + \ No newline at end of file diff --git a/cmd/app/view_modals_sync_test.go b/cmd/app/view_modals_sync_test.go index d6f7b3e8..2368b120 100644 --- a/cmd/app/view_modals_sync_test.go +++ b/cmd/app/view_modals_sync_test.go @@ -20,6 +20,17 @@ func syncModal(t *testing.T, prune, watch bool) string { return stripANSI(m.renderConfirmSyncModal()) } +func forceSyncConfirmModal(t *testing.T) string { + t.Helper() + m := buildBaseModel(100, 30) + m.state.Mode = model.ModeConfirmSync + target := "demo-app" + m.state.Modals.ConfirmTarget = &target + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncForcePending = true + return stripANSI(m.renderConfirmSyncModal()) +} + // optionLine returns the single rendered line carrying the named option. func optionLine(t *testing.T, out, label string) string { t.Helper() @@ -118,15 +129,12 @@ func TestSyncModal_PutsOptionsAboveTheButtons(t *testing.T) { } } -func TestSyncModal_ForceConfirmationSpellsOutWhatForceDoes(t *testing.T) { - m := buildBaseModel(100, 30) - m.state.Mode = model.ModeConfirmSync - target := "demo-app" - m.state.Modals.ConfirmTarget = &target - m.state.Modals.ConfirmSyncForce = true - m.state.Modals.ConfirmSyncForcePending = true +func TestGolden_ConfirmSyncModal_ForceConfirmation(t *testing.T) { + compareWithGolden(t, "modal_confirm_sync_force", forceSyncConfirmModal(t)) +} - out := stripANSI(m.renderConfirmSyncModal()) +func TestSyncModal_ForceConfirmationSpellsOutWhatForceDoes(t *testing.T) { + out := forceSyncConfirmModal(t) for _, want := range []string{"demo-app", "recreates", "Force sync", "Cancel"} { if !strings.Contains(out, want) {