From 748f31bf60f62629540d2e318fe7292c60a048de Mon Sep 17 00:00:00 2001 From: Frederic BIDON Date: Sun, 4 Jan 2026 20:48:31 +0100 Subject: [PATCH] fix(spew): fixed more panic & hang cases in the internalized spew * fixed case of circular reference that hangs * fixed unrecoverable panic when dumping a map with a circular reference test(integrationtest): added new module for integration tests This new module takes a systematic approach to explore edge cases in spew by producing random scenarios with uncommon data types. This approach uncovered 2 bugs in spew (fixed above)... and one in the fmt standard library. This new module leverages the rapid library to build generators and exercises spew.Sdump. The "property based" tests produced with rapid are generalized in a fuzz test. Signed-off-by: Frederic BIDON --- .golangci.yml | 2 + go.work | 1 + internal/assertions/doc.go | 2 + internal/spew/dump.go | 116 ++- internal/spew/edgecases_test.go | 98 +++ internal/testintegration/README.md | 286 ++++++++ internal/testintegration/go.mod | 10 + internal/testintegration/go.sum | 2 + internal/testintegration/spew/doc.go | 20 + .../testintegration/spew/dump_fuzz_test.go | 26 + internal/testintegration/spew/dump_test.go | 39 ++ internal/testintegration/spew/edgecases.go | 632 +++++++++++++++++ .../testintegration/spew/edgecases_test.go | 27 + internal/testintegration/spew/generator.go | 661 ++++++++++++++++++ .../testintegration/spew/generator_test.go | 129 ++++ internal/testintegration/spew/options.go | 25 + 16 files changed, 2040 insertions(+), 36 deletions(-) create mode 100644 internal/spew/edgecases_test.go create mode 100644 internal/testintegration/README.md create mode 100644 internal/testintegration/go.mod create mode 100644 internal/testintegration/go.sum create mode 100644 internal/testintegration/spew/doc.go create mode 100644 internal/testintegration/spew/dump_fuzz_test.go create mode 100644 internal/testintegration/spew/dump_test.go create mode 100644 internal/testintegration/spew/edgecases.go create mode 100644 internal/testintegration/spew/edgecases_test.go create mode 100644 internal/testintegration/spew/generator.go create mode 100644 internal/testintegration/spew/generator_test.go create mode 100644 internal/testintegration/spew/options.go 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 +}