diff --git a/.golangci.yml b/.golangci.yml index e479680d7..1557c258c 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -30,6 +30,8 @@ linters: max-complexity: 22 gocyclo: min-complexity: 22 + gocognit: + min-complexity: 35 exhaustive: default-signifies-exhaustive: true default-case-required: true diff --git a/go.work b/go.work index 0d65e3bac..096ac4347 100644 --- a/go.work +++ b/go.work @@ -2,6 +2,7 @@ use ( . ./codegen ./enable/yaml + ./internal/testintegration ) go 1.24.0 diff --git a/internal/assertions/doc.go b/internal/assertions/doc.go index 7ba6e3031..dd066c4b5 100644 --- a/internal/assertions/doc.go +++ b/internal/assertions/doc.go @@ -8,6 +8,8 @@ // whereas the publicly exposed API (in packages assert and require) // is generated. // +// For convenience, assertion functions are classified by domain. +// The entire API can be searched by domain at https://go-openapi.github.io/testify/api. // # Domains // // - boolean: asserting boolean values diff --git a/internal/spew/dump.go b/internal/spew/dump.go index d2402216a..bff4a7155 100644 --- a/internal/spew/dump.go +++ b/internal/spew/dump.go @@ -97,11 +97,12 @@ func (d *dumpState) dumpPtr(v reflect.Value) { cycleFound := false indirects := 0 ve := v - for ve.Kind() == reflect.Ptr { + for ve.Kind() == reflect.Pointer { if ve.IsNil() { nilFound = true break } + indirects++ addr := ve.Pointer() pointerChain = append(pointerChain, addr) @@ -114,11 +115,27 @@ func (d *dumpState) dumpPtr(v reflect.Value) { ve = ve.Elem() if ve.Kind() == reflect.Interface { - if ve.IsNil() { + if ve.IsNil() { // interface with nil value nilFound = true break } ve = ve.Elem() + if ve.Kind() == reflect.Pointer { + if ve.IsNil() { + nilFound = true + break + } + + // case of interface containing a pointer that cycles to the same depth level. + // If we have a cycle at the same level, we should break the loop now. + addr = ve.Pointer() + if pd, ok := d.pointers[addr]; ok && pd <= d.depth { + cycleFound = true + indirects-- + break + } + d.pointers[addr] = d.depth + } } } @@ -156,6 +173,64 @@ func (d *dumpState) dumpPtr(v reflect.Value) { d.w.Write(closeParenBytes) } +func (d *dumpState) dumpMap(v reflect.Value) { + // Remove pointers at or below the current depth from map used to detect + // circular refs. + for k, depth := range d.pointers { + if depth >= d.depth { + delete(d.pointers, k) + } + } + + // Keep list of all dereferenced pointers to show later. + cycleFound := false + + // nil maps should be indicated as different than empty maps + if v.IsNil() { + _, _ = d.w.Write(nilAngleBytes) + return + } + + // maps like pointers may present circular references + addr := v.Pointer() + if pd, ok := d.pointers[addr]; ok && pd <= d.depth { + cycleFound = true + } + d.pointers[addr] = d.depth + + _, _ = d.w.Write(openBraceNewlineBytes) + d.depth++ + + switch { + case d.cs.MaxDepth != 0 && d.depth > d.cs.MaxDepth: + d.indent() + _, _ = d.w.Write(maxNewlineBytes) + case cycleFound: + _, _ = d.w.Write(circularBytes) + default: + numEntries := v.Len() + keys := v.MapKeys() + if d.cs.SortKeys { + sortValues(keys, d.cs) + } + for i, key := range keys { + d.dump(d.unpackValue(key)) + _, _ = d.w.Write(colonSpaceBytes) + d.ignoreNextIndent = true + d.dump(d.unpackValue(v.MapIndex(key))) + if i < (numEntries - 1) { + _, _ = d.w.Write(commaNewlineBytes) + } else { + _, _ = d.w.Write(newlineBytes) + } + } + } + + d.depth-- + d.indent() + _, _ = d.w.Write(closeBraceBytes) +} + // dumpSlice handles formatting of arrays and slices. Byte (uint8 under // reflection) arrays and slices are dumped in hexdump -C fashion. func (d *dumpState) dumpSlice(v reflect.Value) { @@ -257,7 +332,7 @@ func (d *dumpState) dump(v reflect.Value) { } // Handle pointers specially. - if kind == reflect.Ptr { + if kind == reflect.Pointer { d.indent() d.dumpPtr(v) return @@ -367,43 +442,12 @@ func (d *dumpState) dump(v reflect.Value) { d.w.Write(nilAngleBytes) } - case reflect.Ptr: + case reflect.Pointer: // Do nothing. We should never get here since pointers have already // been handled above. case reflect.Map: - // nil maps should be indicated as different than empty maps - if v.IsNil() { - d.w.Write(nilAngleBytes) - break - } - - d.w.Write(openBraceNewlineBytes) - d.depth++ - if (d.cs.MaxDepth != 0) && (d.depth > d.cs.MaxDepth) { - d.indent() - d.w.Write(maxNewlineBytes) - } else { - numEntries := v.Len() - keys := v.MapKeys() - if d.cs.SortKeys { - sortValues(keys, d.cs) - } - for i, key := range keys { - d.dump(d.unpackValue(key)) - d.w.Write(colonSpaceBytes) - d.ignoreNextIndent = true - d.dump(d.unpackValue(v.MapIndex(key))) - if i < (numEntries - 1) { - d.w.Write(commaNewlineBytes) - } else { - d.w.Write(newlineBytes) - } - } - } - d.depth-- - d.indent() - d.w.Write(closeBraceBytes) + d.dumpMap(v) case reflect.Struct: d.w.Write(openBraceNewlineBytes) diff --git a/internal/spew/edgecases_test.go b/internal/spew/edgecases_test.go new file mode 100644 index 000000000..380ed38b6 --- /dev/null +++ b/internal/spew/edgecases_test.go @@ -0,0 +1,98 @@ +package spew + +import ( + "io" + "iter" + "slices" + "sync" + "testing" +) + +func TestEdgeCases(t *testing.T) { + t.Parallel() + cfg := Config + output := io.Discard + + for tt := range edgeCases() { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + + defer func() { + if r := recover(); r != nil { + t.Errorf("fdump panicked: %v\nWith value type: %T\nValue: %#v", r, tt.value, tt.value) + + return + } + }() + + fdump(&cfg, output, tt.value) + }) + } +} + +type edgeCase struct { + name string + value any +} + +func edgeCases() iter.Seq[edgeCase] { + type withLock struct { + sync.Mutex + } + type withLockPtr struct { + *sync.Mutex + } + var iface any = "x" + str := "y" + var ifaceToPtr any = &str + var ifaceToNilPtr any = (*string)(nil) + ifacePtr := &iface + ifacePtrPtr := &ifaceToPtr + var ifaceCircular any + ifaceCircular = &ifaceCircular + + // Map with circular value (map contains itself) + m := map[string]any{ + "key1": 1, + "key2": "val", + } + m["circular"] = m // Map contains itself as a value + + return slices.Values([]edgeCase{ + { + name: "self-referencing map", + value: m, + }, + { + name: "sync.Mutex", + value: withLock{}, + }, + { + name: "*sync.Mutex", + value: withLockPtr{ + Mutex: &sync.Mutex{}, + }, + }, + { + name: "pointer to interface", + value: ifacePtr, + }, + { + name: "pointer to interface to pointer", + value: ifacePtrPtr, + }, + { + name: "pointer to interface to pointer", + value: &ifaceToNilPtr, + }, + { + name: "pointer to pointer to interface to pointer", + value: &ifaceToPtr, + }, + { + // case that used to hang + name: "pointer to interface with circular reference", + value: &ifaceCircular, + }, + }) +} diff --git a/internal/testintegration/README.md b/internal/testintegration/README.md new file mode 100644 index 000000000..6f18821a8 --- /dev/null +++ b/internal/testintegration/README.md @@ -0,0 +1,286 @@ +# Integration Testing Module + +This is a separate Go module dedicated to property-based and fuzz testing of internal packages. + +## Purpose + +This module uses external testing libraries (like `rapid`) to perform comprehensive black-box testing without polluting the main module's dependency tree. This maintains our zero-dependency goal while enabling powerful testing techniques. + +## Structure + +``` +internal/testintegration/ +├── go.mod # Separate module with test dependencies +├── go.sum # Dependency checksums +├── README.md # This file +└── spew/ + ├── doc.go # Package documentation + ├── generator.go # Reflection-based random value generator + ├── generator_test.go # Generator validation tests + ├── edgecases.go # Hand-crafted edge case generators + ├── edgecases_test.go # Edge case focused tests + ├── dump_test.go # Main property-based tests (rapid.Check) + ├── dump_fuzz_test.go # Go native fuzz tests + └── testdata/ # Fuzz corpus and rapid failure files +``` + +## Dependencies + +- **pgregory.net/rapid** - Property-based testing library with fuzzing capabilities + +## Bugs Fixed in spew + +This test suite helped identify and validate fixes for the following issues: + +### Circular Reference Hangs + +1. **Pointer wrapped as interface** - `self = &self` pattern caused infinite loop +2. **Map containing itself** - `m["key"] = m` pattern caused infinite loop + +Both are now correctly handled with `` markers. + +### Historical Issues Addressed + +- **#1828** - Panic on structs with unexported fields +- **#1829** - Time rendering in diffs + +## Generator Architecture + +### Two-Layer Generator System + +The test suite uses two complementary generators: + +#### 1. Reflection-Based Generator (`generator.go`) + +Generates arbitrary Go values using reflection: + +- All primitive types (int, string, bool, float, complex, etc.) +- Container types (slice, map, array, struct) +- Pointers with cyclical reference tracking +- Channels and functions +- Special types (sync.Mutex, atomic.Value, etc.) + +**Limitations:** +- No generic types +- No type declarations (all types are anonymous) +- No unexported struct fields (reflect limitation) +- No embedded fields with methods + +**Example** + +Sample random structures generated. + +```go +(struct { Ƃ꘨㝛٠ []struct { string; *chan sync.RWMutex; ℙB୧ float64; complex64 }; Ḥ5᠐ string; E߉🯹J sync.Mutex; Ⱆ᮱G𗚟 interface {}; func(int, string) *string }) { + Ƃ꘨㝛٠: ([]struct { string; *chan sync.RWMutex; ℙB୧ float64; complex64 }) { + }, + Ḥ5᠐: (string) (len=4) "~௹", + E߉🯹J: (sync.Mutex) { + _: (sync.noCopy) { + }, + mu: (sync.Mutex) { + state: (int32) 0, + sema: (uint32) 0 + } + }, + Ⱆ᮱G𗚟: (interface {}) , + P𤚁: (func(int, string) *string) 0x689760 + } +``` + +```go +(map[[4]*complex128][]*complex128) (len=1) { + ([4]*complex128) (len=4 cap=4) { + (*complex128)(0xc005201cd0)((-2.8853058180580424e-129-1.6336761511385133e-16i)), + (*complex128)(0xc005201cd0)((-2.8853058180580424e-129-1.6336761511385133e-16i)), + (*complex128)(0xc005201cd0)((-2.8853058180580424e-129-1.6336761511385133e-16i)), + (*complex128)(0xc005201cd0)((-2.8853058180580424e-129-1.6336761511385133e-16i)) + }: ([]*complex128) (len=1 cap=1) { + (*complex128)(0xc005201cd0)((-2.8853058180580424e-129-1.6336761511385133e-16i)) + } + } +``` + +#### 2. Edge Case Generator (`edgecases.go`) + +Hand-crafted generators for known problematic patterns: + +- Structs with unexported fields +- Circular references (various patterns) +- Nil interfaces with typed nil values +- Deep nesting +- Maps with interface keys +- Pointer chains +- time.Time values +- Multi-level pointer indirection +- Pointer-to-interface chains + +### Generator Options + +The generators support options to customize behavior: + +```go +// Skip circular map cases (needed for fuzz testing) +Generator(WithSkipCircularMap()) +``` + +## Running Tests + +```bash +cd internal/testintegration + +# Run all tests (100,000 rapid checks by default) +go test ./... + +# Run with verbose output +go test -v ./spew + +# Run specific test +go test -v ./spew -run TestSdump + +# Run fuzz tests +go test -fuzz=FuzzSdump ./spew -fuzztime=30s + +# Run with custom rapid iterations +go test ./spew -rapid.checks=1000000 +``` + +## Test Types + +### Property-Based Tests (`dump_test.go`) + +Uses `rapid.Check` with 100,000 iterations to verify `spew.Sdump` never panics or hangs: + +```go +rapid.Check(t, NoPanicProp(t.Context(), Generator())) +``` + +The `NoPanicProp` function: +- Draws a random value from the generator +- Runs `spew.Sdump` with a 1-second timeout +- Fails on panic or timeout (hang detection) + +### Edge Case Tests (`edgecases_test.go`) + +Focused testing of known problematic patterns using the edge case generator. + +### Fuzz Tests (`dump_fuzz_test.go`) + +Go native fuzzing integrated with rapid: + +```go +func FuzzSdump(f *testing.F) { + prop := NoPanicProp(f.Context(), Generator(WithSkipCircularMap())) + f.Fuzz(rapid.MakeFuzz(prop)) +} +``` + +## Known Limitations and Workarounds + +### Circular Maps in Fuzz Tests + +**Issue:** Go's standard library `fmt` package cannot handle circular references with maps. When rapid's fuzz integration logs drawn values using `fmt`, it causes a stack overflow before `spew.Sdump` is even called. + +**Workaround:** Fuzz tests use `WithSkipCircularMap()` to exclude the self-referencing map case. This case is still covered by `rapid.Check` tests which don't trigger the logging issue. + +**Root cause:** This is a limitation in Go's `fmt` package, not in rapid or spew. + +```go +// This pattern causes fmt to stack overflow: +m := map[string]any{"key": "value"} +m["self"] = m +fmt.Printf("%v", m) // stack overflow +``` + +### Generator Limitations + +Values that cannot be generated via reflection: +- Structs with unexported fields (use edge case generator) +- Named types with methods on embedded fields +- Generic types +- C types for CGO + +## Adding New Tests + +To add fuzz tests for other internal packages: + +1. Create a new subdirectory under `internal/testintegration/` +2. Add test files with generators specific to that package +3. Use `NoPanicProp` pattern for hang detection + +Example structure: + +```go +package mypackage + +import ( + "context" + "testing" + "pgregory.net/rapid" +) + +func TestMyFunction(t *testing.T) { + rapid.Check(t, func(rt *rapid.T) { + input := myGenerator().Draw(rt, "input") + + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + + done := make(chan struct{}) + go func() { + defer close(done) + _ = mypackage.MyFunction(input) + }() + + select { + case <-done: + // success + case <-ctx.Done(): + rt.Fatal("function timed out") + } + }) +} +``` + +## Why a Separate Module? + +This approach: + +- **Isolates test dependencies** - rapid is only needed for integration testing +- **Maintains zero dependencies** - Main module stays clean +- **Enables powerful testing** - Use best-in-class testing tools +- **Clear separation** - Test infrastructure vs production code +- **Flexible versioning** - Can update test tools independently + +## Rapid Quick Reference + +```go +// Basic generators +rapid.Int() // any int +rapid.IntRange(0, 100) // 0 to 100 +rapid.String() // any string +rapid.Bool() // true or false +rapid.SliceOf(rapid.Int()) // []int +rapid.MapOf(rapid.String(), rapid.Int()) // map[string]int + +// Combinators +rapid.OneOf(gen1, gen2, gen3) // Choose one generator +rapid.Just(value) // Constant value +rapid.Deferred(func() *Generator) // Recursive definitions + +// Drawing values +value := rapid.Int().Draw(t, "label") + +// Custom generators +rapid.Custom(func(t *rapid.T) MyType { + return MyType{ + Field: rapid.String().Draw(t, "field"), + } +}) +``` + +## Further Reading + +- [rapid documentation](https://pkg.go.dev/pgregory.net/rapid) +- [Property-based testing introduction](https://hypothesis.works/articles/what-is-property-based-testing/) +- [Fuzzing in Go](https://go.dev/doc/security/fuzz/) diff --git a/internal/testintegration/go.mod b/internal/testintegration/go.mod new file mode 100644 index 000000000..f40bc06f4 --- /dev/null +++ b/internal/testintegration/go.mod @@ -0,0 +1,10 @@ +module github.com/go-openapi/testify/v2/internal/testintegration + +go 1.24.0 + +require ( + github.com/go-openapi/testify/v2 v2.1.8 + pgregory.net/rapid v1.2.0 +) + +replace github.com/go-openapi/testify/v2 => ../.. diff --git a/internal/testintegration/go.sum b/internal/testintegration/go.sum new file mode 100644 index 000000000..5dd715a81 --- /dev/null +++ b/internal/testintegration/go.sum @@ -0,0 +1,2 @@ +pgregory.net/rapid v1.2.0 h1:keKAYRcjm+e1F0oAuU5F5+YPAWcyxNNRK2wud503Gnk= +pgregory.net/rapid v1.2.0/go.mod h1:PY5XlDGj0+V1FCq0o192FdRhpKHGTRIWBgqjDBTrq04= diff --git a/internal/testintegration/spew/doc.go b/internal/testintegration/spew/doc.go new file mode 100644 index 000000000..510f35387 --- /dev/null +++ b/internal/testintegration/spew/doc.go @@ -0,0 +1,20 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +// Package spew runs integration tests against [github.com/go-openapi/testify/v2/internal/spew]. +// +// It knows how to generate random data structures and how to fuzz that package. +// +// # Motivation +// +// Even though spew is a very well tested library, its heavy use of [reflect] makes it +// highly sensitive to panicking issues. +// +// This package is here to make sure that all edge-cases are eventually explored +// and tested. +// +// The implementation depends on package [pgregory.net/rapid]. +// +// This internal testing functionality is therefore defined as an independent module and +// does not affect the dependencies of [github.com/go-openapi/testify/v2]. +package spew diff --git a/internal/testintegration/spew/dump_fuzz_test.go b/internal/testintegration/spew/dump_fuzz_test.go new file mode 100644 index 000000000..14bff963f --- /dev/null +++ b/internal/testintegration/spew/dump_fuzz_test.go @@ -0,0 +1,26 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spew + +import ( + "testing" + + "pgregory.net/rapid" +) + +// FuzzSdump is the fuzzed equivalent of TestSdump. +// +// Given a high number of values of different types generated randomly, +// the fuzz engine will alter these values and run [spew.Sdump]. +// +// # Limitations +// +// FuzzSdump skips circular map cases because Go's fmt package +// (used by rapid's fuzz logging) cannot handle circular references. +// +// This case is covered by rapid.Check in TestSdump (which doesn't log). +func FuzzSdump(f *testing.F) { + prop := NoPanicProp(f.Context(), Generator(WithSkipCircularMap(true))) + f.Fuzz(rapid.MakeFuzz(prop)) +} diff --git a/internal/testintegration/spew/dump_test.go b/internal/testintegration/spew/dump_test.go new file mode 100644 index 000000000..d58d947d7 --- /dev/null +++ b/internal/testintegration/spew/dump_test.go @@ -0,0 +1,39 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spew + +import ( + "flag" + "os" + "strconv" + "testing" + + "pgregory.net/rapid" +) + +func TestMain(m *testing.M) { + // set flags for rapid: + // "rapid.checks", 100, "rapid: number of checks to perform" + // "rapid.steps", 30, "rapid: average number of Repeat actions to execute" + // "rapid.failfile", "", "rapid: fail file to use to reproduce test failure" + // "rapid.nofailfile", false, "rapid: do not write fail files on test failures" + // "rapid.seed", 0, "rapid: PRNG seed to start with (0 to use a random one)" + // "rapid.log", false, "rapid: eager verbose output to stdout (to aid with unrecoverable test failures)" + // "rapid.v", false, "rapid: verbose output" + // "rapid.debug", false, "rapid: debugging output" + // "rapid.debugvis", false, "rapid: debugging visualization" + // "rapid.shrinktime", 30*time.Second, "rapid: maximum time to spend on test case minimization" + os.Args = append(os.Args, "-rapid.checks", strconv.Itoa(testLoad())) + flag.Parse() + + os.Exit(m.Run()) +} + +// TestSdump uses property-based testing to ensure Dump never panics or hangs +// with arbitrary Go values, including edge cases that historically caused issues. +func TestSdump(t *testing.T) { + t.Parallel() + + rapid.Check(t, NoPanicProp(t.Context(), Generator())) +} diff --git a/internal/testintegration/spew/edgecases.go b/internal/testintegration/spew/edgecases.go new file mode 100644 index 000000000..567ff08e8 --- /dev/null +++ b/internal/testintegration/spew/edgecases.go @@ -0,0 +1,632 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spew + +import ( + "fmt" + "time" + + "pgregory.net/rapid" +) + +// edgeCaseGenerator builds known edge cases which should all be already well-handled +// by [spew.Sdump]. +func edgeCaseGenerator(opts ...Option) *rapid.Generator[any] { + o := optionsWithDefaults(opts) + + allEdgeCases := []*rapid.Generator[any]{ + rapid.Custom(genStructWithUnexportedFields), + rapid.Custom(genNilInterface), + rapid.Custom(genCircularReference), + rapid.Custom(genMapWithInterfaceKeys), + rapid.Custom(genComplexPointerChain), + rapid.Custom(genInterfaceSlice), + rapid.Custom(genTimeValues), + rapid.Custom(genDeeplyNested), + rapid.Custom(genNestedInterfaces), + rapid.Custom(genChanAndFuncValues), + rapid.Custom(genUncomparableMapValues), + rapid.Custom(genMixedPointerSemantics), + rapid.Custom(genAnonymousStructNesting), + rapid.Custom(genInterfaceArrays), + rapid.Custom(genMultiLevelPointerIndirection), + rapid.Custom(genPointerToInterface), + rapid.Custom(genCircularInterfaceRef), + } + if !o.skipCircularMap { + allEdgeCases = append(allEdgeCases, + rapid.Custom(genCircularMapRef), + ) + } + + return rapid.OneOf( + allEdgeCases..., + ) +} + +// genStructWithUnexportedFields generates structs with unexported fields +// This addresses issue #1828 where spew panicked on unexported fields. +func genStructWithUnexportedFields(t *rapid.T) any { + type TestStruct struct { + PublicField string + privateField int // Unexported - caused #1828 + AnotherPublic []any + } + + return TestStruct{ + PublicField: rapid.String().Draw(t, "public"), + privateField: rapid.Int().Draw(t, "private"), + AnotherPublic: []any{ + rapid.Int().Draw(t, "nested-int"), + rapid.String().Draw(t, "nested-string"), + }, + } +} + +// genCircularReference generates structures with circular references. +func genCircularReference(t *rapid.T) any { + type Node struct { + Value string + Next *Node + } + + n1 := &Node{Value: rapid.String().Draw(t, "node1")} + n2 := &Node{Value: rapid.String().Draw(t, "node2"), Next: n1} + n3 := &Node{Value: rapid.String().Draw(t, "node3"), Next: n2} + + // Create cycle + if rapid.Bool().Draw(t, "create-cycle") { + n1.Next = n3 + } + + return n1 +} + +// genNilInterface generates nil values in non-nil interfaces. +func genNilInterface(t *rapid.T) any { + generators := []func() any{ + func() any { var i any = (*int)(nil); return i }, + func() any { var i any = (*string)(nil); return i }, + func() any { var i any = (*[]int)(nil); return i }, + func() any { var i error = (*testError)(nil); return i }, + } + + idx := rapid.IntRange(0, len(generators)-1).Draw(t, "nil-interface-type") + return generators[idx]() +} + +type testError struct{} + +func (testError) Error() string { return "test error" } + +// genDeeplyNested generates deeply nested structures +// Avoids pointer-to-interface which can cause spew to hang. +func genDeeplyNested(t *rapid.T) any { + const ( + minDepth = 5 + maxDepth = 20 + testCases = 3 + + testCaseSliceAny = 0 + testCaseMapString = 1 + testCaseStruct = 2 + testCaseArrayAny = 3 + ) + + depth := rapid.IntRange(minDepth, maxDepth).Draw(t, "depth") + + var result any = rapid.String().Draw(t, "leaf") + + for i := range depth { + switch rapid.IntRange(0, testCases).Draw(t, fmt.Sprintf("nesting-type-%d", i)) { + case testCaseSliceAny: + result = []any{result} + case testCaseMapString: + result = map[string]any{"nested": result} + case testCaseStruct: + // Wrap in struct instead of pointer-to-interface + result = struct{ Value any }{Value: result} + case testCaseArrayAny: + // Array wrapping + result = [1]any{result} + } + } + + return result +} + +// genMapWithInterfaceKeys generates maps with interface{} keys. +func genMapWithInterfaceKeys(t *rapid.T) any { + const maxKeys = 10 + m := make(map[any]string) + + // Add various key types + numKeys := rapid.IntRange(1, maxKeys).Draw(t, "num-keys") + for i := range numKeys { + key := rapid.OneOf( + rapid.Just[any](rapid.Int().Draw(t, fmt.Sprintf("key-int-%d", i))), + rapid.Just[any](rapid.String().Draw(t, fmt.Sprintf("key-string-%d", i))), + rapid.Just[any](struct{ x int }{rapid.Int().Draw(t, fmt.Sprintf("key-struct-%d", i))}), + ).Draw(t, fmt.Sprintf("key-%d", i)) + + m[key] = rapid.String().Draw(t, fmt.Sprintf("value-%d", i)) + } + + return m +} + +// genComplexPointerChain generates complex pointer chains including nil pointers. +func genComplexPointerChain(t *rapid.T) any { + type PointerChain struct { + Value *int + Next *PointerChain + Values []*string + } + + var makeChain func(depth int) *PointerChain + makeChain = func(depth int) *PointerChain { + if depth <= 0 || rapid.Bool().Draw(t, fmt.Sprintf("terminate-%d", depth)) { + return nil + } + + chain := &PointerChain{} + + // Sometimes nil, sometimes has value + if rapid.Bool().Draw(t, fmt.Sprintf("has-value-%d", depth)) { + v := rapid.Int().Draw(t, fmt.Sprintf("value-%d", depth)) + chain.Value = &v + } + + // Sometimes recurse + if rapid.Bool().Draw(t, fmt.Sprintf("has-next-%d", depth)) { + chain.Next = makeChain(depth - 1) + } + + // Add some nil and non-nil string pointers + const maxStrings = 5 + numStrings := rapid.IntRange(0, maxStrings).Draw(t, fmt.Sprintf("num-strings-%d", depth)) + for i := range numStrings { + if rapid.Bool().Draw(t, fmt.Sprintf("string-nil-%d-%d", depth, i)) { + chain.Values = append(chain.Values, nil) + } else { + s := rapid.String().Draw(t, fmt.Sprintf("string-%d-%d", depth, i)) + chain.Values = append(chain.Values, &s) + } + } + + return chain + } + + const ( + minChainDepth = 3 + maxChainDepth = 10 + ) + + return makeChain(rapid.IntRange(minChainDepth, maxChainDepth).Draw(t, "chain-depth")) +} + +// genTimeValues generates various time.Time values +// This addresses issue #1829 - time.Time rendering in diffs. +func genTimeValues(t *rapid.T) any { + type TimeContainer struct { + Time time.Time + TimePtr *time.Time + Times []time.Time + TimeMap map[string]time.Time + ZeroTime time.Time + } + + const maxDuration = 1_000_000_000_000 + now := time.Now() + past := now.Add(-time.Duration(rapid.Int64Range(0, maxDuration).Draw(t, "past-duration"))) + + container := TimeContainer{ + Time: past, + Times: []time.Time{now, past, {}}, + TimeMap: map[string]time.Time{"now": now, "past": past, "zero": {}}, + } + + if rapid.Bool().Draw(t, "has-time-ptr") { + container.TimePtr = &now + } + + return container +} + +// genInterfaceSlice generates slices of interface{} with mixed types. +func genInterfaceSlice(t *rapid.T) any { + const maxLength = 20 + length := rapid.IntRange(0, maxLength).Draw(t, "slice-length") + slice := make([]any, length) + + for i := range length { + slice[i] = rapid.OneOf( + rapid.Just[any](rapid.Int().Draw(t, fmt.Sprintf("elem-int-%d", i))), + rapid.Just[any](rapid.String().Draw(t, fmt.Sprintf("elem-string-%d", i))), + rapid.Just[any](rapid.Bool().Draw(t, fmt.Sprintf("elem-bool-%d", i))), + rapid.Just[any](nil), + rapid.Just[any](struct{ x, y int }{ + x: rapid.Int().Draw(t, fmt.Sprintf("elem-struct-x-%d", i)), + y: rapid.Int().Draw(t, fmt.Sprintf("elem-struct-y-%d", i)), + }), + ).Draw(t, fmt.Sprintf("elem-%d", i)) + } + + return slice +} + +// genPointerToInterface generates pointer-to-interface chains +// This is the problematic case that can cause spew to hang. +func genPointerToInterface(t *rapid.T) any { + const maxDepth = 3 + depth := rapid.IntRange(1, maxDepth).Draw(t, "ptr-depth") // Max 3 levels + + var result any = rapid.String().Draw(t, "leaf") + for range depth { + clone := result + result = &clone + } + + return result +} + +// genNestedInterfaces generates interface values containing other interfaces. +func genNestedInterfaces(t *rapid.T) any { + const ( + maxDepth = 5 + maxIntValue = 100 + ) + depth := rapid.IntRange(1, maxDepth).Draw(t, "interface-depth") + + var result any = rapid.String().Draw(t, "leaf") + for i := range depth { + // Wrap in struct with interface field + type Wrapper struct { + Value any + Extra any + } + result = Wrapper{ + Value: result, + Extra: rapid.IntRange(0, maxIntValue).Draw(t, fmt.Sprintf("extra-%d", i)), + } + } + + return result +} + +// genChanAndFuncValues generates channels and functions as interface values. +func genChanAndFuncValues(t *rapid.T) any { + const ( + testCases = 5 + maxBuffers = 10 + funcMultiplier = 2 + + testCaseUnbufferedChan = 0 + testCaseBufferedChan = 1 + testCaseNilChan = 2 + testCaseFunction = 3 + testCaseNilFunction = 4 + testCaseStruct = 5 + ) + choice := rapid.IntRange(0, testCases).Draw(t, "chan-func-choice") + + switch choice { + case testCaseUnbufferedChan: + // Unbuffered channel + ch := make(chan int) + return ch + case testCaseBufferedChan: + // Buffered channel + ch := make(chan string, rapid.IntRange(1, maxBuffers).Draw(t, "buffer-size")) + return ch + case testCaseNilChan: + // Nil channel + var ch chan int + return ch + case testCaseFunction: + // Function value + fn := func(x int) int { return x * funcMultiplier } + return fn + case testCaseNilFunction: + // Nil function + var fn func() + return fn + case testCaseStruct: + // Struct containing channels and funcs + type ChanFuncStruct struct { + Ch chan int + Fn func(string) bool + Data []any + } + return ChanFuncStruct{ + Ch: make(chan int, 1), + Fn: func(s string) bool { return len(s) > 0 }, + Data: []any{make(chan bool), func() {}}, + } + } + return nil +} + +// genUncomparableMapValues generates maps with uncomparable types as values +// (not as keys, which would panic). +func genUncomparableMapValues(t *rapid.T) any { + const ( + maxEntries = 5 + testCases = 3 + + testCaseSliceValue = 0 + testCaseMapValue = 1 + testCaseFuncValue = 2 + testCaseSliceSliceValue = 3 + ) + m := make(map[string]any) + + numEntries := rapid.IntRange(1, maxEntries).Draw(t, "num-entries") + for i := range numEntries { + key := fmt.Sprintf("key-%d", i) + + // Use uncomparable types as values + choice := rapid.IntRange(0, testCases).Draw(t, fmt.Sprintf("uncomparable-%d", i)) + switch choice { + case testCaseSliceValue: + m[key] = []int{1, 2, 3} + case testCaseMapValue: + m[key] = map[string]int{"nested": i} + case testCaseFuncValue: + m[key] = func() int { return i } + case testCaseSliceSliceValue: + // Slice of slices + m[key] = [][]string{{"a", "b"}, {"c"}} + } + } + + return m +} + +// genMixedPointerSemantics generates structs with mixed value/pointer fields. +func genMixedPointerSemantics(t *rapid.T) any { + type MixedStruct struct { + // Value types + IntVal int + StringVal string + SliceVal []int + + // Pointer types + IntPtr *int + StringPtr *string + SlicePtr *[]int + + // Nested mixed + Nested struct { + Value any + Ptr *any + } + } + + i := rapid.Int().Draw(t, "int-val") + s := rapid.String().Draw(t, "string-val") + slice := []int{1, 2, 3} + + var nestedVal any = rapid.Int().Draw(t, "nested-val") + + ms := MixedStruct{ + IntVal: i, + StringVal: s, + SliceVal: []int{rapid.Int().Draw(t, "slice-elem")}, + IntPtr: &i, + StringPtr: &s, + SlicePtr: &slice, + } + + ms.Nested.Value = nestedVal + ms.Nested.Ptr = &nestedVal + + return ms +} + +// genAnonymousStructNesting generates deeply nested anonymous structs. +func genAnonymousStructNesting(t *rapid.T) any { + const ( + minDepth = 2 + maxDepth = 7 + ) + depth := rapid.IntRange(minDepth, maxDepth).Draw(t, "anon-depth") + + // Start with innermost value + var result any = struct { + Value string + }{ + Value: rapid.String().Draw(t, "innermost"), + } + + // Wrap in anonymous structs + for i := range depth { + result = struct { + Level int + Data any + Extra struct { + Nested any + } + }{ + Level: i, + Data: result, + Extra: struct{ Nested any }{ + Nested: rapid.Int().Draw(t, fmt.Sprintf("extra-%d", i)), + }, + } + } + + return result +} + +// genInterfaceArrays generates arrays (not slices) with interface elements. +func genInterfaceArrays(t *rapid.T) any { + const ( + testCases = 3 + + testCaseArray = 0 + testCaseArrayPointers = 1 + testCaseArrayNested = 2 + testCaseArrayIfaceArray = 3 + ) + choice := rapid.IntRange(0, testCases).Draw(t, "array-choice") + + switch choice { + case testCaseArray: + // Fixed size array of interfaces + return [3]any{ + rapid.Int().Draw(t, "elem-0"), + rapid.String().Draw(t, "elem-1"), + nil, + } + case testCaseArrayPointers: + // Array of pointers to interfaces + var i1, i2, i3 any + i1 = rapid.Int().Draw(t, "ptr-elem-0") + i2 = rapid.String().Draw(t, "ptr-elem-1") + i3 = rapid.Bool().Draw(t, "ptr-elem-2") + return [3]*any{&i1, &i2, &i3} + case testCaseArrayNested: + // Nested arrays + return [2][2]any{ + {rapid.Int().Draw(t, "00"), rapid.String().Draw(t, "01")}, + {rapid.Bool().Draw(t, "10"), nil}, + } + case testCaseArrayIfaceArray: + // Array containing array + inner := [2]int{rapid.Int().Draw(t, "inner-0"), rapid.Int().Draw(t, "inner-1")} + return [1]any{inner} + } + + return nil +} + +// genMultiLevelPointerIndirection generates values with multiple pointer levels. +func genMultiLevelPointerIndirection(t *rapid.T) any { + const ( + maxLevels = 5 + + simplePointer = 0 + oneIndirection = 1 + doubleIndirection = 2 + ) + levels := rapid.IntRange(1, maxLevels).Draw(t, "ptr-levels") + + baseVal := rapid.Int().Draw(t, "base") + + // Build pointer chain: int -> *int -> **int -> ***int ... + var current any = baseVal + for i := range levels { + // Create new pointer level + switch i { + case simplePointer: + p := baseVal + current = &p + case oneIndirection: + p, ok := current.(*int) + if !ok { + t.Fatalf("internal error: expected a *int") + } + current = &p + case doubleIndirection: + p, ok := current.(**int) + if !ok { + t.Fatalf("internal error: expected a **int") + } + current = &p + default: + // For deeper levels, use interface + temp := current + current = &temp + } + } + + return current +} + +// genCircularInterfaceRef generates circular references through interfaces +// This tests the KNOWN BUG: circular references via interface{} cause hangs. +func genCircularInterfaceRef(t *rapid.T) any { + const ( + testCases = 4 + + testCaseSelfPointer = 0 + testCaseCircularStruct = 1 + testCaseCircularSlice = 2 + testCaseCircularChain = 3 + testCaseDoublePointer = 4 + ) + choice := rapid.IntRange(0, testCases).Draw(t, "circular-type") + + switch choice { + case testCaseSelfPointer: + // Simple self-referential pointer through interface + // This recreates the original genPointerToInterface bug + var self any = rapid.String().Draw(t, "base") + self = &self // self now contains a pointer to itself! + return self + + case testCaseCircularStruct: + // Struct with circular interface field + type CircularStruct struct { + Name string + Cycle any // Will point back to the struct + } + cs := &CircularStruct{ + Name: rapid.String().Draw(t, "struct-name"), + } + cs.Cycle = cs // Circular reference through interface + return cs + + case testCaseCircularSlice: + // Slice with circular element (slice contains itself) + type CircularSlice struct { + Items []any + } + cs := &CircularSlice{ + Items: []any{ + rapid.Int().Draw(t, "item-0"), + rapid.String().Draw(t, "item-1"), + }, + } + cs.Items = append(cs.Items, cs) // Add self to slice + return cs + + case testCaseCircularChain: + // Multi-level circular chain: A -> B -> C -> A + type Node struct { + Name string + Next any + } + a := &Node{Name: "A"} + b := &Node{Name: "B"} + c := &Node{Name: "C"} + + a.Next = b + b.Next = c + c.Next = a // Complete the cycle + + return a + + case testCaseDoublePointer: + // Double pointer with circular reference + var base any = rapid.String().Draw(t, "base-val") + ptr1 := &base + base = &ptr1 // Now base points to ptr1, which points to base! + return ptr1 + } + + return nil +} + +func genCircularMapRef(t *rapid.T) any { + // Map with circular value (map contains itself) + m := map[string]any{ + "key1": rapid.Int().Draw(t, "val-1"), + "key2": rapid.String().Draw(t, "val-2"), + } + m["circular"] = m // Map contains itself as a value + + return m +} diff --git a/internal/testintegration/spew/edgecases_test.go b/internal/testintegration/spew/edgecases_test.go new file mode 100644 index 000000000..c930261d6 --- /dev/null +++ b/internal/testintegration/spew/edgecases_test.go @@ -0,0 +1,27 @@ +package spew + +import ( + "testing" + + "pgregory.net/rapid" + + "github.com/go-openapi/testify/v2/internal/spew" +) + +// TestSdump_EdgeCases focuses on known problematic patterns. +func TestSdump_EdgeCases(t *testing.T) { + t.Parallel() + + rapid.Check(t, func(rt *rapid.T) { + value := edgeCaseGenerator().Draw(rt, "edge-case-generator") + func() { + defer func() { + if r := recover(); r != nil { + rt.Fatalf("Dump panicked on edge case: %v\nValue type: %T", r, value) + } + }() + + _ = spew.Sdump(value) + }() + }) +} diff --git a/internal/testintegration/spew/generator.go b/internal/testintegration/spew/generator.go new file mode 100644 index 000000000..d8307f8e9 --- /dev/null +++ b/internal/testintegration/spew/generator.go @@ -0,0 +1,661 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spew + +import ( + "context" + "reflect" + "sync" + "sync/atomic" + "time" + "unicode" + + "pgregory.net/rapid" + + "github.com/go-openapi/testify/v2/internal/spew" +) + +const ( + maxTestDuration = time.Second + defaultMaxDepth = uint32(10) + maxStructFields = 5 + maxFieldNameLen = 10 + maxStringLen = 10 + maxChanBuf = 3 + maxArrayLen = 5 +) + +// NoPanicProp produces a check for the [rapid.T] test wrapper. +// +// It verifies that [spew.Sdump] does not panic or hang given random values generated +// by the provided [rapid.Generator]. +// +// NOTE: [spew.Sdump] is a perfect endpoint to cover most of what the spew package is doing. +// +// This test doesn't check how well the values are rendered. +func NoPanicProp(ctx context.Context, g *rapid.Generator[any]) func(*rapid.T) { + return func(rt *rapid.T) { + value := g.Draw(rt, "arbitrary-value") + timeoutCtx, cancel := context.WithTimeout(ctx, maxTestDuration) + defer cancel() + + var w sync.WaitGroup + done := make(chan struct{}) + + w.Add(1) + go func() { + defer w.Done() + select { + case <-done: + cancel() + + return + case <-timeoutCtx.Done(): + rt.Fatalf("Sdump timed out:\nWith value type: %T\nValue: %#v", value, value) + + return + } + }() + + go func() { // this go routine may leak if timeout kicks + // Sdump should never panic + defer func() { + if r := recover(); r != nil { + rt.Errorf("Sdump panicked: %v\nWith value type: %T\nValue: %#v", r, value, value) + close(done) + + return + } + }() + + value = spew.Sdump(value) + // fmt.Printf("%v", value) + + close(done) + }() + + w.Wait() + } +} + +// Generator builds a [rapid.Generator] capable of producing any kind of go value. +// +// It is biased toward exploring edge cases such as cyclical pointer references, or nil values. +// It may use uncommon but legit constructs like *[]any, *map[],... +// +// Known edge cases reproducing historical issues are added to the generator. +// +// Limitations: +// +// * does not generate generic types +// * does not generate type declarations: all types are anonymous +// * does not generate structs with unexported fields +// * does not generate embedded fields in structs with methods +// +// These limitations are partially mitigated by the edgecase generator, +// which is not dependent on the [reflect] package. +func Generator(opts ...Option) *rapid.Generator[any] { + g := newTypeGenerator() + + return rapid.OneOf( + g.Generator(opts...), + edgeCaseGenerator(opts...), + ) +} + +type typeGenerator struct { + mx sync.Mutex + pointers map[any]struct{} + maxDepth uint32 +} + +func newTypeGenerator() *typeGenerator { + return &typeGenerator{ + pointers: make(map[any]struct{}), + maxDepth: defaultMaxDepth, + } +} + +func (g *typeGenerator) Generator(_ ...Option) *rapid.Generator[any] { + return g.genAnything(0) +} + +func (g *typeGenerator) genAnything(depth uint32) *rapid.Generator[any] { + return rapid.Deferred(func() *rapid.Generator[any] { // recursive definition. Recursion stops on max depth + return rapid.OneOf( + genPrimitiveValue(), // int, bool, string, etc + g.genContainerValue(depth+1), // map, slice, struct, array + g.genOtherValue(depth+1), // chan, func, and other peculiar types present in the standard library (sync.Mutex, ...) + g.genPointer(depth+1), // pointer to anything, including nil and cyclical references + genInterfaceValue(depth+1), // any, some interface + ) + }) +} + +// genPointer may either generate a pointer to a new structure (may be nil), +// or a cyclical reference to an already created pointer. +func (g *typeGenerator) genPointer(depth uint32) *rapid.Generator[any] { + return rapid.OneOf( + g.genNewPointer(depth), + g.genExistingPointer(), + ) +} + +// genNewPointer produces a new pointer. +// +// We don't use [rapid.Ptr] to avoid always having *any. +func (g *typeGenerator) genNewPointer(depth uint32) *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + value := g.genAnything(depth).Draw(t, "new-value") + + val := reflect.ValueOf(value) + if val.Kind() == reflect.Interface { + val = val.Elem() + } + if val.Kind() == reflect.Pointer && val.Elem().Kind() == reflect.Interface { + val = val.Elem().Elem() + } + + flipCoin := rapid.Bool().Draw(t, "flip-coin") + if flipCoin && val.Kind() == reflect.Interface { + val = val.Elem() + } + + typ := reflect.TypeOf(value) + if typ == nil { + var iface any + + return &iface + } + + ptrVal := val + if val.Kind() != reflect.Pointer { + if val.CanAddr() { + ptrVal = val.Addr() + } else { + clone := reflect.New(reflect.TypeOf(value)) + // check the value is not nil + if val.IsValid() { + clone.Elem().Set(val) + } + ptrVal = clone + } + } + + ptr := ptrVal.Interface() + if !ptrVal.IsNil() { + g.mx.Lock() + g.pointers[ptr] = struct{}{} + g.mx.Unlock() + } + + return ptr + }) +} + +func (g *typeGenerator) genExistingPointer() *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + g.mx.Lock() + l := len(g.pointers) + g.mx.Unlock() + + if l == 0 { + return g.genNewPointer(0).Draw(t, "new-pointer") + } + + // may draw a cyclical reference + // Random iterations over the map is deemed sufficient randomization (we MUST call rapid random generator somehow) + const minIter = 4 + maxIter := rapid.IntRange(minIter, max(minIter, len(g.pointers))).Draw(t, "ptr-iterations") + var k any + var j int + + for k = range g.pointers { + if j > maxIter { + break + } + j++ + } + + return k + }) +} + +// container values are structs, slices, arrays and maps. +func (g *typeGenerator) genContainerValue(depth uint32) *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + if depth > g.maxDepth { + return genPrimitiveValue().Draw(t, "final") + } + + return rapid.OneOf( + g.genStructValue(depth+1), + g.genArrayValue(depth+1), + g.genSliceValue(depth+1), + g.genMapValue(depth+1), + ).Draw(t, "container") + }) +} + +// genSliceValue generates a slice of any type. +// We don't use [rapid.SliceOf] to avoid always having a []any. +// +// Since slices are not comparable, we don't need a comparable version of this. +// +// # Limitation +// +// At this moment, the slice is of random size (can be nil or empty) but populated +// with a single random value, unlike [rapid.SliceOf]. +func (g *typeGenerator) genSliceValue(depth uint32) *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + value := g.genAnything(depth).Draw(t, "value") + val := reflect.ValueOf(value) + flipCoin := rapid.Bool().Draw(t, "flip-coin") + if flipCoin && val.Kind() == reflect.Interface { + val = val.Elem() + } + if flipCoin && val.Kind() == reflect.Pointer && val.Elem().Kind() == reflect.Interface { + val = val.Elem() + } + + typ := reflect.TypeOf(value) + if typ == nil { + return ([]any)(nil) + } + + size := rapid.IntRange(0, maxArrayLen).Draw(t, "slice-len") + if size == 0 { + // may chose to return a nil slice + flipCoin := rapid.Bool().Draw(t, "flip-coin") + if flipCoin { + sliceType := reflect.SliceOf(val.Type()) + sliceValue := reflect.New(sliceType).Elem() + + return sliceValue.Interface() + } + } + + sliceType := reflect.SliceOf(val.Type()) + sliceValue := reflect.MakeSlice(sliceType, 0, size) + for range size { + sliceValue = reflect.Append(sliceValue, val) + } + + return sliceValue.Interface() + }) +} + +// genMapValue generates a map of any type with a key of any comparable type. +// We don't use [rapid.MapOf] to avoid always having a map[any]any. +// +// Since maps are not comparable, we don't need a comparable version of this. +// +// Maps can be nil, empty or contain one element. +// +// # Limitation +// +// At this moment, maps contain only one single random value, unlike [rapid.MapOf]. +func (g *typeGenerator) genMapValue(depth uint32) *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + key := g.genComparableValue(depth).Draw(t, "key") + + keyVal := reflect.ValueOf(key) + flipCoin := rapid.Bool().Draw(t, "flip-coin") + if flipCoin && (keyVal.Kind() == reflect.Interface) { + if keyVal.Elem().Comparable() { + // may use interface wrapper, pointer or base type + keyVal = keyVal.Elem() + } + } + + if flipCoin && (keyVal.Kind() == reflect.Pointer && keyVal.Elem().Kind() == reflect.Interface) { + if keyVal.Elem().Comparable() { + // may use interface wrapper, pointer or base type + keyVal = keyVal.Elem() + } + } + + for !keyVal.Comparable() { + if keyVal.Kind() == reflect.Interface || keyVal.Kind() == reflect.Pointer { + // may use interface wrapper, pointer or base type + keyVal = keyVal.Elem() + } + } + + value := g.genAnything(depth).Draw(t, "value") + valueVal := reflect.ValueOf(value) + flipCoin = rapid.Bool().Draw(t, "flip-coin") + if flipCoin && (valueVal.Kind() == reflect.Interface || valueVal.Kind() == reflect.Pointer) { + // may use interface wrapper, pointer or base type + valueVal = valueVal.Elem() + } + + typ := reflect.TypeOf(key) + if typ == nil { + k := "key" + keyVal = reflect.ValueOf(&k) + } + typ = reflect.TypeOf(value) + if typ == nil { + v := "value" + valueVal = reflect.ValueOf(&v) + } + + mapType := reflect.MapOf(keyVal.Type(), valueVal.Type()) + flipCoin = rapid.Bool().Draw(t, "flip-coin") + if flipCoin { + // return nil map + mapObject := reflect.New(mapType).Elem() + + return mapObject.Interface() + } + + mapObject := reflect.MakeMap(mapType) + flipCoin = rapid.Bool().Draw(t, "flip-coin") + if flipCoin { + // populate the map + mapObject.SetMapIndex(keyVal, valueVal) + } + + return mapObject.Interface() + }) +} + +func (g *typeGenerator) genComparableValue(depth uint32) *rapid.Generator[any] { + return rapid.OneOf( + genPrimitiveValue(), // int, bool, string, etc + g.genComparableArrayValue(depth+1), // array + g.genComparableStructValue(depth+1), + g.genPointer(depth+1), // pointer to anything + g.genChanValue(depth+1), + // NOTE: this excerpt from the go language spec is not implemented: + // Interface types that are not type parameters are comparable. + // Two interface values are equal if they have identical dynamic types and equal dynamic values or if both have value nil. + ) +} + +// genArray produces arrays with all their elements copied from a single random value. +func (g *typeGenerator) genArrayValue(depth uint32) *rapid.Generator[any] { + return g.genArrayFromGenerator(g.genAnything, depth+1)() +} + +func (g *typeGenerator) genComparableArrayValue(depth uint32) *rapid.Generator[any] { + return g.genArrayFromGenerator(g.genComparableValue, depth+1)() +} + +func (g *typeGenerator) genArrayFromGenerator(fn func(uint32) *rapid.Generator[any], depth uint32) func() *rapid.Generator[any] { + return func() *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + first := fn(depth+1).Draw(t, "elem") + firstValue := reflect.ValueOf(first) + flipCoin := rapid.Bool().Draw(t, "flip-coin") + if flipCoin && firstValue.Kind() == reflect.Interface { + // may use interface wrapper, pointer or base type + if firstValue.Elem().Comparable() { + firstValue = firstValue.Elem() + } + } + + if flipCoin && firstValue.Kind() == reflect.Pointer && firstValue.Elem().Kind() == reflect.Interface { + firstValue = firstValue.Elem() + if firstValue.Elem().Comparable() { + firstValue = firstValue.Elem() + } + } + + typ := reflect.TypeOf(first) + if typ == nil { + var iface any = "elem" + + firstValue = reflect.ValueOf(&iface) + } + + elem := firstValue.Type() + size := rapid.IntRange(0, maxArrayLen).Draw(t, "array-len") + arrayType := reflect.ArrayOf(size, elem) + arrayValue := reflect.New(arrayType).Elem() + for i := range size { + arrayValue.Index(i).Set(firstValue) + } + + return arrayValue.Interface() + }) + } +} + +// genStructValue generates a random struct object with a random number of exported fields of any type. +// +// # Limitations +// +// * from [reflect.StructOf]: can't generate random unexported fields. To work with unexported fields, use the edgecaseGenerator. +// * no struct tags (not useful for [spew.Dump]). +func (g *typeGenerator) genStructValue(depth uint32) *rapid.Generator[any] { + return g.genStructValueFromGenerator(g.genAnything, depth+1)() +} + +func (g *typeGenerator) genComparableStructValue(depth uint32) *rapid.Generator[any] { + return g.genStructValueFromGenerator(g.genComparableValue, depth+1)() +} + +func (g *typeGenerator) genStructValueFromGenerator(fn func(uint32) *rapid.Generator[any], depth uint32) func() *rapid.Generator[any] { + return func() *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + numFields := rapid.IntRange(0, maxStructFields).Draw(t, "struct-fields") // may have empty struct{} + fields := make([]reflect.StructField, 0, numFields) + fieldValues := make([]reflect.Value, 0, numFields) + names := rapid.SliceOfNDistinct(fieldNameGenerator(), numFields, numFields, func(str string) string { return str }).Draw(t, "field-names") + + for i := range numFields { + value := fn(depth+1).Draw(t, "field-value") + val := reflect.ValueOf(value) + if val.Kind() == reflect.Interface { + val = val.Elem() + } + if val.Kind() == reflect.Pointer && val.Elem().Kind() == reflect.Interface { + val = val.Elem() + } + + typ := reflect.TypeOf(value) + if typ == nil { + var fv any = "field" + val = reflect.ValueOf(fv) + } + fieldType := val.Type() + + // produces a legit exported name + anonymous := rapid.Bool().Draw(t, "is-field-anonymous") + if fieldType.NumMethod() > 0 { + anonymous = false // reflect limitation when creating structs + } + if fieldType.Kind() == reflect.Pointer { + elem := fieldType.Elem() + kind := elem.Kind() + if kind == reflect.Pointer || kind == reflect.Interface { // prevent the creation of illegal **any or *any embedded fields + anonymous = false + } + } + + field := reflect.StructField{ + Name: names[i], // exported (otherwise not supported by [reflect.StructOf] + Type: fieldType, + Anonymous: anonymous, + } + + fields = append(fields, field) + fieldValues = append(fieldValues, val) + } + + structType := reflect.StructOf(fields) + structVal := reflect.New(structType).Elem() + + for i := range structVal.NumField() { + field := structVal.Field(i) + field.Set(fieldValues[i]) + } + + // the struct has a zero value + return structVal.Interface() // all fields are exported + }) + } +} + +func fieldNameGenerator() *rapid.Generator[string] { + return rapid.Custom(func(t *rapid.T) string { + first := rapid.StringOfN(unicodeUpperLetter(), 1, 1, -1).Draw(t, "first-letter") + rest := rapid.StringOfN(unicodeLetterOrDigit(), 1, maxFieldNameLen, -1).Draw(t, "rest-of-field") + + return first + rest + }) +} + +// genOtherValue generates values of type: chan, func() +// Should we add: unsafe.Pointer ?? +// +// - chan {anything} +// - func() , func(int, string) *string +// +// Also add types that might produce odd behavior: +// +// - [sync.Mutex], [sync.RWMutex] +// - [atomic.Int64], [atomic.Value] +// +// # Limitations +// +// * channels are created with a random capacity, but are not written to. +// * only bi-directional channels are created. +func (g *typeGenerator) genOtherValue(depth uint32) *rapid.Generator[any] { + return rapid.OneOf( + g.genChanValue(depth+1), + g.genFuncValue(depth+1), + rapid.Custom(func(t *rapid.T) any { + flipCoin := rapid.Bool().Draw(t, "flip-coin") + if flipCoin { + var mx sync.Mutex + + return &mx + } + + var mx sync.RWMutex + + return &mx + }), + rapid.Custom(func(t *rapid.T) any { + flipCoin := rapid.Bool().Draw(t, "flip-coin") + if flipCoin { + var aint64 atomic.Int64 + + return &aint64 + } + var av atomic.Value + + return &av + }), + ) +} + +func (g *typeGenerator) genChanValue(depth uint32) *rapid.Generator[any] { + return rapid.Custom(func(t *rapid.T) any { + value := g.genAnything(depth+1).Draw(t, "elem") + buf := rapid.IntRange(0, maxChanBuf).Draw(t, "chan-buffers") + + val := reflect.ValueOf(value) + if val.Kind() == reflect.Interface { + if val.Elem().IsNil() { + c := make(chan any, buf) + + return c + } + val = val.Elem() + } + + if val.Kind() == reflect.Pointer && val.Elem().Kind() == reflect.Interface { + val = val.Elem().Elem() + } + + typ := reflect.TypeOf(value) + if typ == nil || !val.IsValid() { + c := make(chan *any, buf) + + return c + } + + valType := val.Type() + + chanType := reflect.ChanOf(reflect.BothDir, valType) + chanValue := reflect.MakeChan(chanType, buf) + + return chanValue.Interface() + }) +} + +// genFuncValue only returns one of a few predeclared functions. +func (g *typeGenerator) genFuncValue(_ uint32) *rapid.Generator[any] { + return rapid.OneOf( + rapid.Just(emptyFunc).AsAny(), + rapid.Just(signatureFunc).AsAny(), + ) +} + +func emptyFunc() {} +func signatureFunc(_ int, _ string) *string { return nil } + +// Proposal for enhancement: add more diversified interfaces. +func genInterfaceValue(_ uint32) *rapid.Generator[any] { + var emptyIface any + return rapid.Just(emptyIface).AsAny() +} + +func unicodeUpperLetter() *rapid.Generator[rune] { + return rapid.RuneFrom(nil, unicode.Upper) // NOTE: unlike go, we don't include "_" in go names +} + +func unicodeLetterOrDigit() *rapid.Generator[rune] { + return rapid.RuneFrom(nil, unicode.Letter, unicode.Digit) +} + +func genPrimitiveValue() *rapid.Generator[any] { + return rapid.OneOf( + genIntegerValue(), // all integer types, incl. rune, byte, uintptr + genFloatValue(), // float32, float64, complex64, complex128 + rapid.Bool().AsAny(), + rapid.String().AsAny(), + ) +} + +func genIntegerValue() *rapid.Generator[any] { + // NOTE: byte and rune are aliases. These types are slightly overrepresented in the sample. + return rapid.OneOf( + rapid.Byte().AsAny(), + rapid.Int().AsAny(), + rapid.Int16().AsAny(), + rapid.Int32().AsAny(), + rapid.Int64().AsAny(), + rapid.Uint().AsAny(), + rapid.Uint8().AsAny(), + rapid.Uint16().AsAny(), + rapid.Uint32().AsAny(), + rapid.Uint64().AsAny(), + rapid.Uintptr().AsAny(), + rapid.Rune().AsAny(), + ) +} + +func genFloatValue() *rapid.Generator[any] { + return rapid.OneOf( + rapid.Float32().AsAny(), + rapid.Float64().AsAny(), + rapid.Custom(func(t *rapid.T) any { + realp := rapid.Float32().Draw(t, "real") + imagp := rapid.Float32().Draw(t, "imag") + + return complex(realp, imagp) + }), + rapid.Custom(func(t *rapid.T) any { + realp := rapid.Float64().Draw(t, "real") + imagp := rapid.Float64().Draw(t, "imag") + + return complex(realp, imagp) + }), + ) +} diff --git a/internal/testintegration/spew/generator_test.go b/internal/testintegration/spew/generator_test.go new file mode 100644 index 000000000..120582232 --- /dev/null +++ b/internal/testintegration/spew/generator_test.go @@ -0,0 +1,129 @@ +// SPDX-FileCopyrightText: Copyright 2025 go-swagger maintainers +// SPDX-License-Identifier: Apache-2.0 + +package spew + +import ( + "os" + "testing" + + "pgregory.net/rapid" +) + +func TestTypeGenerator(t *testing.T) { + t.Parallel() + + // this test exercises the generator and not [spew.Sdump]. + logger := testLogger(t) + const numExamples = 5 + + t.Run("with primitive types", rapid.MakeCheck(func(tr *rapid.T) { + g := genPrimitiveValue() + for range numExamples { + value := g.Draw(tr, "sample") + logger(value) + } + })) + + t.Run("example with array type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genArrayValue(0).Example() + logger(value) + } + }) + + t.Run("example with struct type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genStructValue(0).Example() + logger(value) + } + }) + + t.Run("example with slice type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genSliceValue(0).Example() + logger(value) + } + }) + + t.Run("example with map type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genMapValue(0).Example() + logger(value) + } + }) + + t.Run("example with pointer type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genNewPointer(0).Example() + logger(value) + } + }) + + t.Run("example with channel type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genChanValue(0).Example() + logger(value) + } + }) + + t.Run("example with other values", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genOtherValue(0).Example() + logger(value) + } + }) + + t.Run("example with any container type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genContainerValue(0).Example() + logger(value) + } + }) + + t.Run("example with any type", func(_ *testing.T) { + g := newTypeGenerator() + for range numExamples { + value := g.genAnything(0).Example() + logger(value) + } + }) + + t.Run("with check on any type", rapid.MakeCheck(func(tr *rapid.T) { + g := newTypeGenerator() + value := g.Generator().Draw(tr, "sample") + logger(value) + })) +} + +func testLogger(t *testing.T) func(any) { + t.Helper() + + isDebug := os.Getenv("DEBUG") != "" + + if !isDebug { + return func(_ any) {} + } + + return func(str any) { + t.Logf("%v", str) + } +} + +func testLoad() int { + isCI := os.Getenv("CI") != "" + + if isCI { + return 100 + } + + return 100000 // local testing explores more cases +} diff --git a/internal/testintegration/spew/options.go b/internal/testintegration/spew/options.go new file mode 100644 index 000000000..a591213d5 --- /dev/null +++ b/internal/testintegration/spew/options.go @@ -0,0 +1,25 @@ +package spew + +// Option to tune the [Generator]. +type Option func(*options) + +type options struct { + skipCircularMap bool +} + +// WithSkipCircularMap allows to skip specifically the self-referencing map scenario. +func WithSkipCircularMap(skipped bool) Option { + return func(o *options) { + o.skipCircularMap = skipped + } +} + +func optionsWithDefaults(opts []Option) options { + var o options + + for _, apply := range opts { + apply(&o) + } + + return o +}