From 619a7b3da737e615aa87b24aa6600342e6351957 Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Wed, 30 Sep 2026 14:21:04 +0200 Subject: [PATCH 1/3] add chart crds to human plan view - show helm lifecycle after resources - keep chart crds out of resource counts - cover process, skip, upgrade, and roles --- internal/plan/view/human.go | 97 +++++- internal/plan/view/human_chart_crd_test.go | 299 ++++++++++++++++++ internal/plan/view/human_style_test.go | 4 + .../golden/human_chart_crds_multiple.golden | 23 ++ .../golden/human_chart_crds_process.golden | 15 + .../golden/human_chart_crds_skip.golden | 15 + .../golden/human_chart_crds_upgrade.golden | 15 + .../testdata/golden/style_chart_crds.golden | 11 + 8 files changed, 475 insertions(+), 4 deletions(-) create mode 100644 internal/plan/view/human_chart_crd_test.go create mode 100644 internal/plan/view/testdata/golden/human_chart_crds_multiple.golden create mode 100644 internal/plan/view/testdata/golden/human_chart_crds_process.golden create mode 100644 internal/plan/view/testdata/golden/human_chart_crds_skip.golden create mode 100644 internal/plan/view/testdata/golden/human_chart_crds_upgrade.golden create mode 100644 internal/plan/view/testdata/golden/style_chart_crds.golden diff --git a/internal/plan/view/human.go b/internal/plan/view/human.go index a69f5c2..8408798 100644 --- a/internal/plan/view/human.go +++ b/internal/plan/view/human.go @@ -36,10 +36,9 @@ const humanTabWidth = 4 const headerLabelWidth = 10 -// WriteHuman writes a deterministic YAML-oriented rendering of p. It -// does not mutate p. Resource headings use +, ~, and - markers. -// Drift, when present, is a separate section. The footer is a Summary -// of resource counts and, when tasks or drift exist, those counts. +// WriteHuman writes p as text. It does not change p. +// Resource changes use +, ~, and - markers. The footer counts +// resources and, when present, tasks and drift. func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { prepared, err := prepareRender(p, opts) if err != nil { @@ -56,6 +55,18 @@ func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { return rerr } wroteBody = wroteResources + // Helm's chart-CRD lifecycle for each document, not a resource change. + if len(prepared.ChartCRDs) > 0 { + if wroteBody { + if berr := writeBlank(w); berr != nil { + return berr + } + } + if cerr := writeHumanChartCRDs(w, prepared.ChartCRDs, opts); cerr != nil { + return cerr + } + wroteBody = true + } if len(prepared.Tasks) > 0 { if wroteBody { if berr := writeBlank(w); berr != nil { @@ -67,6 +78,7 @@ func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { } wroteBody = true } + // Drift is observed state. if len(prepared.Drift) > 0 { if wroteBody { if berr := writeBlank(w); berr != nil { @@ -158,6 +170,83 @@ func writeHumanResources(w io.Writer, changes []semantic.ResourceChange, owned m return true, nil } +func writeHumanChartCRDs(w io.Writer, crds []semantic.ChartCRD, opts Options) error { + if len(crds) == 0 { + return nil + } + if err := writeln(w, opts, RoleTitle, "Chart CRDs"); err != nil { + return err + } + if err := writeBlank(w); err != nil { + return err + } + shared := sharedCRDSources(crds) + for i := range crds { + if i > 0 { + if err := writeBlank(w); err != nil { + return err + } + } + if err := writeHumanChartCRD(w, crds[i], shared[crds[i].Source], opts); err != nil { + return err + } + } + return nil +} + +func writeHumanChartCRD(w io.Writer, c semantic.ChartCRD, showIndex bool, opts Options) error { + heading := " " + c.Kind + " " + strconv.Quote(c.Name) + if err := writeln(w, opts, RolePrimary, heading); err != nil { + return err + } + if c.Source != "" { + source := c.Source + if showIndex { + source += fmt.Sprintf(" (index %d)", c.Index) + } + if err := writeln(w, opts, RolePrimary, " "+padLabel("source:")+" "+source); err != nil { + return err + } + } + lifecycle := c.Lifecycle.String() + if note := chartCRDNote(c.Lifecycle); note != "" { + lifecycle += " (" + note + ")" + } + return writeln(w, opts, RolePrimary, " "+padLabel("lifecycle:")+" "+lifecycle) +} + +// sharedCRDSources reports sources that appear on more than one chart +// CRD. An empty source is never shared, so a missing path stays omitted. +func sharedCRDSources(crds []semantic.ChartCRD) map[string]bool { + counts := make(map[string]int, len(crds)) + for _, c := range crds { + if c.Source == "" { + continue + } + counts[c.Source]++ + } + shared := make(map[string]bool) + for source, n := range counts { + if n > 1 { + shared[source] = true + } + } + return shared +} + +func chartCRDNote(lifecycle semantic.ChartCRDLifecycle) string { + switch lifecycle { + case semantic.ChartCRDProcess: + return "Helm install will process this chart CRD" + case semantic.ChartCRDSkip: + return "Helm install will skip this chart CRD" + case semantic.ChartCRDUpgrade: + return "Helm upgrade does not process chart CRDs" + default: + return "" + } +} + func writeHumanTasks(w io.Writer, p semantic.Plan, indexed map[string]semantic.ResourceChange, opts Options) error { if len(p.Tasks) == 0 { return nil diff --git a/internal/plan/view/human_chart_crd_test.go b/internal/plan/view/human_chart_crd_test.go new file mode 100644 index 0000000..de2f601 --- /dev/null +++ b/internal/plan/view/human_chart_crd_test.go @@ -0,0 +1,299 @@ +// Copyright 2026 The Deployah Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package view_test + +import ( + "bytes" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "deployah.dev/deployah/internal/plan/semantic" + "deployah.dev/deployah/internal/plan/view" +) + +func TestWriteHuman_NoChartCRDsHasNoSection(t *testing.T) { + t.Parallel() + text := writeHuman(t, mustPlanWithHeader(t, humanHeader(), semantic.HelmNone, nil, nil)) + assert.NotContains(t, text, "Chart CRDs") +} + +func TestWriteHuman_ChartCRDLifecycle(t *testing.T) { + t.Parallel() + fresh := humanHeader() + fresh.FreshInstall = true + upgrade := humanHeader() + tests := []struct { + name string + header semantic.Header + action semantic.HelmAction + lc semantic.ChartCRDLifecycle + golden string + note string + }{ + { + name: "process", + header: fresh, + action: semantic.HelmInstall, + lc: semantic.ChartCRDProcess, + golden: "human_chart_crds_process", + note: "lifecycle: process (Helm install will process this chart CRD)", + }, + { + name: "skip", + header: fresh, + action: semantic.HelmInstall, + lc: semantic.ChartCRDSkip, + golden: "human_chart_crds_skip", + note: "lifecycle: skip (Helm install will skip this chart CRD)", + }, + { + name: "upgrade", + header: upgrade, + action: semantic.HelmUpgrade, + lc: semantic.ChartCRDUpgrade, + golden: "human_chart_crds_upgrade", + note: "lifecycle: upgrade (Helm upgrade does not process chart CRDs)", + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := planWithChartCRDs(t, tt.header, tt.action, []semantic.ChartCRD{ + chartCRD(".deployah/crds/widget.yaml", "widgets.example.com", 0, tt.lc), + }) + text := writeHuman(t, p) + assertGolden(t, tt.golden, text) + assertHumanLayout(t, text) + assert.Contains(t, text, " Resources: 0 create, 0 update, 0 delete") + assert.Contains(t, text, tt.note) + assertChartCRDSectionHasNoDiff(t, chartCRDSection(t, text)) + }) + } +} + +func TestWriteHuman_ChartCRDSourceLine(t *testing.T) { + t.Parallel() + tests := []struct { + name string + crds []semantic.ChartCRD + contains []string + absent []string + }{ + { + name: "empty source", + crds: []semantic.ChartCRD{ + chartCRD("", "widgets.example.com", 0, semantic.ChartCRDUpgrade), + }, + contains: []string{"lifecycle: upgrade (Helm upgrade does not process chart CRDs)"}, + absent: []string{"source:"}, + }, + { + name: "one document", + crds: []semantic.ChartCRD{ + chartCRD(".deployah/crds/widget.yaml", "widgets.example.com", 0, semantic.ChartCRDUpgrade), + }, + contains: []string{".deployah/crds/widget.yaml"}, + absent: []string{"(index "}, + }, + { + name: "shared source", + crds: []semantic.ChartCRD{ + chartCRD(".deployah/crds/bundle.yaml", "zeta.example.com", 0, semantic.ChartCRDUpgrade), + chartCRD(".deployah/crds/bundle.yaml", "alpha.example.com", 1, semantic.ChartCRDUpgrade), + chartCRD(".deployah/crds/a.yaml", "mu.example.com", 0, semantic.ChartCRDUpgrade), + }, + contains: []string{ + ".deployah/crds/bundle.yaml (index 0)", + ".deployah/crds/bundle.yaml (index 1)", + ".deployah/crds/a.yaml", + }, + absent: []string{"a.yaml (index"}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + section := chartCRDSection(t, writeHuman(t, planWithChartCRDs(t, humanHeader(), semantic.HelmUpgrade, tt.crds))) + for _, s := range tt.contains { + assert.Contains(t, section, s) + } + for _, s := range tt.absent { + assert.NotContains(t, section, s) + } + }) + } +} + +func TestWriteHuman_ChartCRDOrder(t *testing.T) { + t.Parallel() + p := planWithChartCRDs(t, humanHeader(), semantic.HelmUpgrade, []semantic.ChartCRD{ + chartCRD(".deployah/crds/bundle.yaml", "zeta.example.com", 0, semantic.ChartCRDUpgrade), + chartCRD(".deployah/crds/bundle.yaml", "alpha.example.com", 1, semantic.ChartCRDUpgrade), + chartCRD(".deployah/crds/a.yaml", "mu.example.com", 0, semantic.ChartCRDUpgrade), + }) + text := writeHuman(t, p) + assertGolden(t, "human_chart_crds_multiple", text) + assert.Equal(t, text, writeHuman(t, p)) + assertTextOrder(t, text, "zeta.example.com", "alpha.example.com", "mu.example.com") +} + +func TestWriteHuman_ChartCRDPlacement(t *testing.T) { + t.Parallel() + p := mustPlanWithHeader(t, humanHeader(), semantic.HelmUpgrade, []semantic.ResourceChange{{ + Resource: ref("ConfigMap", "app"), + Action: semantic.Create, + After: snap(cm("app", "v1")), + }}, []semantic.TaskPlan{{ + Name: "migrate", + Phase: semantic.TaskPreDeploy, + Action: semantic.TaskUnchanged, + WillRun: true, + }}) + var err error + p, err = semantic.AttachChartCRDs(p, []semantic.ChartCRD{ + chartCRD(".deployah/crds/widget.yaml", "widgets.example.com", 0, semantic.ChartCRDUpgrade), + }) + require.NoError(t, err) + p, err = semantic.AttachDrift(p, []semantic.DriftChange{{ + Resource: ref("ConfigMap", "extra"), + Action: semantic.DriftUnexpected, + Live: snap(cm("extra", "live")), + }}) + require.NoError(t, err) + + text := writeHuman(t, p) + assertHumanLayout(t, text) + assertTextOrder(t, text, + "\nResources\n", + "\nChart CRDs\n", + "\nTasks\n", + "\nDrift\n", + "\nSummary\n", + ) +} + +func TestWriteHuman_ChartCRDSectionUsesNoDiffRole(t *testing.T) { + t.Parallel() + fresh := humanHeader() + fresh.FreshInstall = true + upgrade := humanHeader() + tests := []struct { + name string + header semantic.Header + action semantic.HelmAction + lc semantic.ChartCRDLifecycle + }{ + {name: "process", header: fresh, action: semantic.HelmInstall, lc: semantic.ChartCRDProcess}, + {name: "skip", header: fresh, action: semantic.HelmInstall, lc: semantic.ChartCRDSkip}, + {name: "upgrade", header: upgrade, action: semantic.HelmUpgrade, lc: semantic.ChartCRDUpgrade}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + p := planWithChartCRDs(t, tt.header, tt.action, []semantic.ChartCRD{ + chartCRD(".deployah/crds/widget.yaml", "widgets.example.com", 0, tt.lc), + }) + assertChartCRDSectionRoles(t, p) + }) + } +} + +func chartCRD(source, name string, index int, lc semantic.ChartCRDLifecycle) semantic.ChartCRD { + return semantic.ChartCRD{ + Source: source, + Index: index, + Kind: "CustomResourceDefinition", + Name: name, + Lifecycle: lc, + WillProcess: lc == semantic.ChartCRDProcess, + } +} + +func planWithChartCRDs(tb testing.TB, header semantic.Header, action semantic.HelmAction, crds []semantic.ChartCRD) semantic.Plan { + tb.Helper() + p, err := semantic.AttachChartCRDs(mustPlanWithHeader(tb, header, action, nil, nil), crds) + require.NoError(tb, err) + return p +} + +// chartCRDSection returns the Chart CRDs block, from its title through +// the last entry line. It stops before the blank line that opens the +// next column-0 section. +func chartCRDSection(t *testing.T, text string) string { + t.Helper() + const title = "Chart CRDs\n" + start := strings.Index(text, title) + require.GreaterOrEqual(t, start, 0, "Chart CRDs section missing") + rest := text[start+len(title):] + end := len(rest) + for _, next := range []string{"\n\nTasks\n", "\n\nDrift\n", "\n\nSummary\n"} { + if i := strings.Index(rest, next); i >= 0 && i < end { + end = i + } + } + return text[start : start+len(title)+end] +} + +func assertTextOrder(t *testing.T, text string, parts ...string) { + t.Helper() + prev := -1 + for _, part := range parts { + idx := strings.Index(text, part) + require.Greaterf(t, idx, prev, "%q", part) + prev = idx + } +} + +func assertChartCRDSectionRoles(t *testing.T, p semantic.Plan) { + t.Helper() + var buf bytes.Buffer + rec := &recordingStyler{} + require.NoError(t, view.WriteHuman(&buf, p, view.Options{Styler: rec})) + + inSection := false + sawSection := false + for _, line := range rec.lines { + if line.line == "Chart CRDs" { + inSection = true + sawSection = true + assert.Equal(t, view.RoleTitle, line.role) + continue + } + if !inSection { + continue + } + if line.role == view.RoleTitle { + break + } + assert.Equal(t, view.RolePrimary, line.role, "line %q", line.line) + } + assert.True(t, sawSection) +} + +func assertChartCRDSectionHasNoDiff(t *testing.T, section string) { + t.Helper() + for _, word := range []string{"create", "update", "delete", "apply"} { + assert.NotContains(t, section, word) + } + for line := range strings.SplitSeq(section, "\n") { + trimmed := strings.TrimSpace(line) + for _, marker := range []string{"+ ", "~ ", "- "} { + assert.Falsef(t, strings.HasPrefix(trimmed, marker), "diff marker on %q", line) + } + } +} diff --git a/internal/plan/view/human_style_test.go b/internal/plan/view/human_style_test.go index e2c9980..373dd71 100644 --- a/internal/plan/view/human_style_test.go +++ b/internal/plan/view/human_style_test.go @@ -145,6 +145,9 @@ func TestWriteHuman_StyleRoles(t *testing.T) { }}, }, }) + chartCRDs := planWithChartCRDs(t, header, semantic.HelmUpgrade, []semantic.ChartCRD{ + chartCRD(".deployah/crds/widget.yaml", "widgets.example.com", 0, semantic.ChartCRDUpgrade), + }) tests := []struct { name string golden string @@ -182,6 +185,7 @@ func TestWriteHuman_StyleRoles(t *testing.T) { {name: "drift missing", golden: "style_drift_missing", plan: missing}, {name: "drift modified", golden: "style_drift_modified", plan: modified}, {name: "tasks", golden: "style_tasks", plan: tasks}, + {name: "chart crds", golden: "style_chart_crds", plan: chartCRDs}, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { diff --git a/internal/plan/view/testdata/golden/human_chart_crds_multiple.golden b/internal/plan/view/testdata/golden/human_chart_crds_multiple.golden new file mode 100644 index 0000000..eb44a8c --- /dev/null +++ b/internal/plan/view/testdata/golden/human_chart_crds_multiple.golden @@ -0,0 +1,23 @@ +Plan for project "web" on environment "prod" + +Context: production-eu +Namespace: prod +Release: web +Revision: 12 + +Chart CRDs + + CustomResourceDefinition "zeta.example.com" + source: .deployah/crds/bundle.yaml (index 0) + lifecycle: upgrade (Helm upgrade does not process chart CRDs) + + CustomResourceDefinition "alpha.example.com" + source: .deployah/crds/bundle.yaml (index 1) + lifecycle: upgrade (Helm upgrade does not process chart CRDs) + + CustomResourceDefinition "mu.example.com" + source: .deployah/crds/a.yaml + lifecycle: upgrade (Helm upgrade does not process chart CRDs) + +Summary + Resources: 0 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_chart_crds_process.golden b/internal/plan/view/testdata/golden/human_chart_crds_process.golden new file mode 100644 index 0000000..4c3f838 --- /dev/null +++ b/internal/plan/view/testdata/golden/human_chart_crds_process.golden @@ -0,0 +1,15 @@ +Plan for project "web" on environment "prod" + +Context: production-eu +Namespace: prod +Release: web +Revision: 12 + +Chart CRDs + + CustomResourceDefinition "widgets.example.com" + source: .deployah/crds/widget.yaml + lifecycle: process (Helm install will process this chart CRD) + +Summary + Resources: 0 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_chart_crds_skip.golden b/internal/plan/view/testdata/golden/human_chart_crds_skip.golden new file mode 100644 index 0000000..78ea126 --- /dev/null +++ b/internal/plan/view/testdata/golden/human_chart_crds_skip.golden @@ -0,0 +1,15 @@ +Plan for project "web" on environment "prod" + +Context: production-eu +Namespace: prod +Release: web +Revision: 12 + +Chart CRDs + + CustomResourceDefinition "widgets.example.com" + source: .deployah/crds/widget.yaml + lifecycle: skip (Helm install will skip this chart CRD) + +Summary + Resources: 0 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/human_chart_crds_upgrade.golden b/internal/plan/view/testdata/golden/human_chart_crds_upgrade.golden new file mode 100644 index 0000000..1d3b58f --- /dev/null +++ b/internal/plan/view/testdata/golden/human_chart_crds_upgrade.golden @@ -0,0 +1,15 @@ +Plan for project "web" on environment "prod" + +Context: production-eu +Namespace: prod +Release: web +Revision: 12 + +Chart CRDs + + CustomResourceDefinition "widgets.example.com" + source: .deployah/crds/widget.yaml + lifecycle: upgrade (Helm upgrade does not process chart CRDs) + +Summary + Resources: 0 create, 0 update, 0 delete diff --git a/internal/plan/view/testdata/golden/style_chart_crds.golden b/internal/plan/view/testdata/golden/style_chart_crds.golden new file mode 100644 index 0000000..720826b --- /dev/null +++ b/internal/plan/view/testdata/golden/style_chart_crds.golden @@ -0,0 +1,11 @@ +Title Plan for project "web" on environment "prod" +Primary Context: production-eu +Primary Namespace: prod +Primary Release: web +Primary Revision: 12 +Title Chart CRDs +Primary CustomResourceDefinition "widgets.example.com" +Primary source: .deployah/crds/widget.yaml +Primary lifecycle: upgrade (Helm upgrade does not process chart CRDs) +Title Summary +Primary Resources: 0 create, 0 update, 0 delete From bdf5c6d90537e3a24a40a294259c324a1193c71f Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Wed, 30 Sep 2026 14:27:34 +0200 Subject: [PATCH 2/3] restore WriteHuman doc comment --- internal/plan/view/human.go | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/internal/plan/view/human.go b/internal/plan/view/human.go index 8408798..4d61327 100644 --- a/internal/plan/view/human.go +++ b/internal/plan/view/human.go @@ -36,9 +36,12 @@ const humanTabWidth = 4 const headerLabelWidth = 10 -// WriteHuman writes p as text. It does not change p. -// Resource changes use +, ~, and - markers. The footer counts -// resources and, when present, tasks and drift. +// WriteHuman writes a deterministic YAML-oriented rendering of p. It +// does not mutate p. Resource headings use +, ~, and - markers. +// Drift, when present, is a separate section. Chart CRDs, when +// present, are a separate section after Resources that states Helm's +// chart-CRD lifecycle for each document. The footer is a Summary +// of resource counts and, when tasks or drift exist, those counts. func WriteHuman(w io.Writer, p semantic.Plan, opts Options) error { prepared, err := prepareRender(p, opts) if err != nil { From 7094cf2a5bf3137d2636bb042897a53577e599fe Mon Sep 17 00:00:00 2001 From: Mohammad Abdolirad Date: Wed, 30 Sep 2026 23:29:44 +0200 Subject: [PATCH 3/3] cover chart crd write errors --- internal/plan/view/human_chart_crd_test.go | 87 ++++++++++++++++++++++ 1 file changed, 87 insertions(+) diff --git a/internal/plan/view/human_chart_crd_test.go b/internal/plan/view/human_chart_crd_test.go index de2f601..9f2ec22 100644 --- a/internal/plan/view/human_chart_crd_test.go +++ b/internal/plan/view/human_chart_crd_test.go @@ -16,6 +16,8 @@ package view_test import ( "bytes" + "errors" + "fmt" "strings" "testing" @@ -213,6 +215,91 @@ func TestWriteHuman_ChartCRDSectionUsesNoDiffRole(t *testing.T) { } } +func TestWriteHuman_ChartCRDWriteError(t *testing.T) { + t.Parallel() + p := mustPlanWithHeader(t, humanHeader(), semantic.HelmUpgrade, []semantic.ResourceChange{{ + Resource: ref("ConfigMap", "app"), + Action: semantic.Create, + After: snap(cm("app", "v1")), + }}, nil) + var err error + p, err = semantic.AttachChartCRDs(p, []semantic.ChartCRD{ + chartCRD(".deployah/crds/widget.yaml", "widgets.example.com", 0, semantic.ChartCRDUpgrade), + chartCRD(".deployah/crds/widget.yaml", "gadgets.example.com", 1, semantic.ChartCRDUpgrade), + }) + require.NoError(t, err) + + var plain captureWriter + require.NoError(t, view.WriteHuman(&plain, p, view.Options{})) + from, to := chartCRDWriteSpan(t, plain.chunks) + + tests := make([]struct { + name string + at int + }, 0, to-from+1) + for at := from; at <= to; at++ { + label := strings.TrimSpace(plain.chunks[at]) + if label == "" { + label = "blank" + } + tests = append(tests, struct { + name string + at int + }{name: fmt.Sprintf("%d %s", at, label), at: at}) + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + writeErr := view.WriteHuman(&failAtWriter{at: tt.at}, p, view.Options{}) + require.Error(t, writeErr) + assert.ErrorContains(t, writeErr, "write failed") + }) + } +} + +// chartCRDWriteSpan returns the write indexes for the blank line before +// Chart CRDs through the last lifecycle line. Failing each of those +// writes checks that a section write error reaches WriteHuman. +func chartCRDWriteSpan(t *testing.T, chunks []string) (from, to int) { + t.Helper() + title, last := -1, -1 + for i, chunk := range chunks { + if strings.Contains(chunk, "Chart CRDs") { + title = i + } + if title >= 0 && strings.Contains(chunk, "lifecycle:") { + last = i + } + } + require.GreaterOrEqual(t, title, 1, "Chart CRDs write missing") + require.GreaterOrEqual(t, last, title, "lifecycle write missing") + require.Equal(t, "\n", chunks[title-1], "write before Chart CRDs should be the separating blank line") + return title - 1, last +} + +type captureWriter struct { + chunks []string +} + +func (w *captureWriter) Write(p []byte) (int, error) { + w.chunks = append(w.chunks, string(p)) + return len(p), nil +} + +// failAtWriter accepts writes until index at, then fails. +type failAtWriter struct { + at int + n int +} + +func (w *failAtWriter) Write(p []byte) (int, error) { + if w.n == w.at { + return 0, errors.New("write failed") + } + w.n++ + return len(p), nil +} + func chartCRD(source, name string, index int, lc semantic.ChartCRDLifecycle) semantic.ChartCRD { return semantic.ChartCRD{ Source: source,