From 697d4285645b0db1f0f0054bc376e826d3373293 Mon Sep 17 00:00:00 2001 From: Ilmars Janis Bluzmanis <9987548+darksworm@users.noreply.github.com> Date: Tue, 18 Aug 2026 23:21:51 +0200 Subject: [PATCH 1/2] feat: add a dry run option to the sync modal --- cmd/app/api_integration.go | 8 +-- cmd/app/input_handlers.go | 10 ++-- cmd/app/sync_dry_run_test.go | 53 +++++++++++++++++++ .../snapshots/modal_confirm_sync.golden | 1 + .../modal_confirm_sync_prune_on.golden | 1 + cmd/app/view.go | 10 +++- cmd/app/view_modals.go | 9 +++- pkg/model/state.go | 1 + 8 files changed, 84 insertions(+), 9 deletions(-) create mode 100644 cmd/app/sync_dry_run_test.go diff --git a/cmd/app/api_integration.go b/cmd/app/api_integration.go index 8971edd4..0f28b857 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, force bool) tea.Cmd { +func (m *Model) syncSelectedApplications(prune, force, dryRun 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, force 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, api.SyncOptions{Prune: prune, Force: force}) + err := apiService.SyncApplication(ctx, server, appName, nil, api.SyncOptions{Prune: prune, Force: force, DryRun: dryRun}) 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, force bool) tea.Cmd { +func (m *Model) syncSingleApplication(appName string, appNamespace *string, prune, force, dryRun 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, api.SyncOptions{Prune: prune, Force: force}) + err := apiService.SyncApplication(ctx, server, appName, appNamespace, api.SyncOptions{Prune: prune, Force: force, DryRun: dryRun}) 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 8ec9bd15..7cd7aaa1 100644 --- a/cmd/app/input_handlers.go +++ b/cmd/app/input_handlers.go @@ -706,7 +706,7 @@ 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 { + if m.state.Modals.ConfirmSyncForce && !m.state.Modals.ConfirmSyncDryRun { m.state.Modals.ConfirmSyncForcePending = true return m, nil } @@ -719,6 +719,9 @@ func (m *Model) handleConfirmSyncKeys(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "f": m.state.Modals.ConfirmSyncForce = !m.state.Modals.ConfirmSyncForce return m, nil + case "d": + m.state.Modals.ConfirmSyncDryRun = !m.state.Modals.ConfirmSyncDryRun + return m, nil case "w": // Toggle watch option (single or multi) m.state.Modals.ConfirmSyncWatch = !m.state.Modals.ConfirmSyncWatch @@ -762,6 +765,7 @@ func (m *Model) startConfirmedSync() (tea.Model, tea.Cmd) { targetNamespace := m.state.Modals.ConfirmTargetNamespace prune := m.state.Modals.ConfirmSyncPrune force := m.state.Modals.ConfirmSyncForce + dryRun := m.state.Modals.ConfirmSyncDryRun m.state.Modals.ConfirmSyncLoading = true m.state.Mode = model.ModeConfirmSync @@ -770,9 +774,9 @@ func (m *Model) startConfirmedSync() (tea.Model, tea.Cmd) { "target", *target, "isMulti", *target == "__MULTI__") if *target == "__MULTI__" { - return m, m.syncSelectedApplications(prune, force) + return m, m.syncSelectedApplications(prune, force, dryRun) } else { - return m, m.syncSingleApplication(*target, targetNamespace, prune, force) + return m, m.syncSingleApplication(*target, targetNamespace, prune, force, dryRun) } } return m, nil diff --git a/cmd/app/sync_dry_run_test.go b/cmd/app/sync_dry_run_test.go new file mode 100644 index 00000000..edc83b78 --- /dev/null +++ b/cmd/app/sync_dry_run_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "strings" + "testing" + + "github.com/darksworm/argonaut/pkg/model" +) + +func TestSyncModal_DKeyTogglesDryRun(t *testing.T) { + m := syncModalModel(t) + + m = press(t, m, "d") + + if !m.state.Modals.ConfirmSyncDryRun { + t.Error("expected d to turn dry run on") + } +} + +func TestSyncModal_DryRunSkipsTheForceConfirmationBecauseNothingIsApplied(t *testing.T) { + m := syncModalModel(t) + m.state.Modals.ConfirmSyncForce = true + m.state.Modals.ConfirmSyncDryRun = true + + m = press(t, m, "y") + + if m.state.Modals.ConfirmSyncForcePending { + t.Error("expected no force confirmation during a dry run — nothing is applied") + } + if !m.state.Modals.ConfirmSyncLoading { + t.Error("expected the dry run to start immediately") + } +} + +func TestSyncModal_DryRunMarksForceInert(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.ConfirmSyncDryRun = true + + out := stripANSI(m.renderConfirmSyncModal()) + + force := optionLine(t, out, "Force") + if !strings.Contains(force, "inert in dry run") { + t.Errorf("expected force marked inert during a dry run, got %q", force) + } + dryRun := optionLine(t, out, "Dry run") + if !strings.Contains(dryRun, "validates only") { + t.Errorf("expected the dry run row to say what it does, got %q", dryRun) + } +} diff --git a/cmd/app/testdata/snapshots/modal_confirm_sync.golden b/cmd/app/testdata/snapshots/modal_confirm_sync.golden index 9bccb217..e92c6f61 100644 --- a/cmd/app/testdata/snapshots/modal_confirm_sync.golden +++ b/cmd/app/testdata/snapshots/modal_confirm_sync.golden @@ -5,6 +5,7 @@ │ │ │ p Prune Off │ │ f Force Off │ + │ d Dry run 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 9b4624f8..5ee3b7ac 100644 --- a/cmd/app/testdata/snapshots/modal_confirm_sync_prune_on.golden +++ b/cmd/app/testdata/snapshots/modal_confirm_sync_prune_on.golden @@ -5,6 +5,7 @@ │ │ │ p Prune On removes extras │ │ f Force Off │ + │ d Dry run Off │ │ w Watch On │ │ │ │ Sync Cancel │ diff --git a/cmd/app/view.go b/cmd/app/view.go index b6a33185..970442d0 100644 --- a/cmd/app/view.go +++ b/cmd/app/view.go @@ -776,9 +776,17 @@ func (m *Model) renderConfirmSyncModal() string { // Left-aligned so the eye scans one column of values; the title and // buttons stay centered, because they are not a list. + // A client-side dry run never touches a live resource, so force cannot + // delete or recreate anything: say so rather than leaving it looking armed. + force := syncOption{Key: "f", Label: "Force", On: m.state.Modals.ConfirmSyncForce, Clause: "delete & recreate", Danger: true} + if m.state.Modals.ConfirmSyncDryRun { + force.Clause, force.Danger, force.Inert = "inert in dry run", false, true + } + 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}, + force, + {Key: "d", Label: "Dry run", On: m.state.Modals.ConfirmSyncDryRun, Clause: "validates only", Info: 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 d0e8a03b..f4279201 100644 --- a/cmd/app/view_modals.go +++ b/cmd/app/view_modals.go @@ -742,6 +742,8 @@ type syncOption struct { On bool Clause string // dim consequence text, shown beside the value only when on Danger bool // destructive when on, so On reads in Danger rather than Warning + Info bool // on, but with no effect on the cluster + Inert bool // on, but neutralised by another option } // The option rows are a fixed three-column grid. The columns do not adapt to @@ -764,8 +766,13 @@ func renderSyncOptions(opts []syncOption, innerWidth int) string { if o.On { value = "On" valueStyle = lipgloss.NewStyle().Foreground(yellowBright).Bold(true) - if o.Danger { + switch { + case o.Inert: + valueStyle = dim + case o.Danger: valueStyle = lipgloss.NewStyle().Foreground(outOfSyncColor).Bold(true) + case o.Info: + valueStyle = lipgloss.NewStyle().Foreground(cyanBright).Bold(true) } } diff --git a/pkg/model/state.go b/pkg/model/state.go index 93a64cf6..b7a86a5b 100644 --- a/pkg/model/state.go +++ b/pkg/model/state.go @@ -142,6 +142,7 @@ type ModalState struct { ConfirmSyncPrune bool `json:"confirmSyncPrune"` ConfirmSyncWatch bool `json:"confirmSyncWatch"` ConfirmSyncForce bool `json:"confirmSyncForce"` + ConfirmSyncDryRun bool `json:"confirmSyncDryRun"` // 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. From 93890e97212252006f89d9948b0b8149aa858932 Mon Sep 17 00:00:00 2001 From: Ilmars Janis Bluzmanis <9987548+darksworm@users.noreply.github.com> Date: Wed, 19 Aug 2026 00:16:51 +0200 Subject: [PATCH 2/2] test: cover the dry run option from key to request body --- e2e/sync_options_test.go | 37 +++++++++++++++++++++++++++++++ pkg/api/applications_sync_test.go | 21 ++++++++++++++++++ 2 files changed, 58 insertions(+) diff --git a/e2e/sync_options_test.go b/e2e/sync_options_test.go index 10a5227d..389d0875 100644 --- a/e2e/sync_options_test.go +++ b/e2e/sync_options_test.go @@ -156,3 +156,40 @@ func TestSyncApp_WithoutForce_SendsNoStrategy(t *testing.T) { t.Errorf("expected no strategy on an ordinary sync, got %v", strategy) } } + +func TestSyncApp_WithDryRun_SendsDryRun(t *testing.T) { + t.Parallel() + tf, rec := startAppsView(t) + + _ = tf.Send("s") + if !tf.WaitForScreen("Dry run", 3*time.Second) { + t.Fatalf("sync modal never showed the dry run option:\n%s", tf.Screen()) + } + _ = tf.Send("d") + _ = tf.Send("y") + + if dryRun := syncBody(t, rec, 1)[0]["dryRun"]; dryRun != true { + t.Errorf("expected dryRun=true, got %s", rec.Calls[0].Body) + } +} + +func TestSyncApp_DryRunWithForce_SkipsTheForceConfirmation(t *testing.T) { + t.Parallel() + tf, rec := startAppsView(t) + + _ = tf.Send("s") + if !tf.WaitForScreen("Dry run", 3*time.Second) { + t.Fatalf("sync modal never opened:\n%s", tf.Screen()) + } + _ = tf.Send("f") + _ = tf.Send("d") + _ = tf.Send("y") // nothing is applied, so this must sync rather than ask again + + body := syncBody(t, rec, 1)[0] + if body["dryRun"] != true { + t.Errorf("expected dryRun=true, got %s", rec.Calls[0].Body) + } + if force, _ := hookForce(t, body); !force { + t.Errorf("expected force still sent on a dry run, got %s", rec.Calls[0].Body) + } +} diff --git a/pkg/api/applications_sync_test.go b/pkg/api/applications_sync_test.go index 57073603..560b9baa 100644 --- a/pkg/api/applications_sync_test.go +++ b/pkg/api/applications_sync_test.go @@ -59,3 +59,24 @@ func TestSyncApplication_WithoutForce_SendsNoStrategy(t *testing.T) { t.Errorf("expected no strategy when force is off, got %v", strategy) } } + +func TestSyncApplication_DryRun_IsSentSoArgoCDAppliesNothing(t *testing.T) { + var body map[string]interface{} + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Errorf("decoding sync request body: %v", err) + } + w.WriteHeader(http.StatusOK) + w.Write([]byte("{}")) + })) + defer server.Close() + + svc := NewApplicationService(&model.Server{BaseURL: server.URL, Token: "test-token"}) + if err := svc.SyncApplication(context.Background(), "test-app", &SyncOptions{DryRun: true}); err != nil { + t.Fatalf("SyncApplication: %v", err) + } + + if body["dryRun"] != true { + t.Errorf("expected dryRun=true in the request body, got %v", body) + } +}