From 0312cbe3d572952de99fae33df2ded10b0057197 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:31:57 +0200 Subject: [PATCH 1/7] feat(goldengen): Unit interface and resource/component adapters (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/testing/goldengen/helpers_test.go | 86 +++++++++++++++++++++++++++ pkg/testing/goldengen/unit.go | 86 +++++++++++++++++++++++++++ pkg/testing/goldengen/unit_test.go | 40 +++++++++++++ 3 files changed, 212 insertions(+) create mode 100644 pkg/testing/goldengen/helpers_test.go create mode 100644 pkg/testing/goldengen/unit.go create mode 100644 pkg/testing/goldengen/unit_test.go diff --git a/pkg/testing/goldengen/helpers_test.go b/pkg/testing/goldengen/helpers_test.go new file mode 100644 index 00000000..55872128 --- /dev/null +++ b/pkg/testing/goldengen/helpers_test.go @@ -0,0 +1,86 @@ +package goldengen_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/component" + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" + corev1 "k8s.io/api/core/v1" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/runtime" + clientgoscheme "k8s.io/client-go/kubernetes/scheme" +) + +// staticGate is a feature.Gate with a fixed outcome for tests. A nil *staticGate +// is never used; pass a value to gate a mutation off. +type staticGate struct { + enabled bool + err error +} + +func (g staticGate) Enabled() (bool, error) { return g.enabled, g.err } + +// testScheme returns a scheme with the core and apps Kubernetes types registered, +// sufficient to serialize StatefulSets and ConfigMaps. +func testScheme() *runtime.Scheme { + return clientgoscheme.Scheme +} + +// baseStatefulSet returns a minimal StatefulSet usable as the desired object for a +// statefulset builder in tests. +func baseStatefulSet() *appsv1.StatefulSet { + return &appsv1.StatefulSet{ + TypeMeta: metav1.TypeMeta{APIVersion: "apps/v1", Kind: "StatefulSet"}, + ObjectMeta: metav1.ObjectMeta{ + Name: "db", + Namespace: "default", + }, + Spec: appsv1.StatefulSetSpec{ + Selector: &metav1.LabelSelector{MatchLabels: map[string]string{"app": "db"}}, + Template: corev1.PodTemplateSpec{ + ObjectMeta: metav1.ObjectMeta{Labels: map[string]string{"app": "db"}}, + Spec: corev1.PodSpec{ + Containers: []corev1.Container{{Name: "db", Image: "db:1.0.0"}}, + }, + }, + }, + } +} + +// buildStatefulSetWith builds a statefulset.Resource with the supplied mutations. +func buildStatefulSetWith(t *testing.T, mutations ...statefulset.Mutation) *statefulset.Resource { + t.Helper() + res, err := statefulset.NewBuilder(baseStatefulSet()). + WithMutation(mutations...). + Build() + require.NoError(t, err) + return res +} + +// namedStatefulSet builds a statefulset.Resource with a distinct name so it can +// coexist with others in a single component. +func namedStatefulSet(t *testing.T, name string) *statefulset.Resource { + t.Helper() + sts := baseStatefulSet() + sts.Name = name + res, err := statefulset.NewBuilder(sts).Build() + require.NoError(t, err) + return res +} + +// buildComponentWith builds a component managing the supplied statefulset +// resources. Each resource must have a distinct identity. +func buildComponentWith(t *testing.T, resources ...*statefulset.Resource) *component.Component { + t.Helper() + b := component.NewComponentBuilder(). + WithName("test"). + WithConditionType("TestReady") + for _, r := range resources { + b = b.WithResource(r) + } + c, err := b.Build() + require.NoError(t, err) + return c +} diff --git a/pkg/testing/goldengen/unit.go b/pkg/testing/goldengen/unit.go new file mode 100644 index 00000000..657726c9 --- /dev/null +++ b/pkg/testing/goldengen/unit.go @@ -0,0 +1,86 @@ +// Package goldengen is a test-only helper that sweeps a consumer-supplied version +// universe, classifies versions into behaviorally-distinct gating regimes by +// firing-set, generates the minimal goldens covering them, and asserts per-fixture +// mutation gating. +// +// It is opt-in: a consumer that does not import it pays nothing, and the core +// Build/ApplyIntent path never references it. +package goldengen + +import ( + "fmt" + + "github.com/sourcehawk/operator-component-framework/pkg/component/concepts" + "github.com/sourcehawk/operator-component-framework/pkg/testing/golden" + "k8s.io/apimachinery/pkg/runtime" + "sigs.k8s.io/controller-runtime/pkg/client" +) + +// Unit is a built resource or component the generator can introspect and render. +type Unit interface { + // RegisteredMutations returns the deduplicated Names of every registered mutation. + RegisteredMutations() []string + // FiringSet returns the Names of mutations enabled at the built version. + FiringSet() ([]string, error) + // RenderYAML renders the unit's desired state as canonical golden YAML. + RenderYAML() ([]byte, error) +} + +// ResourcePreviewer is a built single-resource primitive: it can be introspected +// and rendered to one client.Object. Every built-in primitive satisfies it. +type ResourcePreviewer interface { + concepts.MutationInspector + concepts.Previewable +} + +// ComponentPreviewer is a built component: introspectable and rendered to many +// client.Objects. *component.Component satisfies it. +type ComponentPreviewer interface { + RegisteredMutations() []string + FiringSet() ([]string, error) + Preview() ([]client.Object, error) +} + +type resourceUnit struct { + res ResourcePreviewer + scheme *runtime.Scheme +} + +// Resource adapts a built primitive resource to a Unit, serializing through the +// golden package with the given scheme. +func Resource(res ResourcePreviewer, scheme *runtime.Scheme) Unit { + return resourceUnit{res: res, scheme: scheme} +} + +func (u resourceUnit) RegisteredMutations() []string { return u.res.RegisteredMutations() } +func (u resourceUnit) FiringSet() ([]string, error) { return u.res.FiringSet() } + +func (u resourceUnit) RenderYAML() ([]byte, error) { + obj, err := u.res.Preview() + if err != nil { + return nil, fmt.Errorf("preview resource: %w", err) + } + return golden.Serialize(obj, u.scheme) +} + +type componentUnit struct { + comp ComponentPreviewer + scheme *runtime.Scheme +} + +// Component adapts a built component to a Unit, serializing its managed resources +// into a multi-document YAML stream through the golden package. +func Component(comp ComponentPreviewer, scheme *runtime.Scheme) Unit { + return componentUnit{comp: comp, scheme: scheme} +} + +func (u componentUnit) RegisteredMutations() []string { return u.comp.RegisteredMutations() } +func (u componentUnit) FiringSet() ([]string, error) { return u.comp.FiringSet() } + +func (u componentUnit) RenderYAML() ([]byte, error) { + objs, err := u.comp.Preview() + if err != nil { + return nil, fmt.Errorf("preview component: %w", err) + } + return golden.SerializeComponent(objs, u.scheme) +} diff --git a/pkg/testing/goldengen/unit_test.go b/pkg/testing/goldengen/unit_test.go new file mode 100644 index 00000000..3c12cc12 --- /dev/null +++ b/pkg/testing/goldengen/unit_test.go @@ -0,0 +1,40 @@ +package goldengen_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestResourceAdapter(t *testing.T) { + res := buildStatefulSetWith(t, statefulset.Mutation{ + Name: "Always", + Mutate: func(*statefulset.Mutator) error { return nil }, + }) + u := goldengen.Resource(res, testScheme()) + + assert.Equal(t, []string{"Always"}, u.RegisteredMutations()) + + firing, err := u.FiringSet() + require.NoError(t, err) + assert.Equal(t, []string{"Always"}, firing) + + y, err := u.RenderYAML() + require.NoError(t, err) + assert.Contains(t, string(y), "kind: StatefulSet") +} + +func TestComponentAdapter(t *testing.T) { + // Build two distinct resources so the rendered YAML is multi-document. + a := buildStatefulSetWith(t) + b := namedStatefulSet(t, "db-2") + c := buildComponentWith(t, a, b) + + u := goldengen.Component(c, testScheme()) + y, err := u.RenderYAML() + require.NoError(t, err) + assert.Contains(t, string(y), "---") // multi-doc when >1 resource +} From 5e1c3239c5d0b41cab92f6d2b3d770834581e424 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:32:34 +0200 Subject: [PATCH 2/7] feat(goldengen): config types and validation (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/testing/goldengen/config.go | 94 ++++++++++++++++++++++++++++ pkg/testing/goldengen/config_test.go | 75 ++++++++++++++++++++++ 2 files changed, 169 insertions(+) create mode 100644 pkg/testing/goldengen/config.go create mode 100644 pkg/testing/goldengen/config_test.go diff --git a/pkg/testing/goldengen/config.go b/pkg/testing/goldengen/config.go new file mode 100644 index 00000000..fdbfd085 --- /dev/null +++ b/pkg/testing/goldengen/config.go @@ -0,0 +1,94 @@ +package goldengen + +import ( + "fmt" + + "k8s.io/apimachinery/pkg/runtime" +) + +// Expect is an assertion that a named mutation fires (Requires) or does not fire +// (Forbids) over a fixture's version sweep. For is optional; when set it must be a +// concrete version drawn from Config.Versions and pins the assertion to that +// version instead of quantifying over the whole sweep. +type Expect struct { + // Name is the registered mutation Name the expectation applies to. + Name string + // For optionally pins the expectation to a single version from Config.Versions. + For string +} + +// Fixture is one spec and the gating expectations the author asserts for it. +type Fixture[T any] struct { + // Name identifies the fixture and names its golden subdirectory. + Name string + // Spec is the input passed to Config.Build for every version. + Spec T + // Requires lists mutations that must fire, existentially or pinned via For. + Requires []Expect + // Forbids lists mutations that must not fire, existentially or pinned via For. + Forbids []Expect +} + +// Config declares the whole version matrix: the versions to sweep, the fixtures to +// build, the scheme to serialize with, and the Build function that materializes a +// Unit from a fixture spec at a version. +type Config[T any] struct { + // Dir is the root directory for generated goldens and the manifest. + Dir string + // Versions is the version universe to sweep, in supplied order. Representative + // selection uses this order. + Versions []string + // Exclude lists registered mutation Names deliberately left unaccounted by the + // completeness check. It does not affect gating or golden generation. + Exclude []string + // Scheme resolves TypeMeta when serializing rendered objects. + Scheme *runtime.Scheme + // Fixtures are the specs to build and assert across the version sweep. + Fixtures []Fixture[T] + // Build materializes a Unit from a fixture spec at a version. + Build func(version string, spec T) (Unit, error) +} + +// Validate checks the static invariants of the config before any sweep runs: +// non-empty Versions, a non-nil Build, a non-empty Dir, unique non-empty fixture +// names, non-empty expectation names, and every Expect.For being a member of +// Versions. +func (c Config[T]) Validate() error { + if len(c.Versions) == 0 { + return fmt.Errorf("goldengen: Versions must not be empty") + } + if c.Build == nil { + return fmt.Errorf("goldengen: Build must not be nil") + } + if c.Dir == "" { + return fmt.Errorf("goldengen: Dir must not be empty") + } + + known := make(map[string]struct{}, len(c.Versions)) + for _, v := range c.Versions { + known[v] = struct{}{} + } + + seenFixture := make(map[string]struct{}, len(c.Fixtures)) + for _, f := range c.Fixtures { + if f.Name == "" { + return fmt.Errorf("goldengen: fixture name must not be empty") + } + if _, dup := seenFixture[f.Name]; dup { + return fmt.Errorf("goldengen: duplicate fixture name %q", f.Name) + } + seenFixture[f.Name] = struct{}{} + + for _, e := range append(append([]Expect{}, f.Requires...), f.Forbids...) { + if e.Name == "" { + return fmt.Errorf("goldengen: fixture %q has an expectation with an empty Name", f.Name) + } + if e.For != "" { + if _, ok := known[e.For]; !ok { + return fmt.Errorf("goldengen: fixture %q expectation %q has For %q not in Versions", f.Name, e.Name, e.For) + } + } + } + } + return nil +} diff --git a/pkg/testing/goldengen/config_test.go b/pkg/testing/goldengen/config_test.go new file mode 100644 index 00000000..841ff032 --- /dev/null +++ b/pkg/testing/goldengen/config_test.go @@ -0,0 +1,75 @@ +package goldengen_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + corev1 "k8s.io/api/core/v1" +) + +func TestConfigValidate(t *testing.T) { + valid := goldengen.Config[*corev1.ConfigMap]{ + Dir: "td", + Versions: []string{"1.0.0", "2.0.0"}, + Scheme: testScheme(), + Fixtures: []goldengen.Fixture[*corev1.ConfigMap]{{ + Name: "default", Spec: &corev1.ConfigMap{}, + Requires: []goldengen.Expect{{Name: "A"}, {Name: "B", For: "2.0.0"}}, + }}, + Build: func(string, *corev1.ConfigMap) (goldengen.Unit, error) { return nil, nil }, + } + require.NoError(t, valid.Validate()) + + t.Run("for not in versions", func(t *testing.T) { + bad := valid + bad.Fixtures = []goldengen.Fixture[*corev1.ConfigMap]{{ + Name: "default", Spec: &corev1.ConfigMap{}, + Requires: []goldengen.Expect{{Name: "B", For: "9.9.9"}}, + }} + err := bad.Validate() + require.Error(t, err) + assert.Contains(t, err.Error(), "9.9.9") + }) + + t.Run("empty versions", func(t *testing.T) { + bad := valid + bad.Versions = nil + require.Error(t, bad.Validate()) + }) + + t.Run("nil build", func(t *testing.T) { + bad := valid + bad.Build = nil + require.Error(t, bad.Validate()) + }) + + t.Run("empty dir", func(t *testing.T) { + bad := valid + bad.Dir = "" + require.Error(t, bad.Validate()) + }) + + t.Run("duplicate fixture name", func(t *testing.T) { + bad := valid + bad.Fixtures = append([]goldengen.Fixture[*corev1.ConfigMap]{}, valid.Fixtures...) + bad.Fixtures = append(bad.Fixtures, bad.Fixtures[0]) + require.Error(t, bad.Validate()) + }) + + t.Run("empty fixture name", func(t *testing.T) { + bad := valid + bad.Fixtures = []goldengen.Fixture[*corev1.ConfigMap]{{Name: "", Spec: &corev1.ConfigMap{}}} + require.Error(t, bad.Validate()) + }) + + t.Run("empty expectation name", func(t *testing.T) { + bad := valid + bad.Fixtures = []goldengen.Fixture[*corev1.ConfigMap]{{ + Name: "default", Spec: &corev1.ConfigMap{}, + Forbids: []goldengen.Expect{{Name: ""}}, + }} + require.Error(t, bad.Validate()) + }) +} From 2a72671ee3c2fa7016eca0e4bed086f044edb3d4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:33:03 +0200 Subject: [PATCH 3/7] feat(goldengen): firing-set classification into regimes (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/testing/goldengen/classify.go | 41 ++++++++++++++++++++++++ pkg/testing/goldengen/classify_test.go | 44 ++++++++++++++++++++++++++ 2 files changed, 85 insertions(+) create mode 100644 pkg/testing/goldengen/classify.go create mode 100644 pkg/testing/goldengen/classify_test.go diff --git a/pkg/testing/goldengen/classify.go b/pkg/testing/goldengen/classify.go new file mode 100644 index 00000000..40aabf5b --- /dev/null +++ b/pkg/testing/goldengen/classify.go @@ -0,0 +1,41 @@ +package goldengen + +import ( + "sort" + "strings" +) + +// Regime is a group of swept versions sharing an identical firing-set. +type Regime struct { + // Representative is the first version in supplied order within the group. + Representative string + // Versions are all versions in this regime, in supplied order. + Versions []string + // Firing is the shared firing-set, sorted. + Firing []string +} + +// ClassifyRegimes groups versions (in supplied order) by identical firing-set. +// Two versions belong to the same regime when their firing-sets are equal as sets, +// independent of order. Regimes are returned in order of first appearance; the +// representative of each is the first version in supplied order belonging to it. +func ClassifyRegimes(versions []string, firing map[string][]string) []Regime { + index := make(map[string]int) // signature -> regime index + regimes := make([]Regime, 0) + for _, v := range versions { + sorted := append([]string(nil), firing[v]...) + sort.Strings(sorted) + sig := strings.Join(sorted, "\x00") + if i, ok := index[sig]; ok { + regimes[i].Versions = append(regimes[i].Versions, v) + continue + } + index[sig] = len(regimes) + regimes = append(regimes, Regime{ + Representative: v, + Versions: []string{v}, + Firing: sorted, + }) + } + return regimes +} diff --git a/pkg/testing/goldengen/classify_test.go b/pkg/testing/goldengen/classify_test.go new file mode 100644 index 00000000..f9295ee4 --- /dev/null +++ b/pkg/testing/goldengen/classify_test.go @@ -0,0 +1,44 @@ +package goldengen_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestClassifyRegimes(t *testing.T) { + versions := []string{"1.0.0", "1.1.0", "2.0.0"} + firing := map[string][]string{ + "1.0.0": {"A"}, + "1.1.0": {"A"}, // same regime as 1.0.0 + "2.0.0": {"A", "B"}, // new regime + } + regimes := goldengen.ClassifyRegimes(versions, firing) + require.Len(t, regimes, 2) + assert.Equal(t, "1.0.0", regimes[0].Representative) + assert.Equal(t, []string{"1.0.0", "1.1.0"}, regimes[0].Versions) + assert.Equal(t, []string{"A"}, regimes[0].Firing) + assert.Equal(t, "2.0.0", regimes[1].Representative) + assert.Equal(t, []string{"2.0.0"}, regimes[1].Versions) + assert.Equal(t, []string{"A", "B"}, regimes[1].Firing) +} + +func TestClassifyOrderIndependentSignature(t *testing.T) { + // firing-set order must not split a regime. + versions := []string{"1.0.0", "2.0.0"} + firing := map[string][]string{"1.0.0": {"A", "B"}, "2.0.0": {"B", "A"}} + regimes := goldengen.ClassifyRegimes(versions, firing) + require.Len(t, regimes, 1) + assert.Equal(t, []string{"A", "B"}, regimes[0].Firing) +} + +func TestClassifyEmptyFiringSet(t *testing.T) { + versions := []string{"1.0.0", "2.0.0"} + firing := map[string][]string{"1.0.0": {}, "2.0.0": nil} + regimes := goldengen.ClassifyRegimes(versions, firing) + require.Len(t, regimes, 1) + assert.Equal(t, "1.0.0", regimes[0].Representative) + assert.Empty(t, regimes[0].Firing) +} From d1b7447e9677825c192a2ef45664c8caaf037489 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:33:33 +0200 Subject: [PATCH 4/7] feat(goldengen): Requires/Forbids gating assertions (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/testing/goldengen/gating.go | 58 ++++++++++++++++++++++++++++ pkg/testing/goldengen/gating_test.go | 53 +++++++++++++++++++++++++ 2 files changed, 111 insertions(+) create mode 100644 pkg/testing/goldengen/gating.go create mode 100644 pkg/testing/goldengen/gating_test.go diff --git a/pkg/testing/goldengen/gating.go b/pkg/testing/goldengen/gating.go new file mode 100644 index 00000000..2d8a94ec --- /dev/null +++ b/pkg/testing/goldengen/gating.go @@ -0,0 +1,58 @@ +package goldengen + +import "fmt" + +// firesAt reports whether name is in the firing-set at version v. +func firesAt(firing map[string][]string, v, name string) bool { + for _, n := range firing[v] { + if n == name { + return true + } + } + return false +} + +// firesSomewhere reports whether name is in the firing-set at any swept version. +func firesSomewhere(firing map[string][]string, versions []string, name string) bool { + for _, v := range versions { + if firesAt(firing, v, name) { + return true + } + } + return false +} + +// CheckGating verifies a fixture's Requires/Forbids expectations against its +// per-version firing-sets. The lattice is: +// +// - Requires with empty For: the name fires at some swept version. +// - Requires with For=v: the name fires at v. +// - Forbids with empty For: the name fires at no swept version. +// - Forbids with For=v: the name does not fire at v. +// +// It returns a descriptive error on the first violation. +func CheckGating[T any](f Fixture[T], versions []string, firing map[string][]string) error { + for _, e := range f.Requires { + if e.For == "" { + if !firesSomewhere(firing, versions, e.Name) { + return fmt.Errorf("fixture %q: required mutation %q never fires across the version sweep", f.Name, e.Name) + } + continue + } + if !firesAt(firing, e.For, e.Name) { + return fmt.Errorf("fixture %q: required mutation %q does not fire at %s", f.Name, e.Name, e.For) + } + } + for _, e := range f.Forbids { + if e.For == "" { + if firesSomewhere(firing, versions, e.Name) { + return fmt.Errorf("fixture %q: forbidden mutation %q fires somewhere across the version sweep", f.Name, e.Name) + } + continue + } + if firesAt(firing, e.For, e.Name) { + return fmt.Errorf("fixture %q: forbidden mutation %q fires at %s", f.Name, e.Name, e.For) + } + } + return nil +} diff --git a/pkg/testing/goldengen/gating_test.go b/pkg/testing/goldengen/gating_test.go new file mode 100644 index 00000000..85578692 --- /dev/null +++ b/pkg/testing/goldengen/gating_test.go @@ -0,0 +1,53 @@ +package goldengen_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCheckGating(t *testing.T) { + firing := map[string][]string{ // version -> firing-set + "8.8.0": {"Always", "Pre89"}, + "8.9.0": {"Always", "Unified89"}, + } + f := goldengen.Fixture[int]{ + Name: "default", + Requires: []goldengen.Expect{ + {Name: "Always"}, // existential: fires somewhere + {Name: "Unified89", For: "8.9.0"}, // pinned + {Name: "Pre89", For: "8.8.0"}, + }, + Forbids: []goldengen.Expect{ + {Name: "Unified89", For: "8.8.0"}, // not before boundary + {Name: "Pre89", For: "8.9.0"}, // not after boundary + }, + } + require.NoError(t, goldengen.CheckGating(f, []string{"8.8.0", "8.9.0"}, firing)) +} + +func TestCheckGatingFailures(t *testing.T) { + firing := map[string][]string{"8.9.0": {"Always"}} + versions := []string{"8.9.0"} + + t.Run("required existential missing", func(t *testing.T) { + f := goldengen.Fixture[int]{Name: "f", Requires: []goldengen.Expect{{Name: "Ghost"}}} + err := goldengen.CheckGating(f, versions, firing) + require.Error(t, err) + assert.Contains(t, err.Error(), "Ghost") + }) + t.Run("required pinned missing", func(t *testing.T) { + f := goldengen.Fixture[int]{Name: "f", Requires: []goldengen.Expect{{Name: "Ghost", For: "8.9.0"}}} + require.Error(t, goldengen.CheckGating(f, versions, firing)) + }) + t.Run("forbidden existential fires", func(t *testing.T) { + f := goldengen.Fixture[int]{Name: "f", Forbids: []goldengen.Expect{{Name: "Always"}}} + require.Error(t, goldengen.CheckGating(f, versions, firing)) + }) + t.Run("forbidden pinned fires", func(t *testing.T) { + f := goldengen.Fixture[int]{Name: "f", Forbids: []goldengen.Expect{{Name: "Always", For: "8.9.0"}}} + require.Error(t, goldengen.CheckGating(f, versions, firing)) + }) +} From 5dcfe75d051d1997fb011796f4a48f1f8d7c836a Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:33:56 +0200 Subject: [PATCH 5/7] feat(goldengen): coverage manifest type (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/testing/goldengen/manifest.go | 28 ++++++++++++++++++++++++++ pkg/testing/goldengen/manifest_test.go | 26 ++++++++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 pkg/testing/goldengen/manifest.go create mode 100644 pkg/testing/goldengen/manifest_test.go diff --git a/pkg/testing/goldengen/manifest.go b/pkg/testing/goldengen/manifest.go new file mode 100644 index 00000000..36e78641 --- /dev/null +++ b/pkg/testing/goldengen/manifest.go @@ -0,0 +1,28 @@ +package goldengen + +import "sigs.k8s.io/yaml" + +// RegimeManifest records one behaviorally-distinct regime of a fixture: its +// representative version, the versions it covers, and the shared firing-set. +type RegimeManifest struct { + Representative string `json:"representative"` + Versions []string `json:"versions"` + Firing []string `json:"firing"` +} + +// FixtureManifest records all regimes derived for one fixture. +type FixtureManifest struct { + Name string `json:"name"` + Regimes []RegimeManifest `json:"regimes"` +} + +// Manifest is the reviewable coverage map: per fixture, the distinct gating +// regimes with their representative version and firing-set. +type Manifest struct { + Fixtures []FixtureManifest `json:"fixtures"` +} + +// YAML renders the manifest as deterministic YAML. +func (m Manifest) YAML() ([]byte, error) { + return yaml.Marshal(m) +} diff --git a/pkg/testing/goldengen/manifest_test.go b/pkg/testing/goldengen/manifest_test.go new file mode 100644 index 00000000..d4b036d1 --- /dev/null +++ b/pkg/testing/goldengen/manifest_test.go @@ -0,0 +1,26 @@ +package goldengen_test + +import ( + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestManifestYAML(t *testing.T) { + m := goldengen.Manifest{ + Fixtures: []goldengen.FixtureManifest{{ + Name: "default", + Regimes: []goldengen.RegimeManifest{ + {Representative: "8.8.0", Versions: []string{"8.8.0"}, Firing: []string{"Always", "Pre89"}}, + {Representative: "8.9.0", Versions: []string{"8.9.0"}, Firing: []string{"Always", "Unified89"}}, + }, + }}, + } + out, err := m.YAML() + require.NoError(t, err) + assert.Contains(t, string(out), "name: default") + assert.Contains(t, string(out), "representative: 8.8.0") + assert.Contains(t, string(out), "Unified89") +} From a229a4df9bef89a0dd99df9e6c2f320fdb41d100 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:35:46 +0200 Subject: [PATCH 6/7] feat(goldengen): Generator Run with sweep, gating, goldens, manifest (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/testing/goldengen/generator.go | 130 ++++++++++++++++++++++++ pkg/testing/goldengen/generator_test.go | 82 +++++++++++++++ 2 files changed, 212 insertions(+) create mode 100644 pkg/testing/goldengen/generator.go create mode 100644 pkg/testing/goldengen/generator_test.go diff --git a/pkg/testing/goldengen/generator.go b/pkg/testing/goldengen/generator.go new file mode 100644 index 00000000..228275f5 --- /dev/null +++ b/pkg/testing/goldengen/generator.go @@ -0,0 +1,130 @@ +package goldengen + +import ( + "fmt" + "os" + "path/filepath" + "testing" +) + +// Generator runs a version matrix: per-fixture gating assertions, minimal goldens, +// and a coverage manifest, all derived from one Config. +type Generator[T any] struct { + cfg Config[T] + update bool +} + +// New creates a Generator from a Config. +func New[T any](cfg Config[T]) *Generator[T] { + return &Generator[T]{cfg: cfg} +} + +// WithUpdate controls whether Run overwrites goldens and the manifest. Wire it to +// a -update test flag: gen.WithUpdate(*update). +func (g *Generator[T]) WithUpdate(enabled bool) *Generator[T] { + g.update = enabled + return g +} + +// fixtureSweep holds, per fixture, the per-version firing-sets and the built units +// used to render representatives. +type fixtureSweep struct { + firing map[string][]string // version -> firing-set + units map[string]Unit // version -> built unit +} + +// sweepFixture builds the fixture at every version, collecting its per-version +// firing-set and the built unit. +func (g *Generator[T]) sweepFixture(f Fixture[T]) (fixtureSweep, error) { + sw := fixtureSweep{firing: map[string][]string{}, units: map[string]Unit{}} + for _, v := range g.cfg.Versions { + unit, err := g.cfg.Build(v, f.Spec) + if err != nil { + return sw, fmt.Errorf("build fixture %q at %s: %w", f.Name, v, err) + } + firing, err := unit.FiringSet() + if err != nil { + return sw, fmt.Errorf("firing set for fixture %q at %s: %w", f.Name, v, err) + } + sw.firing[v] = firing + sw.units[v] = unit + } + return sw, nil +} + +// Run validates the config, asserts per-fixture gating, and writes (or compares) +// one golden per regime at //.yaml plus the coverage +// manifest at /manifest.yaml. It honors WithUpdate. +func (g *Generator[T]) Run(t *testing.T) { + t.Helper() + if err := g.cfg.Validate(); err != nil { + t.Fatalf("goldengen: invalid config: %v", err) + } + + manifest := Manifest{} + for _, f := range g.cfg.Fixtures { + f := f + t.Run(f.Name, func(t *testing.T) { + sw, err := g.sweepFixture(f) + if err != nil { + t.Fatalf("%v", err) + } + if err := CheckGating(f, g.cfg.Versions, sw.firing); err != nil { + t.Errorf("%v", err) + } + + regimes := ClassifyRegimes(g.cfg.Versions, sw.firing) + fm := FixtureManifest{Name: f.Name} + for _, r := range regimes { + unit := sw.units[r.Representative] + y, err := unit.RenderYAML() + if err != nil { + t.Fatalf("render fixture %q regime %s: %v", f.Name, r.Representative, err) + } + path := filepath.Join(g.cfg.Dir, f.Name, r.Representative+".yaml") + if err := writeOrCompareGolden(path, y, g.update); err != nil { + t.Errorf("%v", err) + } + fm.Regimes = append(fm.Regimes, RegimeManifest{ + Representative: r.Representative, + Versions: r.Versions, + Firing: r.Firing, + }) + } + manifest.Fixtures = append(manifest.Fixtures, fm) + }) + } + + manifestYAML, err := manifest.YAML() + if err != nil { + t.Fatalf("goldengen: marshal manifest: %v", err) + } + if err := writeOrCompareGolden(filepath.Join(g.cfg.Dir, "manifest.yaml"), manifestYAML, g.update); err != nil { + t.Errorf("%v", err) + } +} + +// writeOrCompareGolden writes the bytes when update is set, otherwise compares +// against the file at path, returning a descriptive error on mismatch. +func writeOrCompareGolden(path string, actual []byte, update bool) error { + if update { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return fmt.Errorf("create golden dir: %w", err) + } + if err := os.WriteFile(path, actual, 0o644); err != nil { + return fmt.Errorf("write golden %s: %w", path, err) + } + return nil + } + expected, err := os.ReadFile(path) + if os.IsNotExist(err) { + return fmt.Errorf("golden %s does not exist; run with -update", path) + } + if err != nil { + return fmt.Errorf("read golden %s: %w", path, err) + } + if string(expected) != string(actual) { + return fmt.Errorf("golden mismatch at %s", path) + } + return nil +} diff --git a/pkg/testing/goldengen/generator_test.go b/pkg/testing/goldengen/generator_test.go new file mode 100644 index 00000000..e68ab9d1 --- /dev/null +++ b/pkg/testing/goldengen/generator_test.go @@ -0,0 +1,82 @@ +package goldengen_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/sourcehawk/operator-component-framework/pkg/primitives/statefulset" + "github.com/sourcehawk/operator-component-framework/pkg/testing/goldengen" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + appsv1 "k8s.io/api/apps/v1" +) + +// configForStatefulSet returns a Config whose two versions produce two regimes: +// "v2only" fires only at 2.0.0, so 1.0.0 and 2.0.0 land in distinct regimes. +func configForStatefulSet(dir string) goldengen.Config[*appsv1.StatefulSet] { + return goldengen.Config[*appsv1.StatefulSet]{ + Dir: dir, + Versions: []string{"1.0.0", "2.0.0"}, + Scheme: testScheme(), + Fixtures: []goldengen.Fixture[*appsv1.StatefulSet]{{ + Name: "default", + Spec: baseStatefulSet(), + Requires: []goldengen.Expect{ + {Name: "Always"}, + {Name: "V2Only", For: "2.0.0"}, + }, + Forbids: []goldengen.Expect{ + {Name: "V2Only", For: "1.0.0"}, + }, + }}, + Build: func(v string, spec *appsv1.StatefulSet) (goldengen.Unit, error) { + res, err := statefulset.NewBuilder(spec.DeepCopy()). + WithMutation( + statefulset.Mutation{ + Name: "Always", + Mutate: func(*statefulset.Mutator) error { return nil }, + }, + statefulset.Mutation{ + Name: "V2Only", + Feature: staticGate{enabled: v == "2.0.0"}, + Mutate: func(*statefulset.Mutator) error { return nil }, + }, + ). + Build() + if err != nil { + return nil, err + } + return goldengen.Resource(res, testScheme()), nil + }, + } +} + +func TestGeneratorRunWritesGoldensAndManifest(t *testing.T) { + dir := t.TempDir() + gen := goldengen.New(configForStatefulSet(dir)) + + // First run with update writes goldens + manifest. + gen.WithUpdate(true).Run(t) + + assert.FileExists(t, filepath.Join(dir, "default", "1.0.0.yaml")) + assert.FileExists(t, filepath.Join(dir, "default", "2.0.0.yaml")) + assert.FileExists(t, filepath.Join(dir, "manifest.yaml")) + + // Exactly one golden per regime: 1.0.0 and 2.0.0 are distinct regimes, so no + // third golden exists for the swept version that shares 1.0.0's firing-set. + gold200, err := os.ReadFile(filepath.Join(dir, "default", "2.0.0.yaml")) + require.NoError(t, err) + assert.Contains(t, string(gold200), "kind: StatefulSet") + + // The manifest records both regimes for the fixture. + manifest, err := os.ReadFile(filepath.Join(dir, "manifest.yaml")) + require.NoError(t, err) + assert.Contains(t, string(manifest), "name: default") + assert.Contains(t, string(manifest), "representative: 1.0.0") + assert.Contains(t, string(manifest), "representative: 2.0.0") + assert.Contains(t, string(manifest), "V2Only") + + // Second run without update compares clean. + gen.WithUpdate(false).Run(t) +} From 5f6207f3fdfc94aba958fb48ff4b3212045c72a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=C3=86gir=20M=C3=A1ni=20Hauksson?= <54936225+sourcehawk@users.noreply.github.com> Date: Mon, 1 Jun 2026 03:37:13 +0200 Subject: [PATCH 7/7] style(goldengen): satisfy staticcheck and gofmt (#133) Co-Authored-By: Claude Opus 4.8 (1M context) --- pkg/testing/goldengen/generator.go | 6 +----- pkg/testing/goldengen/unit.go | 4 ++-- 2 files changed, 3 insertions(+), 7 deletions(-) diff --git a/pkg/testing/goldengen/generator.go b/pkg/testing/goldengen/generator.go index 228275f5..2792be3e 100644 --- a/pkg/testing/goldengen/generator.go +++ b/pkg/testing/goldengen/generator.go @@ -85,11 +85,7 @@ func (g *Generator[T]) Run(t *testing.T) { if err := writeOrCompareGolden(path, y, g.update); err != nil { t.Errorf("%v", err) } - fm.Regimes = append(fm.Regimes, RegimeManifest{ - Representative: r.Representative, - Versions: r.Versions, - Firing: r.Firing, - }) + fm.Regimes = append(fm.Regimes, RegimeManifest(r)) } manifest.Fixtures = append(manifest.Fixtures, fm) }) diff --git a/pkg/testing/goldengen/unit.go b/pkg/testing/goldengen/unit.go index 657726c9..946e355c 100644 --- a/pkg/testing/goldengen/unit.go +++ b/pkg/testing/goldengen/unit.go @@ -53,7 +53,7 @@ func Resource(res ResourcePreviewer, scheme *runtime.Scheme) Unit { } func (u resourceUnit) RegisteredMutations() []string { return u.res.RegisteredMutations() } -func (u resourceUnit) FiringSet() ([]string, error) { return u.res.FiringSet() } +func (u resourceUnit) FiringSet() ([]string, error) { return u.res.FiringSet() } func (u resourceUnit) RenderYAML() ([]byte, error) { obj, err := u.res.Preview() @@ -75,7 +75,7 @@ func Component(comp ComponentPreviewer, scheme *runtime.Scheme) Unit { } func (u componentUnit) RegisteredMutations() []string { return u.comp.RegisteredMutations() } -func (u componentUnit) FiringSet() ([]string, error) { return u.comp.FiringSet() } +func (u componentUnit) FiringSet() ([]string, error) { return u.comp.FiringSet() } func (u componentUnit) RenderYAML() ([]byte, error) { objs, err := u.comp.Preview()