diff --git a/examples/gno.land/p/moul/fp/fp.gno b/examples/gno.land/p/moul/fp/fp.gno new file mode 100644 index 00000000000..b2811c77d5a --- /dev/null +++ b/examples/gno.land/p/moul/fp/fp.gno @@ -0,0 +1,270 @@ +// Package fp provides functional programming utilities for Gno, enabling +// transformations, filtering, and other operations on slices of interface{}. +// +// Example of chaining operations: +// +// numbers := []interface{}{1, 2, 3, 4, 5, 6} +// +// // Define predicates, mappers and reducers +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// double := func(v interface{}) interface{} { return v.(int) * 2 } +// sum := func(a, b interface{}) interface{} { return a.(int) + b.(int) } +// +// // Chain operations: filter even numbers, double them, then sum +// evenNums := Filter(numbers, isEven) // [2, 4, 6] +// doubled := Map(evenNums, double) // [4, 8, 12] +// result := Reduce(doubled, sum, 0) // 24 +// +// // Alternative: group by even/odd, then get even numbers +// byMod2 := func(v interface{}) interface{} { return v.(int) % 2 } +// grouped := GroupBy(numbers, byMod2) // {0: [2,4,6], 1: [1,3,5]} +// evens := grouped[0] // [2,4,6] +package fp + +// Mapper is a function type that maps an element to another element. +type Mapper func(interface{}) interface{} + +// Predicate is a function type that evaluates a condition on an element. +type Predicate func(interface{}) bool + +// Reducer is a function type that reduces two elements to a single value. +type Reducer func(interface{}, interface{}) interface{} + +// Filter filters elements from the slice that satisfy the given predicate. +// +// Example: +// +// numbers := []interface{}{-1, 0, 1, 2} +// isPositive := func(v interface{}) bool { return v.(int) > 0 } +// result := Filter(numbers, isPositive) // [1, 2] +func Filter(values []interface{}, fn Predicate) []interface{} { + result := []interface{}{} + for _, v := range values { + if fn(v) { + result = append(result, v) + } + } + return result +} + +// Map applies a function to each element in the slice. +// +// Example: +// +// numbers := []interface{}{1, 2, 3} +// toString := func(v interface{}) interface{} { return fmt.Sprintf("%d", v) } +// result := Map(numbers, toString) // ["1", "2", "3"] +func Map(values []interface{}, fn Mapper) []interface{} { + result := make([]interface{}, len(values)) + for i, v := range values { + result[i] = fn(v) + } + return result +} + +// Reduce reduces a slice to a single value by applying a function. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4} +// sum := func(a, b interface{}) interface{} { return a.(int) + b.(int) } +// result := Reduce(numbers, sum, 0) // 10 +func Reduce(values []interface{}, fn Reducer, initial interface{}) interface{} { + acc := initial + for _, v := range values { + acc = fn(acc, v) + } + return acc +} + +// FlatMap maps each element to a collection and flattens the results. +// +// Example: +// +// words := []interface{}{"hello", "world"} +// split := func(v interface{}) interface{} { +// chars := []interface{}{} +// for _, c := range v.(string) { +// chars = append(chars, string(c)) +// } +// return chars +// } +// result := FlatMap(words, split) // ["h","e","l","l","o","w","o","r","l","d"] +func FlatMap(values []interface{}, fn Mapper) []interface{} { + result := []interface{}{} + for _, v := range values { + inner := fn(v).([]interface{}) + result = append(result, inner...) + } + return result +} + +// All returns true if all elements satisfy the predicate. +// +// Example: +// +// numbers := []interface{}{2, 4, 6, 8} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result := All(numbers, isEven) // true +func All(values []interface{}, fn Predicate) bool { + for _, v := range values { + if !fn(v) { + return false + } + } + return true +} + +// Any returns true if at least one element satisfies the predicate. +// +// Example: +// +// numbers := []interface{}{1, 3, 4, 7} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result := Any(numbers, isEven) // true (4 is even) +func Any(values []interface{}, fn Predicate) bool { + for _, v := range values { + if fn(v) { + return true + } + } + return false +} + +// None returns true if no elements satisfy the predicate. +// +// Example: +// +// numbers := []interface{}{1, 3, 5, 7} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result := None(numbers, isEven) // true (no even numbers) +func None(values []interface{}, fn Predicate) bool { + for _, v := range values { + if fn(v) { + return false + } + } + return true +} + +// Chunk splits a slice into chunks of the given size. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4, 5} +// result := Chunk(numbers, 2) // [[1,2], [3,4], [5]] +func Chunk(values []interface{}, size int) [][]interface{} { + if size <= 0 { + return nil + } + var chunks [][]interface{} + for i := 0; i < len(values); i += size { + end := i + size + if end > len(values) { + end = len(values) + } + chunks = append(chunks, values[i:end]) + } + return chunks +} + +// Find returns the first element that satisfies the predicate and a boolean indicating if an element was found. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4} +// isEven := func(v interface{}) bool { return v.(int)%2 == 0 } +// result, found := Find(numbers, isEven) // 2, true +func Find(values []interface{}, fn Predicate) (interface{}, bool) { + for _, v := range values { + if fn(v) { + return v, true + } + } + return nil, false +} + +// Reverse reverses the order of elements in a slice. +// +// Example: +// +// numbers := []interface{}{1, 2, 3} +// result := Reverse(numbers) // [3, 2, 1] +func Reverse(values []interface{}) []interface{} { + result := make([]interface{}, len(values)) + for i, v := range values { + result[len(values)-1-i] = v + } + return result +} + +// Zip combines two slices into a slice of pairs. If the slices have different lengths, +// extra elements from the longer slice are ignored. +// +// Example: +// +// a := []interface{}{1, 2, 3} +// b := []interface{}{"a", "b", "c"} +// result := Zip(a, b) // [[1,"a"], [2,"b"], [3,"c"]] +func Zip(a, b []interface{}) [][2]interface{} { + length := min(len(a), len(b)) + result := make([][2]interface{}, length) + for i := 0; i < length; i++ { + result[i] = [2]interface{}{a[i], b[i]} + } + return result +} + +// Unzip splits a slice of pairs into two separate slices. +// +// Example: +// +// pairs := [][2]interface{}{{1,"a"}, {2,"b"}, {3,"c"}} +// numbers, letters := Unzip(pairs) // [1,2,3], ["a","b","c"] +func Unzip(pairs [][2]interface{}) ([]interface{}, []interface{}) { + a := make([]interface{}, len(pairs)) + b := make([]interface{}, len(pairs)) + for i, pair := range pairs { + a[i] = pair[0] + b[i] = pair[1] + } + return a, b +} + +// GroupBy groups elements based on a key returned by a Mapper. +// +// Example: +// +// numbers := []interface{}{1, 2, 3, 4, 5, 6} +// byMod3 := func(v interface{}) interface{} { return v.(int) % 3 } +// result := GroupBy(numbers, byMod3) // {0: [3,6], 1: [1,4], 2: [2,5]} +func GroupBy(values []interface{}, fn Mapper) map[interface{}][]interface{} { + result := make(map[interface{}][]interface{}) + for _, v := range values { + key := fn(v) + result[key] = append(result[key], v) + } + return result +} + +// Flatten flattens a slice of slices into a single slice. +// +// Example: +// +// nested := [][]interface{}{{1,2}, {3,4}, {5}} +// result := Flatten(nested) // [1,2,3,4,5] +func Flatten(values [][]interface{}) []interface{} { + result := []interface{}{} + for _, v := range values { + result = append(result, v...) + } + return result +} + +// Helper functions +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/examples/gno.land/p/moul/fp/fp_test.gno b/examples/gno.land/p/moul/fp/fp_test.gno new file mode 100644 index 00000000000..00957486fe9 --- /dev/null +++ b/examples/gno.land/p/moul/fp/fp_test.gno @@ -0,0 +1,666 @@ +package fp + +import ( + "fmt" + "testing" +) + +func TestMap(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) interface{} + expected []interface{} + }{ + { + name: "multiply numbers by 2", + input: []interface{}{1, 2, 3}, + fn: func(v interface{}) interface{} { return v.(int) * 2 }, + expected: []interface{}{2, 4, 6}, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) interface{} { return v.(int) * 2 }, + expected: []interface{}{}, + }, + { + name: "convert numbers to strings", + input: []interface{}{1, 2, 3}, + fn: func(v interface{}) interface{} { return fmt.Sprintf("%d", v.(int)) }, + expected: []interface{}{"1", "2", "3"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Map(tt.input, tt.fn) + if !equalSlices(result, tt.expected) { + t.Errorf("Map failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestFilter(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) bool + expected []interface{} + }{ + { + name: "filter even numbers", + input: []interface{}{1, 2, 3, 4}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{2, 4}, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{}, + }, + { + name: "no matches", + input: []interface{}{1, 3, 5}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{}, + }, + { + name: "all matches", + input: []interface{}{2, 4, 6}, + fn: func(v interface{}) bool { return v.(int)%2 == 0 }, + expected: []interface{}{2, 4, 6}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Filter(tt.input, tt.fn) + if !equalSlices(result, tt.expected) { + t.Errorf("Filter failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestReduce(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}, interface{}) interface{} + initial interface{} + expected interface{} + }{ + { + name: "sum numbers", + input: []interface{}{1, 2, 3}, + fn: func(a, b interface{}) interface{} { return a.(int) + b.(int) }, + initial: 0, + expected: 6, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(a, b interface{}) interface{} { return a.(int) + b.(int) }, + initial: 0, + expected: 0, + }, + { + name: "concatenate strings", + input: []interface{}{"a", "b", "c"}, + fn: func(a, b interface{}) interface{} { return a.(string) + b.(string) }, + initial: "", + expected: "abc", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Reduce(tt.input, tt.fn, tt.initial) + if result != tt.expected { + t.Errorf("Reduce failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestFlatMap(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) interface{} + expected []interface{} + }{ + { + name: "split words into chars", + input: []interface{}{"go", "fn"}, + fn: func(word interface{}) interface{} { + chars := []interface{}{} + for _, c := range word.(string) { + chars = append(chars, string(c)) + } + return chars + }, + expected: []interface{}{"g", "o", "f", "n"}, + }, + { + name: "empty string handling", + input: []interface{}{"", "a", ""}, + fn: func(word interface{}) interface{} { + chars := []interface{}{} + for _, c := range word.(string) { + chars = append(chars, string(c)) + } + return chars + }, + expected: []interface{}{"a"}, + }, + { + name: "nil handling", + input: []interface{}{nil, "a", nil}, + fn: func(word interface{}) interface{} { + if word == nil { + return []interface{}{} + } + return []interface{}{word} + }, + expected: []interface{}{"a"}, + }, + { + name: "empty slice result", + input: []interface{}{"", "", ""}, + fn: func(word interface{}) interface{} { + return []interface{}{} + }, + expected: []interface{}{}, + }, + { + name: "nested array flattening", + input: []interface{}{1, 2, 3}, + fn: func(n interface{}) interface{} { + return []interface{}{n, n} + }, + expected: []interface{}{1, 1, 2, 2, 3, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := FlatMap(tt.input, tt.fn) + if !equalSlices(result, tt.expected) { + t.Errorf("FlatMap failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestAllAnyNone(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) bool + expectedAll bool + expectedAny bool + expectedNone bool + }{ + { + name: "all even numbers", + input: []interface{}{2, 4, 6, 8}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: true, + expectedAny: true, + expectedNone: false, + }, + { + name: "no even numbers", + input: []interface{}{1, 3, 5, 7}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: false, + expectedAny: false, + expectedNone: true, + }, + { + name: "mixed even/odd numbers", + input: []interface{}{1, 2, 3, 4}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: false, + expectedAny: true, + expectedNone: false, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(x interface{}) bool { return x.(int)%2 == 0 }, + expectedAll: true, // vacuously true + expectedAny: false, // vacuously false + expectedNone: true, // vacuously true + }, + { + name: "nil predicate handling", + input: []interface{}{nil, nil, nil}, + fn: func(x interface{}) bool { return x == nil }, + expectedAll: true, + expectedAny: true, + expectedNone: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + resultAll := All(tt.input, tt.fn) + if resultAll != tt.expectedAll { + t.Errorf("All failed, expected %v, got %v", tt.expectedAll, resultAll) + } + + resultAny := Any(tt.input, tt.fn) + if resultAny != tt.expectedAny { + t.Errorf("Any failed, expected %v, got %v", tt.expectedAny, resultAny) + } + + resultNone := None(tt.input, tt.fn) + if resultNone != tt.expectedNone { + t.Errorf("None failed, expected %v, got %v", tt.expectedNone, resultNone) + } + }) + } +} + +func TestChunk(t *testing.T) { + tests := []struct { + name string + input []interface{} + size int + expected [][]interface{} + }{ + { + name: "normal chunks", + input: []interface{}{1, 2, 3, 4, 5}, + size: 2, + expected: [][]interface{}{{1, 2}, {3, 4}, {5}}, + }, + { + name: "empty slice", + input: []interface{}{}, + size: 2, + expected: [][]interface{}{}, + }, + { + name: "chunk size equals length", + input: []interface{}{1, 2, 3}, + size: 3, + expected: [][]interface{}{{1, 2, 3}}, + }, + { + name: "chunk size larger than length", + input: []interface{}{1, 2}, + size: 3, + expected: [][]interface{}{{1, 2}}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Chunk(tt.input, tt.size) + if !equalNestedSlices(result, tt.expected) { + t.Errorf("Chunk failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestFind(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) bool + expected interface{} + shouldFound bool + }{ + { + name: "find first number greater than 2", + input: []interface{}{1, 2, 3, 4}, + fn: func(v interface{}) bool { return v.(int) > 2 }, + expected: 3, + shouldFound: true, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) bool { return v.(int) > 2 }, + expected: nil, + shouldFound: false, + }, + { + name: "no match", + input: []interface{}{1, 2}, + fn: func(v interface{}) bool { return v.(int) > 10 }, + expected: nil, + shouldFound: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, found := Find(tt.input, tt.fn) + if found != tt.shouldFound { + t.Errorf("Find failed, expected found=%v, got found=%v", tt.shouldFound, found) + } + if found && result != tt.expected { + t.Errorf("Find failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestReverse(t *testing.T) { + tests := []struct { + name string + input []interface{} + expected []interface{} + }{ + { + name: "normal sequence", + input: []interface{}{1, 2, 3, 4}, + expected: []interface{}{4, 3, 2, 1}, + }, + { + name: "empty slice", + input: []interface{}{}, + expected: []interface{}{}, + }, + { + name: "single element", + input: []interface{}{1}, + expected: []interface{}{1}, + }, + { + name: "mixed types", + input: []interface{}{1, "a", true, 2.5}, + expected: []interface{}{2.5, true, "a", 1}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Reverse(tt.input) + if !equalSlices(result, tt.expected) { + t.Errorf("Reverse failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestZipUnzip(t *testing.T) { + tests := []struct { + name string + a []interface{} + b []interface{} + expectedZip [][2]interface{} + expectedA []interface{} + expectedB []interface{} + }{ + { + name: "normal case", + a: []interface{}{1, 2, 3}, + b: []interface{}{"a", "b", "c"}, + expectedZip: [][2]interface{}{{1, "a"}, {2, "b"}, {3, "c"}}, + expectedA: []interface{}{1, 2, 3}, + expectedB: []interface{}{"a", "b", "c"}, + }, + { + name: "empty slices", + a: []interface{}{}, + b: []interface{}{}, + expectedZip: [][2]interface{}{}, + expectedA: []interface{}{}, + expectedB: []interface{}{}, + }, + { + name: "different lengths - a shorter", + a: []interface{}{1, 2}, + b: []interface{}{"a", "b", "c"}, + expectedZip: [][2]interface{}{{1, "a"}, {2, "b"}}, + expectedA: []interface{}{1, 2}, + expectedB: []interface{}{"a", "b"}, + }, + { + name: "different lengths - b shorter", + a: []interface{}{1, 2, 3}, + b: []interface{}{"a"}, + expectedZip: [][2]interface{}{{1, "a"}}, + expectedA: []interface{}{1}, + expectedB: []interface{}{"a"}, + }, + { + name: "mixed types", + a: []interface{}{1, true, "x"}, + b: []interface{}{2.5, false, "y"}, + expectedZip: [][2]interface{}{{1, 2.5}, {true, false}, {"x", "y"}}, + expectedA: []interface{}{1, true, "x"}, + expectedB: []interface{}{2.5, false, "y"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + zipped := Zip(tt.a, tt.b) + if len(zipped) != len(tt.expectedZip) { + t.Errorf("Zip failed, expected length %v, got %v", len(tt.expectedZip), len(zipped)) + } + for i, pair := range zipped { + if pair[0] != tt.expectedZip[i][0] || pair[1] != tt.expectedZip[i][1] { + t.Errorf("Zip failed at index %d, expected %v, got %v", i, tt.expectedZip[i], pair) + } + } + + unzippedA, unzippedB := Unzip(zipped) + if !equalSlices(unzippedA, tt.expectedA) { + t.Errorf("Unzip failed for slice A, expected %v, got %v", tt.expectedA, unzippedA) + } + if !equalSlices(unzippedB, tt.expectedB) { + t.Errorf("Unzip failed for slice B, expected %v, got %v", tt.expectedB, unzippedB) + } + }) + } +} + +func TestGroupBy(t *testing.T) { + tests := []struct { + name string + input []interface{} + fn func(interface{}) interface{} + expected map[interface{}][]interface{} + }{ + { + name: "group by even/odd", + input: []interface{}{1, 2, 3, 4, 5, 6}, + fn: func(v interface{}) interface{} { return v.(int) % 2 }, + expected: map[interface{}][]interface{}{ + 0: {2, 4, 6}, + 1: {1, 3, 5}, + }, + }, + { + name: "empty slice", + input: []interface{}{}, + fn: func(v interface{}) interface{} { return v.(int) % 2 }, + expected: map[interface{}][]interface{}{}, + }, + { + name: "single group", + input: []interface{}{2, 4, 6}, + fn: func(v interface{}) interface{} { return v.(int) % 2 }, + expected: map[interface{}][]interface{}{ + 0: {2, 4, 6}, + }, + }, + { + name: "group by type", + input: []interface{}{1, "a", 2, "b", true}, + fn: func(v interface{}) interface{} { + switch v.(type) { + case int: + return "int" + case string: + return "string" + default: + return "other" + } + }, + expected: map[interface{}][]interface{}{ + "int": {1, 2}, + "string": {"a", "b"}, + "other": {true}, + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := GroupBy(tt.input, tt.fn) + if len(result) != len(tt.expected) { + t.Errorf("GroupBy failed, expected %d groups, got %d", len(tt.expected), len(result)) + } + for k, v := range tt.expected { + if !equalSlices(result[k], v) { + t.Errorf("GroupBy failed for key %v, expected %v, got %v", k, v, result[k]) + } + } + }) + } +} + +func TestFlatten(t *testing.T) { + tests := []struct { + name string + input [][]interface{} + expected []interface{} + }{ + { + name: "normal nested slices", + input: [][]interface{}{{1, 2}, {3, 4}, {5}}, + expected: []interface{}{1, 2, 3, 4, 5}, + }, + { + name: "empty outer slice", + input: [][]interface{}{}, + expected: []interface{}{}, + }, + { + name: "empty inner slices", + input: [][]interface{}{{}, {}, {}}, + expected: []interface{}{}, + }, + { + name: "mixed types", + input: [][]interface{}{{1, "a"}, {true, 2.5}, {nil}}, + expected: []interface{}{1, "a", true, 2.5, nil}, + }, + { + name: "single element slices", + input: [][]interface{}{{1}, {2}, {3}}, + expected: []interface{}{1, 2, 3}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := Flatten(tt.input) + if !equalSlices(result, tt.expected) { + t.Errorf("Flatten failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +func TestContains(t *testing.T) { + tests := []struct { + name string + slice []interface{} + item interface{} + expected bool + }{ + { + name: "contains integer", + slice: []interface{}{1, 2, 3}, + item: 2, + expected: true, + }, + { + name: "does not contain integer", + slice: []interface{}{1, 2, 3}, + item: 4, + expected: false, + }, + { + name: "contains string", + slice: []interface{}{"a", "b", "c"}, + item: "b", + expected: true, + }, + { + name: "empty slice", + slice: []interface{}{}, + item: 1, + expected: false, + }, + { + name: "contains nil", + slice: []interface{}{1, nil, 3}, + item: nil, + expected: true, + }, + { + name: "mixed types", + slice: []interface{}{1, "a", true}, + item: true, + expected: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := contains(tt.slice, tt.item) + if result != tt.expected { + t.Errorf("contains failed, expected %v, got %v", tt.expected, result) + } + }) + } +} + +// Helper function for testing +func contains(slice []interface{}, item interface{}) bool { + for _, v := range slice { + if v == item { + return true + } + } + return false +} + +// Helper functions for comparing slices +func equalSlices(a, b []interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} + +func equalNestedSlices(a, b [][]interface{}) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if !equalSlices(a[i], b[i]) { + return false + } + } + return true +} diff --git a/examples/gno.land/p/moul/fp/gno.mod b/examples/gno.land/p/moul/fp/gno.mod new file mode 100644 index 00000000000..905fa0f1c0e --- /dev/null +++ b/examples/gno.land/p/moul/fp/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/fp diff --git a/examples/gno.land/p/moul/memo/gno.mod b/examples/gno.land/p/moul/memo/gno.mod new file mode 100644 index 00000000000..4a9948c30f7 --- /dev/null +++ b/examples/gno.land/p/moul/memo/gno.mod @@ -0,0 +1 @@ +module gno.land/p/moul/memo diff --git a/examples/gno.land/p/moul/memo/memo.gno b/examples/gno.land/p/moul/memo/memo.gno new file mode 100644 index 00000000000..e31f13aab15 --- /dev/null +++ b/examples/gno.land/p/moul/memo/memo.gno @@ -0,0 +1,134 @@ +// Package memo provides a simple memoization utility to cache function results. +// +// The package offers a Memoizer type that can cache function results based on keys, +// with optional validation of cached values. This is useful for expensive computations +// that need to be cached and potentially invalidated based on custom conditions. +// +// /!\ Important Warning for Gno Usage: +// In Gno, storage updates only persist during transactions. This means: +// - Cache entries created during queries will NOT persist +// - Creating cache entries during queries will actually decrease performance +// as it wastes resources trying to save data that won't be saved +// +// Best Practices: +// - Use this pattern in transaction-driven contexts rather than query/render scenarios +// - Consider controlled cache updates, e.g., by specific accounts (like oracles) +// - Ideal for cases where cache updates happen every N blocks or on specific events +// - Carefully evaluate if caching will actually improve performance in your use case +// +// Basic usage example: +// +// m := memo.New() +// +// // Cache expensive computation +// result := m.Memoize("key", func() interface{} { +// // expensive operation +// return "computed-value" +// }) +// +// // Subsequent calls with same key return cached result +// result = m.Memoize("key", func() interface{} { +// // function won't be called, cached value is returned +// return "computed-value" +// }) +// +// Example with validation: +// +// type TimestampedValue struct { +// Value string +// Timestamp time.Time +// } +// +// m := memo.New() +// +// // Cache value with timestamp +// result := m.MemoizeWithValidator( +// "key", +// func() interface{} { +// return TimestampedValue{ +// Value: "data", +// Timestamp: time.Now(), +// } +// }, +// func(cached interface{}) bool { +// // Validate that the cached value is not older than 1 hour +// if tv, ok := cached.(TimestampedValue); ok { +// return time.Since(tv.Timestamp) < time.Hour +// } +// return false +// }, +// ) +package memo + +import ( + "gno.land/p/demo/btree" + "gno.land/p/demo/ufmt" +) + +// Record implements the btree.Record interface for our cache entries +type cacheEntry struct { + key interface{} + value interface{} +} + +// Less implements btree.Record interface +func (e cacheEntry) Less(than btree.Record) bool { + // Convert the other record to cacheEntry + other := than.(cacheEntry) + // Compare string representations of keys for consistent ordering + return ufmt.Sprintf("%v", e.key) < ufmt.Sprintf("%v", other.key) +} + +// Memoizer is a structure to handle memoization of function results. +type Memoizer struct { + cache *btree.BTree +} + +// New creates a new Memoizer instance. +func New() *Memoizer { + return &Memoizer{ + cache: btree.New(), + } +} + +// Memoize ensures the result of the given function is cached for the specified key. +func (m *Memoizer) Memoize(key interface{}, fn func() interface{}) interface{} { + entry := cacheEntry{key: key} + if found := m.cache.Get(entry); found != nil { + return found.(cacheEntry).value + } + + value := fn() + m.cache.Insert(cacheEntry{key: key, value: value}) + return value +} + +// MemoizeWithValidator ensures the result is cached and valid according to the validator function. +func (m *Memoizer) MemoizeWithValidator(key interface{}, fn func() interface{}, isValid func(interface{}) bool) interface{} { + entry := cacheEntry{key: key} + if found := m.cache.Get(entry); found != nil { + cachedEntry := found.(cacheEntry) + if isValid(cachedEntry.value) { + return cachedEntry.value + } + } + + value := fn() + m.cache.Insert(cacheEntry{key: key, value: value}) + return value +} + +// Invalidate removes the cached value for the specified key. +func (m *Memoizer) Invalidate(key interface{}) { + m.cache.Delete(cacheEntry{key: key}) +} + +// Clear clears all cached values. +func (m *Memoizer) Clear() { + m.cache.Clear(true) +} + +// Size returns the number of items currently in the cache. +func (m *Memoizer) Size() int { + return m.cache.Len() +} diff --git a/examples/gno.land/p/moul/memo/memo_test.gno b/examples/gno.land/p/moul/memo/memo_test.gno new file mode 100644 index 00000000000..44dde5df640 --- /dev/null +++ b/examples/gno.land/p/moul/memo/memo_test.gno @@ -0,0 +1,449 @@ +package memo + +import ( + "std" + "testing" + "time" +) + +type timestampedValue struct { + value interface{} + timestamp time.Time +} + +// complexKey is used to test struct keys +type complexKey struct { + ID int + Name string +} + +func TestMemoize(t *testing.T) { + tests := []struct { + name string + key interface{} + value interface{} + callCount *int + }{ + { + name: "string key and value", + key: "test-key", + value: "test-value", + callCount: new(int), + }, + { + name: "int key and value", + key: 42, + value: 123, + callCount: new(int), + }, + { + name: "mixed types", + key: "number", + value: 42, + callCount: new(int), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + if m.Size() != 0 { + t.Errorf("Initial size = %d, want 0", m.Size()) + } + + fn := func() interface{} { + *tt.callCount++ + return tt.value + } + + // First call should compute + result := m.Memoize(tt.key, fn) + if result != tt.value { + t.Errorf("Memoize() = %v, want %v", result, tt.value) + } + if *tt.callCount != 1 { + t.Errorf("Function called %d times, want 1", *tt.callCount) + } + if m.Size() != 1 { + t.Errorf("Size after first call = %d, want 1", m.Size()) + } + + // Second call should use cache + result = m.Memoize(tt.key, fn) + if result != tt.value { + t.Errorf("Memoize() second call = %v, want %v", result, tt.value) + } + if *tt.callCount != 1 { + t.Errorf("Function called %d times, want 1", *tt.callCount) + } + if m.Size() != 1 { + t.Errorf("Size after second call = %d, want 1", m.Size()) + } + }) + } +} + +func TestMemoizeWithValidator(t *testing.T) { + tests := []struct { + name string + key interface{} + value interface{} + validDuration time.Duration + waitDuration time.Duration + expectedCalls int + shouldRecompute bool + }{ + { + name: "valid cache", + key: "key1", + value: "value1", + validDuration: time.Hour, + waitDuration: time.Millisecond, + expectedCalls: 1, + shouldRecompute: false, + }, + { + name: "expired cache", + key: "key2", + value: "value2", + validDuration: time.Millisecond, + waitDuration: time.Millisecond * 2, + expectedCalls: 2, + shouldRecompute: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + callCount := 0 + + fn := func() interface{} { + callCount++ + return timestampedValue{ + value: tt.value, + timestamp: time.Now(), + } + } + + isValid := func(cached interface{}) bool { + if tv, ok := cached.(timestampedValue); ok { + return time.Since(tv.timestamp) < tt.validDuration + } + return false + } + + // First call + result := m.MemoizeWithValidator(tt.key, fn, isValid) + if tv, ok := result.(timestampedValue); !ok || tv.value != tt.value { + t.Errorf("MemoizeWithValidator() = %v, want value %v", result, tt.value) + } + + // Wait + std.TestSkipHeights(10) + + // Second call + result = m.MemoizeWithValidator(tt.key, fn, isValid) + if tv, ok := result.(timestampedValue); !ok || tv.value != tt.value { + t.Errorf("MemoizeWithValidator() second call = %v, want value %v", result, tt.value) + } + + if callCount != tt.expectedCalls { + t.Errorf("Function called %d times, want %d", callCount, tt.expectedCalls) + } + }) + } +} + +func TestInvalidate(t *testing.T) { + tests := []struct { + name string + key interface{} + value interface{} + callCount *int + }{ + { + name: "invalidate existing key", + key: "test-key", + value: "test-value", + callCount: new(int), + }, + { + name: "invalidate non-existing key", + key: "missing-key", + value: "test-value", + callCount: new(int), + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + fn := func() interface{} { + *tt.callCount++ + return tt.value + } + + // First call + m.Memoize(tt.key, fn) + if m.Size() != 1 { + t.Errorf("Size after first call = %d, want 1", m.Size()) + } + + // Invalidate + m.Invalidate(tt.key) + if m.Size() != 0 { + t.Errorf("Size after invalidate = %d, want 0", m.Size()) + } + + // Call again should recompute + result := m.Memoize(tt.key, fn) + if result != tt.value { + t.Errorf("Memoize() after invalidate = %v, want %v", result, tt.value) + } + if *tt.callCount != 2 { + t.Errorf("Function called %d times, want 2", *tt.callCount) + } + if m.Size() != 1 { + t.Errorf("Size after recompute = %d, want 1", m.Size()) + } + }) + } +} + +func TestClear(t *testing.T) { + m := New() + callCount := 0 + + fn := func() interface{} { + callCount++ + return "value" + } + + // Cache some values + m.Memoize("key1", fn) + m.Memoize("key2", fn) + + if callCount != 2 { + t.Errorf("Initial calls = %d, want 2", callCount) + } + if m.Size() != 2 { + t.Errorf("Size after initial calls = %d, want 2", m.Size()) + } + + // Clear cache + m.Clear() + if m.Size() != 0 { + t.Errorf("Size after clear = %d, want 0", m.Size()) + } + + // Recompute values + m.Memoize("key1", fn) + m.Memoize("key2", fn) + + if callCount != 4 { + t.Errorf("Calls after clear = %d, want 4", callCount) + } + if m.Size() != 2 { + t.Errorf("Size after recompute = %d, want 2", m.Size()) + } +} + +func TestSize(t *testing.T) { + m := New() + + if m.Size() != 0 { + t.Errorf("Initial size = %d, want 0", m.Size()) + } + + callCount := 0 + fn := func() interface{} { + callCount++ + return "value" + } + + // Add items + m.Memoize("key1", fn) + if m.Size() != 1 { + t.Errorf("Size after first insert = %d, want 1", m.Size()) + } + + m.Memoize("key2", fn) + if m.Size() != 2 { + t.Errorf("Size after second insert = %d, want 2", m.Size()) + } + + // Duplicate key should not increase size + m.Memoize("key1", fn) + if m.Size() != 2 { + t.Errorf("Size after duplicate insert = %d, want 2", m.Size()) + } + + // Remove item + m.Invalidate("key1") + if m.Size() != 1 { + t.Errorf("Size after invalidate = %d, want 1", m.Size()) + } + + // Clear all + m.Clear() + if m.Size() != 0 { + t.Errorf("Size after clear = %d, want 0", m.Size()) + } +} + +func TestMemoizeWithDifferentKeyTypes(t *testing.T) { + tests := []struct { + name string + keys []interface{} // Now an array of keys + values []string // Corresponding values + callCount *int + }{ + { + name: "integer keys", + keys: []interface{}{42, 43}, + values: []string{"value-for-42", "value-for-43"}, + callCount: new(int), + }, + { + name: "float keys", + keys: []interface{}{3.14, 2.718}, + values: []string{"value-for-pi", "value-for-e"}, + callCount: new(int), + }, + { + name: "bool keys", + keys: []interface{}{true, false}, + values: []string{"value-for-true", "value-for-false"}, + callCount: new(int), + }, + /* + { + name: "struct keys", + keys: []interface{}{ + complexKey{ID: 1, Name: "test1"}, + complexKey{ID: 2, Name: "test2"}, + }, + values: []string{"value-for-struct1", "value-for-struct2"}, + callCount: new(int), + }, + { + name: "nil and empty interface keys", + keys: []interface{}{nil, interface{}(nil)}, + values: []string{"value-for-nil", "value-for-empty-interface"}, + callCount: new(int), + }, + */ + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + m := New() + + // Test both keys + for i, key := range tt.keys { + value := tt.values[i] + fn := func() interface{} { + *tt.callCount++ + return value + } + + // First call should compute + result := m.Memoize(key, fn) + if result != value { + t.Errorf("Memoize() for key %v = %v, want %v", key, result, value) + } + if *tt.callCount != i+1 { + t.Errorf("Function called %d times, want %d", *tt.callCount, i+1) + } + } + + // Verify size includes both entries + if m.Size() != 2 { + t.Errorf("Size after both inserts = %d, want 2", m.Size()) + } + + // Second call for each key should use cache + for i, key := range tt.keys { + initialCount := *tt.callCount + result := m.Memoize(key, func() interface{} { + *tt.callCount++ + return "should-not-be-called" + }) + + if result != tt.values[i] { + t.Errorf("Memoize() second call for key %v = %v, want %v", key, result, tt.values[i]) + } + if *tt.callCount != initialCount { + t.Errorf("Cache miss for key %v", key) + } + } + + // Test invalidate for each key + for i, key := range tt.keys { + m.Invalidate(key) + if m.Size() != 1-i { + t.Errorf("Size after invalidate %d = %d, want %d", i+1, m.Size(), 1-i) + } + } + }) + } +} + +func TestMultipleKeyTypes(t *testing.T) { + m := New() + callCount := 0 + + // Insert different key types simultaneously (two of each type) + keys := []interface{}{ + 42, 43, // ints + "string-key1", "string-key2", // strings + 3.14, 2.718, // floats + true, false, // bools + } + + for i, key := range keys { + value := i + m.Memoize(key, func() interface{} { + callCount++ + return value + }) + } + + // Verify size includes all entries + if m.Size() != len(keys) { + t.Errorf("Size = %d, want %d", m.Size(), len(keys)) + } + + // Verify all values are cached correctly + for i, key := range keys { + initialCount := callCount + result := m.Memoize(key, func() interface{} { + callCount++ + return -1 // Should never be returned if cache works + }) + + if result != i { + t.Errorf("Memoize(%v) = %v, want %v", key, result, i) + } + if callCount != initialCount { + t.Errorf("Cache miss for key %v", key) + } + } + + // Test invalidation of pairs + for i := 0; i < len(keys); i += 2 { + m.Invalidate(keys[i]) + m.Invalidate(keys[i+1]) + expectedSize := len(keys) - (i + 2) + if m.Size() != expectedSize { + t.Errorf("Size after invalidating pair %d = %d, want %d", i/2, m.Size(), expectedSize) + } + } + + // Clear and verify + m.Clear() + if m.Size() != 0 { + t.Errorf("Size after clear = %d, want 0", m.Size()) + } +} diff --git a/gno.land/pkg/gnoclient/integration_test.go b/gno.land/pkg/gnoclient/integration_test.go index 4b70fb60c49..d3d4e0d2c52 100644 --- a/gno.land/pkg/gnoclient/integration_test.go +++ b/gno.land/pkg/gnoclient/integration_test.go @@ -1,10 +1,12 @@ package gnoclient import ( + "path/filepath" "testing" "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gno.land/pkg/gnoland" "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" "github.com/gnolang/gno/gno.land/pkg/integration" "github.com/gnolang/gno/gno.land/pkg/sdk/vm" @@ -21,8 +23,14 @@ import ( ) func TestCallSingle_Integration(t *testing.T) { - // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + // Setup packages + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) + meta := loadpkgs(t, rootdir, "gno.land/r/demo/deep/very/deep") + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -74,8 +82,14 @@ func TestCallSingle_Integration(t *testing.T) { } func TestCallMultiple_Integration(t *testing.T) { - // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + // Setup packages + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) + meta := loadpkgs(t, rootdir, "gno.land/r/demo/deep/very/deep") + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -137,7 +151,7 @@ func TestCallMultiple_Integration(t *testing.T) { func TestSendSingle_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -201,7 +215,7 @@ func TestSendSingle_Integration(t *testing.T) { func TestSendMultiple_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -273,8 +287,15 @@ func TestSendMultiple_Integration(t *testing.T) { // Run tests func TestRunSingle_Integration(t *testing.T) { + // Setup packages + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) + meta := loadpkgs(t, rootdir, "gno.land/p/demo/ufmt", "gno.land/r/demo/tests") + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -342,7 +363,17 @@ func main() { // Run tests func TestRunMultiple_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + rootdir := gnoenv.RootDir() + config := integration.TestingMinimalNodeConfig(rootdir) + meta := loadpkgs(t, rootdir, + "gno.land/p/demo/ufmt", + "gno.land/r/demo/tests", + "gno.land/r/demo/deep/very/deep", + ) + state := config.Genesis.AppState.(gnoland.GnoGenesisState) + state.Txs = append(state.Txs, meta...) + config.Genesis.AppState = state + node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -434,7 +465,7 @@ func main() { func TestAddPackageSingle_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -519,7 +550,7 @@ func Echo(str string) string { func TestAddPackageMultiple_Integration(t *testing.T) { // Set up in-memory node - config, _ := integration.TestingNodeConfig(t, gnoenv.RootDir()) + config := integration.TestingMinimalNodeConfig(gnoenv.RootDir()) node, remoteAddr := integration.TestingInMemoryNode(t, log.NewNoopLogger(), config) defer node.Stop() @@ -670,3 +701,24 @@ func newInMemorySigner(t *testing.T, chainid string) *SignerFromKeybase { ChainID: chainid, // Chain ID for transaction signing } } + +func loadpkgs(t *testing.T, rootdir string, paths ...string) []gnoland.TxWithMetadata { + t.Helper() + + loader := integration.NewPkgsLoader() + examplesDir := filepath.Join(rootdir, "examples") + for _, path := range paths { + path = filepath.Clean(path) + path = filepath.Join(examplesDir, path) + err := loader.LoadPackage(examplesDir, path, "") + require.NoErrorf(t, err, "`loadpkg` unable to load package(s) from %q: %s", path, err) + } + + creator := crypto.MustAddressFromString(integration.DefaultAccount_Address) + defaultFee := std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) + + meta, err := loader.LoadPackages(creator, defaultFee, nil) + require.NoError(t, err) + + return meta +} diff --git a/gno.land/pkg/gnoland/node_inmemory.go b/gno.land/pkg/gnoland/node_inmemory.go index f42166411c8..a89f39b0b4a 100644 --- a/gno.land/pkg/gnoland/node_inmemory.go +++ b/gno.land/pkg/gnoland/node_inmemory.go @@ -23,8 +23,8 @@ type InMemoryNodeConfig struct { PrivValidator bft.PrivValidator // identity of the validator Genesis *bft.GenesisDoc TMConfig *tmcfg.Config - DB *memdb.MemDB // will be initialized if nil - VMOutput io.Writer // optional + DB db.DB // will be initialized if nil + VMOutput io.Writer // optional // If StdlibDir not set, then it's filepath.Join(TMConfig.RootDir, "gnovm", "stdlibs") InitChainerConfig diff --git a/gno.land/pkg/integration/doc.go b/gno.land/pkg/integration/doc.go index ef3ed9923da..3e09d627c9a 100644 --- a/gno.land/pkg/integration/doc.go +++ b/gno.land/pkg/integration/doc.go @@ -76,13 +76,6 @@ // // Input: // -// - LOG_LEVEL: -// The logging level to be used, which can be one of "error", "debug", "info", or an empty string. -// If empty, the log level defaults to "debug". -// -// - LOG_DIR: -// If set, logs will be directed to the specified directory. -// // - TESTWORK: // A boolean that, when enabled, retains working directories after tests for // inspection. If enabled, gnoland logs will be persisted inside this diff --git a/gno.land/pkg/integration/testing_node.go b/gno.land/pkg/integration/node_testing.go similarity index 86% rename from gno.land/pkg/integration/testing_node.go rename to gno.land/pkg/integration/node_testing.go index 7eaf3457b03..fdf94c8c545 100644 --- a/gno.land/pkg/integration/testing_node.go +++ b/gno.land/pkg/integration/node_testing.go @@ -55,7 +55,7 @@ func TestingInMemoryNode(t TestingTS, logger *slog.Logger, config *gnoland.InMem // with default packages and genesis transactions already loaded. // It will return the default creator address of the loaded packages. func TestingNodeConfig(t TestingTS, gnoroot string, additionalTxs ...gnoland.TxWithMetadata) (*gnoland.InMemoryNodeConfig, bft.Address) { - cfg := TestingMinimalNodeConfig(t, gnoroot) + cfg := TestingMinimalNodeConfig(gnoroot) creator := crypto.MustAddressFromString(DefaultAccount_Address) // test1 @@ -65,24 +65,24 @@ func TestingNodeConfig(t TestingTS, gnoroot string, additionalTxs ...gnoland.TxW txs = append(txs, LoadDefaultPackages(t, creator, gnoroot)...) txs = append(txs, additionalTxs...) - ggs := cfg.Genesis.AppState.(gnoland.GnoGenesisState) - ggs.Balances = balances - ggs.Txs = txs - ggs.Params = params - cfg.Genesis.AppState = ggs + cfg.Genesis.AppState = gnoland.GnoGenesisState{ + Balances: balances, + Txs: txs, + Params: params, + } return cfg, creator } // TestingMinimalNodeConfig constructs the default minimal in-memory node configuration for testing. -func TestingMinimalNodeConfig(t TestingTS, gnoroot string) *gnoland.InMemoryNodeConfig { +func TestingMinimalNodeConfig(gnoroot string) *gnoland.InMemoryNodeConfig { tmconfig := DefaultTestingTMConfig(gnoroot) // Create Mocked Identity pv := gnoland.NewMockedPrivValidator() // Generate genesis config - genesis := DefaultTestingGenesisConfig(t, gnoroot, pv.GetPubKey(), tmconfig) + genesis := DefaultTestingGenesisConfig(gnoroot, pv.GetPubKey(), tmconfig) return &gnoland.InMemoryNodeConfig{ PrivValidator: pv, @@ -96,16 +96,7 @@ func TestingMinimalNodeConfig(t TestingTS, gnoroot string) *gnoland.InMemoryNode } } -func DefaultTestingGenesisConfig(t TestingTS, gnoroot string, self crypto.PubKey, tmconfig *tmcfg.Config) *bft.GenesisDoc { - genState := gnoland.DefaultGenState() - genState.Balances = []gnoland.Balance{ - { - Address: crypto.MustAddressFromString(DefaultAccount_Address), - Amount: std.MustParseCoins(ugnot.ValueString(10000000000000)), - }, - } - genState.Txs = []gnoland.TxWithMetadata{} - genState.Params = []gnoland.Param{} +func DefaultTestingGenesisConfig(gnoroot string, self crypto.PubKey, tmconfig *tmcfg.Config) *bft.GenesisDoc { return &bft.GenesisDoc{ GenesisTime: time.Now(), ChainID: tmconfig.ChainID(), @@ -125,7 +116,16 @@ func DefaultTestingGenesisConfig(t TestingTS, gnoroot string, self crypto.PubKey Name: "self", }, }, - AppState: genState, + AppState: gnoland.GnoGenesisState{ + Balances: []gnoland.Balance{ + { + Address: crypto.MustAddressFromString(DefaultAccount_Address), + Amount: std.MustParseCoins(ugnot.ValueString(10_000_000_000_000)), + }, + }, + Txs: []gnoland.TxWithMetadata{}, + Params: []gnoland.Param{}, + }, } } @@ -178,8 +178,9 @@ func DefaultTestingTMConfig(gnoroot string) *tmcfg.Config { tmconfig := tmcfg.TestConfig().SetRootDir(gnoroot) tmconfig.Consensus.WALDisabled = true + tmconfig.Consensus.SkipTimeoutCommit = true tmconfig.Consensus.CreateEmptyBlocks = true - tmconfig.Consensus.CreateEmptyBlocksInterval = time.Duration(0) + tmconfig.Consensus.CreateEmptyBlocksInterval = time.Millisecond * 100 tmconfig.RPC.ListenAddress = defaultListner tmconfig.P2P.ListenAddress = defaultListner return tmconfig diff --git a/gno.land/pkg/integration/pkgloader.go b/gno.land/pkg/integration/pkgloader.go new file mode 100644 index 00000000000..541e24b96eb --- /dev/null +++ b/gno.land/pkg/integration/pkgloader.go @@ -0,0 +1,168 @@ +package integration + +import ( + "fmt" + "path/filepath" + "strings" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/gno.land/pkg/sdk/vm" + "github.com/gnolang/gno/gnovm/pkg/gnolang" + "github.com/gnolang/gno/gnovm/pkg/gnomod" + "github.com/gnolang/gno/gnovm/pkg/packages" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/std" +) + +type PkgsLoader struct { + pkgs []gnomod.Pkg + visited map[string]struct{} + + // list of occurrences to patchs with the given value + // XXX: find a better way + patchs map[string]string +} + +func NewPkgsLoader() *PkgsLoader { + return &PkgsLoader{ + pkgs: make([]gnomod.Pkg, 0), + visited: make(map[string]struct{}), + patchs: make(map[string]string), + } +} + +func (pl *PkgsLoader) List() gnomod.PkgList { + return pl.pkgs +} + +func (pl *PkgsLoader) SetPatch(replace, with string) { + pl.patchs[replace] = with +} + +func (pl *PkgsLoader) LoadPackages(creator bft.Address, fee std.Fee, deposit std.Coins) ([]gnoland.TxWithMetadata, error) { + pkgslist, err := pl.List().Sort() // sorts packages by their dependencies. + if err != nil { + return nil, fmt.Errorf("unable to sort packages: %w", err) + } + + txs := make([]gnoland.TxWithMetadata, len(pkgslist)) + for i, pkg := range pkgslist { + tx, err := gnoland.LoadPackage(pkg, creator, fee, deposit) + if err != nil { + return nil, fmt.Errorf("unable to load pkg %q: %w", pkg.Name, err) + } + + // If any replace value is specified, apply them + if len(pl.patchs) > 0 { + for _, msg := range tx.Msgs { + addpkg, ok := msg.(vm.MsgAddPackage) + if !ok { + continue + } + + if addpkg.Package == nil { + continue + } + + for _, file := range addpkg.Package.Files { + for replace, with := range pl.patchs { + file.Body = strings.ReplaceAll(file.Body, replace, with) + } + } + } + } + + txs[i] = gnoland.TxWithMetadata{ + Tx: tx, + } + } + + return txs, nil +} + +func (pl *PkgsLoader) LoadAllPackagesFromDir(path string) error { + // list all packages from target path + pkgslist, err := gnomod.ListPkgs(path) + if err != nil { + return fmt.Errorf("listing gno packages: %w", err) + } + + for _, pkg := range pkgslist { + if !pl.exist(pkg) { + pl.add(pkg) + } + } + + return nil +} + +func (pl *PkgsLoader) LoadPackage(modroot string, path, name string) error { + // Initialize a queue with the root package + queue := []gnomod.Pkg{{Dir: path, Name: name}} + + for len(queue) > 0 { + // Dequeue the first package + currentPkg := queue[0] + queue = queue[1:] + + if currentPkg.Dir == "" { + return fmt.Errorf("no path specified for package") + } + + if currentPkg.Name == "" { + // Load `gno.mod` information + gnoModPath := filepath.Join(currentPkg.Dir, "gno.mod") + gm, err := gnomod.ParseGnoMod(gnoModPath) + if err != nil { + return fmt.Errorf("unable to load %q: %w", gnoModPath, err) + } + gm.Sanitize() + + // Override package info with mod infos + currentPkg.Name = gm.Module.Mod.Path + currentPkg.Draft = gm.Draft + + pkg, err := gnolang.ReadMemPackage(currentPkg.Dir, currentPkg.Name) + if err != nil { + return fmt.Errorf("unable to read package at %q: %w", currentPkg.Dir, err) + } + imports, err := packages.Imports(pkg, nil) + if err != nil { + return fmt.Errorf("unable to load package imports in %q: %w", currentPkg.Dir, err) + } + for _, imp := range imports { + if imp.PkgPath == currentPkg.Name || gnolang.IsStdlib(imp.PkgPath) { + continue + } + currentPkg.Imports = append(currentPkg.Imports, imp.PkgPath) + } + } + + if currentPkg.Draft { + continue // Skip draft package + } + + if pl.exist(currentPkg) { + continue + } + pl.add(currentPkg) + + // Add requirements to the queue + for _, pkgPath := range currentPkg.Imports { + fullPath := filepath.Join(modroot, pkgPath) + queue = append(queue, gnomod.Pkg{Dir: fullPath}) + } + } + + return nil +} + +func (pl *PkgsLoader) add(pkg gnomod.Pkg) { + pl.visited[pkg.Name] = struct{}{} + pl.pkgs = append(pl.pkgs, pkg) +} + +func (pl *PkgsLoader) exist(pkg gnomod.Pkg) (ok bool) { + _, ok = pl.visited[pkg.Name] + return +} diff --git a/gno.land/pkg/integration/process.go b/gno.land/pkg/integration/process.go new file mode 100644 index 00000000000..839004ca1f3 --- /dev/null +++ b/gno.land/pkg/integration/process.go @@ -0,0 +1,451 @@ +package integration + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "log/slog" + "os" + "os/exec" + "os/signal" + "slices" + "sync" + "testing" + "time" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/tm2/pkg/amino" + tmcfg "github.com/gnolang/gno/tm2/pkg/bft/config" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" + "github.com/gnolang/gno/tm2/pkg/db" + "github.com/gnolang/gno/tm2/pkg/db/goleveldb" + "github.com/gnolang/gno/tm2/pkg/db/memdb" + "github.com/stretchr/testify/require" +) + +const gracefulShutdown = time.Second * 5 + +type ProcessNodeConfig struct { + ValidatorKey ed25519.PrivKeyEd25519 `json:"priv"` + Verbose bool `json:"verbose"` + DBDir string `json:"dbdir"` + RootDir string `json:"rootdir"` + Genesis *MarshalableGenesisDoc `json:"genesis"` + TMConfig *tmcfg.Config `json:"tm"` +} + +type ProcessConfig struct { + Node *ProcessNodeConfig + + // These parameters are not meant to be passed to the process + CoverDir string + Stderr, Stdout io.Writer +} + +func (i ProcessConfig) validate() error { + if i.Node.TMConfig == nil { + return errors.New("no tm config set") + } + + if i.Node.Genesis == nil { + return errors.New("no genesis is set") + } + + return nil +} + +// RunNode initializes and runs a gnoaland node with the provided configuration. +func RunNode(ctx context.Context, pcfg *ProcessNodeConfig, stdout, stderr io.Writer) error { + // Setup logger based on verbosity + var handler slog.Handler + if pcfg.Verbose { + handler = slog.NewTextHandler(stdout, &slog.HandlerOptions{Level: slog.LevelDebug}) + } else { + handler = slog.NewTextHandler(stdout, &slog.HandlerOptions{Level: slog.LevelError}) + } + logger := slog.New(handler) + + // Initialize database + db, err := initDatabase(pcfg.DBDir) + if err != nil { + return err + } + defer db.Close() // ensure db is close + + nodecfg := TestingMinimalNodeConfig(pcfg.RootDir) + + // Configure validator if provided + if len(pcfg.ValidatorKey) > 0 && !isAllZero(pcfg.ValidatorKey) { + nodecfg.PrivValidator = bft.NewMockPVWithParams(pcfg.ValidatorKey, false, false) + } + pv := nodecfg.PrivValidator.GetPubKey() + + // Setup node configuration + nodecfg.DB = db + nodecfg.TMConfig.DBPath = pcfg.DBDir + nodecfg.TMConfig = pcfg.TMConfig + nodecfg.Genesis = pcfg.Genesis.ToGenesisDoc() + nodecfg.Genesis.Validators = []bft.GenesisValidator{ + { + Address: pv.Address(), + PubKey: pv, + Power: 10, + Name: "self", + }, + } + + // Create and start the node + node, err := gnoland.NewInMemoryNode(logger, nodecfg) + if err != nil { + return fmt.Errorf("failed to create new in-memory node: %w", err) + } + + if err := node.Start(); err != nil { + return fmt.Errorf("failed to start node: %w", err) + } + defer node.Stop() + + // Determine if the node is a validator + ourAddress := nodecfg.PrivValidator.GetPubKey().Address() + isValidator := slices.ContainsFunc(nodecfg.Genesis.Validators, func(val bft.GenesisValidator) bool { + return val.Address == ourAddress + }) + + lisnAddress := node.Config().RPC.ListenAddress + if isValidator { + select { + case <-ctx.Done(): + return fmt.Errorf("waiting for the node to start: %w", ctx.Err()) + case <-node.Ready(): + } + } + + // Write READY signal to stdout + signalWriteReady(stdout, lisnAddress) + + <-ctx.Done() + return node.Stop() +} + +type NodeProcess interface { + Stop() error + Address() string +} + +type nodeProcess struct { + cmd *exec.Cmd + address string + + stopOnce sync.Once + stopErr error +} + +func (n *nodeProcess) Address() string { + return n.address +} + +func (n *nodeProcess) Stop() error { + n.stopOnce.Do(func() { + // Send SIGTERM to the process + if err := n.cmd.Process.Signal(os.Interrupt); err != nil { + n.stopErr = fmt.Errorf("error sending `SIGINT` to the node: %w", err) + return + } + + // Optionally wait for the process to exit + if _, err := n.cmd.Process.Wait(); err != nil { + n.stopErr = fmt.Errorf("process exited with error: %w", err) + return + } + }) + + return n.stopErr +} + +// RunNodeProcess runs the binary at the given path with the provided configuration. +func RunNodeProcess(ctx context.Context, cfg ProcessConfig, name string, args ...string) (NodeProcess, error) { + if cfg.Stdout == nil { + cfg.Stdout = os.Stdout + } + + if cfg.Stderr == nil { + cfg.Stderr = os.Stderr + } + + if err := cfg.validate(); err != nil { + return nil, err + } + + // Marshal the configuration to JSON + nodeConfigData, err := json.Marshal(cfg.Node) + if err != nil { + return nil, fmt.Errorf("failed to marshal config to JSON: %w", err) + } + + // Create and configure the command to execute the binary + cmd := exec.Command(name, args...) + cmd.Env = os.Environ() + cmd.Stdin = bytes.NewReader(nodeConfigData) + + if cfg.CoverDir != "" { + cmd.Env = append(cmd.Env, "GOCOVERDIR="+cfg.CoverDir) + } + + // Redirect all errors into a buffer + cmd.Stderr = os.Stderr + if cfg.Stderr != nil { + cmd.Stderr = cfg.Stderr + } + + // Create pipes for stdout + stdoutPipe, err := cmd.StdoutPipe() + if err != nil { + return nil, fmt.Errorf("failed to create stdout pipe: %w", err) + } + + // Start the command + if err := cmd.Start(); err != nil { + return nil, fmt.Errorf("failed to start command: %w", err) + } + + address, err := waitForProcessReady(ctx, stdoutPipe, cfg.Stdout) + if err != nil { + return nil, fmt.Errorf("waiting for readiness: %w", err) + } + + return &nodeProcess{ + cmd: cmd, + address: address, + }, nil +} + +type nodeInMemoryProcess struct { + address string + + stopOnce sync.Once + stopErr error + stop context.CancelFunc + ccNodeError chan error +} + +func (n *nodeInMemoryProcess) Address() string { + return n.address +} + +func (n *nodeInMemoryProcess) Stop() error { + n.stopOnce.Do(func() { + n.stop() + var err error + select { + case err = <-n.ccNodeError: + case <-time.After(time.Second * 5): + err = fmt.Errorf("timeout while waiting for node to stop") + } + + if err != nil { + n.stopErr = fmt.Errorf("unable to node gracefully: %w", err) + } + }) + + return n.stopErr +} + +func RunInMemoryProcess(ctx context.Context, cfg ProcessConfig) (NodeProcess, error) { + ctx, cancel := context.WithCancel(ctx) + + out, in := io.Pipe() + ccStopErr := make(chan error, 1) + go func() { + defer close(ccStopErr) + defer cancel() + + err := RunNode(ctx, cfg.Node, in, cfg.Stderr) + if err != nil { + fmt.Fprintf(cfg.Stderr, "run node failed: %v", err) + } + + ccStopErr <- err + }() + + address, err := waitForProcessReady(ctx, out, cfg.Stdout) + if err == nil { // ok + return &nodeInMemoryProcess{ + address: address, + stop: cancel, + ccNodeError: ccStopErr, + }, nil + } + + cancel() + + select { + case err = <-ccStopErr: // return node error in priority + default: + } + + return nil, err +} + +func RunMain(ctx context.Context, stdin io.Reader, stdout, stderr io.Writer) error { + ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt) + defer stop() + + // Read the configuration from standard input + configData, err := io.ReadAll(stdin) + if err != nil { + // log.Fatalf("error reading stdin: %v", err) + return fmt.Errorf("error reading stdin: %w", err) + } + + // Unmarshal the JSON configuration + var cfg ProcessNodeConfig + if err := json.Unmarshal(configData, &cfg); err != nil { + return fmt.Errorf("error unmarshaling JSON: %w", err) + // log.Fatalf("error unmarshaling JSON: %v", err) + } + + // Run the node + ccErr := make(chan error, 1) + go func() { + ccErr <- RunNode(ctx, &cfg, stdout, stderr) + close(ccErr) + }() + + // Wait for the node to gracefully terminate + <-ctx.Done() + + // Attempt graceful shutdown + select { + case <-time.After(gracefulShutdown): + return fmt.Errorf("unable to gracefully stop the node, exiting now") + case err = <-ccErr: // done + } + + return err +} + +func runTestingNodeProcess(t TestingTS, ctx context.Context, pcfg ProcessConfig) NodeProcess { + bin, err := os.Executable() + require.NoError(t, err) + args := []string{ + "-test.run=^$", + "-run-node-process", + } + + if pcfg.CoverDir != "" && testing.CoverMode() != "" { + args = append(args, "-test.gocoverdir="+pcfg.CoverDir) + } + + node, err := RunNodeProcess(ctx, pcfg, bin, args...) + require.NoError(t, err) + + return node +} + +// initDatabase initializes the database based on the provided directory configuration. +func initDatabase(dbDir string) (db.DB, error) { + if dbDir == "" { + return memdb.NewMemDB(), nil + } + + data, err := goleveldb.NewGoLevelDB("testdb", dbDir) + if err != nil { + return nil, fmt.Errorf("unable to init database in %q: %w", dbDir, err) + } + + return data, nil +} + +func signalWriteReady(w io.Writer, address string) error { + _, err := fmt.Fprintf(w, "READY:%s\n", address) + return err +} + +func signalReadReady(line string) (string, bool) { + var address string + if _, err := fmt.Sscanf(line, "READY:%s", &address); err == nil { + return address, true + } + return "", false +} + +// waitForProcessReady waits for the process to signal readiness and returns the address. +func waitForProcessReady(ctx context.Context, stdoutPipe io.Reader, out io.Writer) (string, error) { + var address string + + cReady := make(chan error, 2) + go func() { + defer close(cReady) + + scanner := bufio.NewScanner(stdoutPipe) + ready := false + for scanner.Scan() { + line := scanner.Text() + + if !ready { + if addr, ok := signalReadReady(line); ok { + address = addr + ready = true + cReady <- nil + } + } + + fmt.Fprintln(out, line) + } + + if err := scanner.Err(); err != nil { + cReady <- fmt.Errorf("error reading stdout: %w", err) + } else { + cReady <- fmt.Errorf("process exited without 'READY'") + } + }() + + select { + case err := <-cReady: + return address, err + case <-ctx.Done(): + return "", ctx.Err() + } +} + +// isAllZero checks if a 64-byte key consists entirely of zeros. +func isAllZero(key [64]byte) bool { + for _, v := range key { + if v != 0 { + return false + } + } + return true +} + +type MarshalableGenesisDoc bft.GenesisDoc + +func NewMarshalableGenesisDoc(doc *bft.GenesisDoc) *MarshalableGenesisDoc { + m := MarshalableGenesisDoc(*doc) + return &m +} + +func (m *MarshalableGenesisDoc) MarshalJSON() ([]byte, error) { + doc := (*bft.GenesisDoc)(m) + return amino.MarshalJSON(doc) +} + +func (m *MarshalableGenesisDoc) UnmarshalJSON(data []byte) (err error) { + doc, err := bft.GenesisDocFromJSON(data) + if err != nil { + return err + } + + *m = MarshalableGenesisDoc(*doc) + return +} + +// Cast back to the original bft.GenesisDoc. +func (m *MarshalableGenesisDoc) ToGenesisDoc() *bft.GenesisDoc { + return (*bft.GenesisDoc)(m) +} diff --git a/gno.land/pkg/integration/process/main.go b/gno.land/pkg/integration/process/main.go new file mode 100644 index 00000000000..bcd52e6fd44 --- /dev/null +++ b/gno.land/pkg/integration/process/main.go @@ -0,0 +1,20 @@ +package main + +import ( + "context" + "fmt" + "os" + "time" + + "github.com/gnolang/gno/gno.land/pkg/integration" +) + +func main() { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + if err := integration.RunMain(ctx, os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} diff --git a/gno.land/pkg/integration/process_test.go b/gno.land/pkg/integration/process_test.go new file mode 100644 index 00000000000..b8768ad0e63 --- /dev/null +++ b/gno.land/pkg/integration/process_test.go @@ -0,0 +1,144 @@ +package integration + +import ( + "bytes" + "context" + "flag" + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + "github.com/gnolang/gno/tm2/pkg/bft/rpc/client" + "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// Define a flag to indicate whether to run the embedded command +var runCommand = flag.Bool("run-node-process", false, "execute the embedded command") + +func TestMain(m *testing.M) { + flag.Parse() + + // Check if the embedded command should be executed + if !*runCommand { + fmt.Println("Running tests...") + os.Exit(m.Run()) + } + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*30) + defer cancel() + + if err := RunMain(ctx, os.Stdin, os.Stdout, os.Stderr); err != nil { + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } +} + +// TestGnolandIntegration tests the forking of a Gnoland node. +func TestNodeProcess(t *testing.T) { + t.Parallel() + + ctx, cancel := context.WithTimeout(context.Background(), time.Second*20) + defer cancel() + + gnoRootDir := gnoenv.RootDir() + + // Define paths for the build directory and the gnoland binary + gnolandDBDir := filepath.Join(t.TempDir(), "db") + + // Prepare a minimal node configuration for testing + cfg := TestingMinimalNodeConfig(gnoRootDir) + + var stdio bytes.Buffer + defer func() { + t.Log("node output:") + t.Log(stdio.String()) + }() + + start := time.Now() + node := runTestingNodeProcess(t, ctx, ProcessConfig{ + Stderr: &stdio, Stdout: &stdio, + Node: &ProcessNodeConfig{ + Verbose: true, + ValidatorKey: ed25519.GenPrivKey(), + DBDir: gnolandDBDir, + RootDir: gnoRootDir, + TMConfig: cfg.TMConfig, + Genesis: NewMarshalableGenesisDoc(cfg.Genesis), + }, + }) + t.Logf("time to start the node: %v", time.Since(start).String()) + + // Create a new HTTP client to interact with the integration node + cli, err := client.NewHTTPClient(node.Address()) + require.NoError(t, err) + + // Retrieve node info + info, err := cli.ABCIInfo() + require.NoError(t, err) + assert.NotEmpty(t, info.Response.Data) + + // Attempt to stop the node + err = node.Stop() + require.NoError(t, err) + + // Attempt to stop the node a second time, should not fail + err = node.Stop() + require.NoError(t, err) +} + +// TestGnolandIntegration tests the forking of a Gnoland node. +func TestInMemoryNodeProcess(t *testing.T) { + ctx, cancel := context.WithTimeout(context.Background(), time.Second*20) + defer cancel() + + gnoRootDir := gnoenv.RootDir() + + // Define paths for the build directory and the gnoland binary + gnolandDBDir := filepath.Join(t.TempDir(), "db") + + // Prepare a minimal node configuration for testing + cfg := TestingMinimalNodeConfig(gnoRootDir) + + var stdio bytes.Buffer + defer func() { + t.Log("node output:") + t.Log(stdio.String()) + }() + + start := time.Now() + node, err := RunInMemoryProcess(ctx, ProcessConfig{ + Stderr: &stdio, Stdout: &stdio, + Node: &ProcessNodeConfig{ + Verbose: true, + ValidatorKey: ed25519.GenPrivKey(), + DBDir: gnolandDBDir, + RootDir: gnoRootDir, + TMConfig: cfg.TMConfig, + Genesis: NewMarshalableGenesisDoc(cfg.Genesis), + }, + }) + require.NoError(t, err) + t.Logf("time to start the node: %v", time.Since(start).String()) + + // Create a new HTTP client to interact with the integration node + cli, err := client.NewHTTPClient(node.Address()) + require.NoError(t, err) + + // Retrieve node info + info, err := cli.ABCIInfo() + require.NoError(t, err) + assert.NotEmpty(t, info.Response.Data) + + // Attempt to stop the node + err = node.Stop() + require.NoError(t, err) + + // Attempt to stop the node a second time, should not fail + err = node.Stop() + require.NoError(t, err) +} diff --git a/gno.land/pkg/integration/testdata/gnoland.txtar b/gno.land/pkg/integration/testdata/gnoland.txtar index 78bdc9cae4e..83c8fe9c9a5 100644 --- a/gno.land/pkg/integration/testdata/gnoland.txtar +++ b/gno.land/pkg/integration/testdata/gnoland.txtar @@ -28,7 +28,7 @@ cmp stderr gnoland-already-stop.stderr.golden -- gnoland-no-arguments.stdout.golden -- -- gnoland-no-arguments.stderr.golden -- -"gnoland" error: syntax: gnoland [start|stop|restart] +"gnoland" error: no command provided -- gnoland-start.stdout.golden -- node started successfully -- gnoland-start.stderr.golden -- diff --git a/gno.land/pkg/integration/testdata/loadpkg_example.txtar b/gno.land/pkg/integration/testdata/loadpkg_example.txtar index c05bedfef65..f7be500f3b6 100644 --- a/gno.land/pkg/integration/testdata/loadpkg_example.txtar +++ b/gno.land/pkg/integration/testdata/loadpkg_example.txtar @@ -4,11 +4,11 @@ loadpkg gno.land/p/demo/ufmt ## start a new node gnoland start -gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/importtest -gas-fee 1000000ugnot -gas-wanted 9000000 -broadcast -chainid=tendermint_test test1 +gnokey maketx addpkg -pkgdir $WORK -pkgpath gno.land/r/importtest -gas-fee 1000000ugnot -gas-wanted 10000000 -broadcast -chainid=tendermint_test test1 stdout OK! ## execute Render -gnokey maketx call -pkgpath gno.land/r/importtest -func Render -gas-fee 1000000ugnot -gas-wanted 9000000 -args '' -broadcast -chainid=tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/importtest -func Render -gas-fee 1000000ugnot -gas-wanted 10000000 -args '' -broadcast -chainid=tendermint_test test1 stdout '("92054" string)' stdout OK! diff --git a/gno.land/pkg/integration/testdata/restart.txtar b/gno.land/pkg/integration/testdata/restart.txtar index 8a63713a214..5571aa9fa66 100644 --- a/gno.land/pkg/integration/testdata/restart.txtar +++ b/gno.land/pkg/integration/testdata/restart.txtar @@ -4,12 +4,12 @@ loadpkg gno.land/r/demo/counter $WORK gnoland start -gnokey maketx call -pkgpath gno.land/r/demo/counter -func Incr -gas-fee 1000000ugnot -gas-wanted 150000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/counter -func Incr -gas-fee 1000000ugnot -gas-wanted 200000 -broadcast -chainid tendermint_test test1 stdout '\(1 int\)' gnoland restart -gnokey maketx call -pkgpath gno.land/r/demo/counter -func Incr -gas-fee 1000000ugnot -gas-wanted 150000 -broadcast -chainid tendermint_test test1 +gnokey maketx call -pkgpath gno.land/r/demo/counter -func Incr -gas-fee 1000000ugnot -gas-wanted 200000 -broadcast -chainid tendermint_test test1 stdout '\(2 int\)' -- counter.gno -- diff --git a/gno.land/pkg/integration/testdata_test.go b/gno.land/pkg/integration/testdata_test.go new file mode 100644 index 00000000000..ba4d5176df1 --- /dev/null +++ b/gno.land/pkg/integration/testdata_test.go @@ -0,0 +1,67 @@ +package integration + +import ( + "os" + "strconv" + "testing" + + gno_integration "github.com/gnolang/gno/gnovm/pkg/integration" + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/require" +) + +func TestTestdata(t *testing.T) { + t.Parallel() + + flagInMemoryTS, _ := strconv.ParseBool(os.Getenv("INMEMORY_TS")) + flagNoSeqTS, _ := strconv.ParseBool(os.Getenv("NO_SEQ_TS")) + + p := gno_integration.NewTestingParams(t, "testdata") + + if coverdir, ok := gno_integration.ResolveCoverageDir(); ok { + err := gno_integration.SetupTestscriptsCoverage(&p, coverdir) + require.NoError(t, err) + } + + // Set up gnoland for testscript + err := SetupGnolandTestscript(t, &p) + require.NoError(t, err) + + mode := commandKindTesting + if flagInMemoryTS { + mode = commandKindInMemory + } + + origSetup := p.Setup + p.Setup = func(env *testscript.Env) error { + env.Values[envKeyExecCommand] = mode + if origSetup != nil { + if err := origSetup(env); err != nil { + return err + } + } + + return nil + } + + if flagInMemoryTS && !flagNoSeqTS { + testscript.RunT(tSeqShim{t}, p) + } else { + testscript.Run(t, p) + } +} + +type tSeqShim struct{ *testing.T } + +// noop Parallel method allow us to run test sequentially +func (tSeqShim) Parallel() {} + +func (t tSeqShim) Run(name string, f func(testscript.T)) { + t.T.Run(name, func(t *testing.T) { + f(tSeqShim{t}) + }) +} + +func (t tSeqShim) Verbose() bool { + return testing.Verbose() +} diff --git a/gno.land/pkg/integration/testing_integration.go b/gno.land/pkg/integration/testing_integration.go deleted file mode 100644 index 0a181950bb3..00000000000 --- a/gno.land/pkg/integration/testing_integration.go +++ /dev/null @@ -1,795 +0,0 @@ -package integration - -import ( - "context" - "errors" - "flag" - "fmt" - "hash/crc32" - "log/slog" - "os" - "path/filepath" - "strconv" - "strings" - "testing" - - "github.com/gnolang/gno/gno.land/pkg/gnoland" - "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" - "github.com/gnolang/gno/gno.land/pkg/keyscli" - "github.com/gnolang/gno/gno.land/pkg/log" - "github.com/gnolang/gno/gno.land/pkg/sdk/vm" - "github.com/gnolang/gno/gnovm/pkg/gnoenv" - "github.com/gnolang/gno/gnovm/pkg/gnolang" - "github.com/gnolang/gno/gnovm/pkg/gnomod" - "github.com/gnolang/gno/gnovm/pkg/packages" - "github.com/gnolang/gno/tm2/pkg/bft/node" - bft "github.com/gnolang/gno/tm2/pkg/bft/types" - "github.com/gnolang/gno/tm2/pkg/commands" - "github.com/gnolang/gno/tm2/pkg/crypto" - "github.com/gnolang/gno/tm2/pkg/crypto/bip39" - "github.com/gnolang/gno/tm2/pkg/crypto/keys" - "github.com/gnolang/gno/tm2/pkg/crypto/keys/client" - "github.com/gnolang/gno/tm2/pkg/db/memdb" - tm2Log "github.com/gnolang/gno/tm2/pkg/log" - "github.com/gnolang/gno/tm2/pkg/std" - "github.com/rogpeppe/go-internal/testscript" - "go.uber.org/zap/zapcore" -) - -const ( - envKeyGenesis int = iota - envKeyLogger - envKeyPkgsLoader -) - -type tSeqShim struct{ *testing.T } - -// noop Parallel method allow us to run test sequentially -func (tSeqShim) Parallel() {} - -func (t tSeqShim) Run(name string, f func(testscript.T)) { - t.T.Run(name, func(t *testing.T) { - f(tSeqShim{t}) - }) -} - -func (t tSeqShim) Verbose() bool { - return testing.Verbose() -} - -// RunGnolandTestscripts sets up and runs txtar integration tests for gnoland nodes. -// It prepares an in-memory gnoland node and initializes the necessary environment and custom commands. -// The function adapts the test setup for use with the testscript package, enabling -// the execution of gnoland and gnokey commands within txtar scripts. -// -// Refer to package documentation in doc.go for more information on commands and example txtar scripts. -func RunGnolandTestscripts(t *testing.T, txtarDir string) { - t.Helper() - - p := setupGnolandTestScript(t, txtarDir) - if deadline, ok := t.Deadline(); ok && p.Deadline.IsZero() { - p.Deadline = deadline - } - - testscript.RunT(tSeqShim{t}, p) -} - -type testNode struct { - *node.Node - cfg *gnoland.InMemoryNodeConfig - nGnoKeyExec uint // Counter for execution of gnokey. -} - -func setupGnolandTestScript(t *testing.T, txtarDir string) testscript.Params { - t.Helper() - - tmpdir := t.TempDir() - - // `gnoRootDir` should point to the local location of the gno repository. - // It serves as the gno equivalent of GOROOT. - gnoRootDir := gnoenv.RootDir() - - // `gnoHomeDir` should be the local directory where gnokey stores keys. - gnoHomeDir := filepath.Join(tmpdir, "gno") - - // Testscripts run concurrently by default, so we need to be prepared for that. - nodes := map[string]*testNode{} - - updateScripts, _ := strconv.ParseBool(os.Getenv("UPDATE_SCRIPTS")) - persistWorkDir, _ := strconv.ParseBool(os.Getenv("TESTWORK")) - return testscript.Params{ - UpdateScripts: updateScripts, - TestWork: persistWorkDir, - Dir: txtarDir, - Setup: func(env *testscript.Env) error { - kb, err := keys.NewKeyBaseFromDir(gnoHomeDir) - if err != nil { - return err - } - - // create sessions ID - var sid string - { - works := env.Getenv("WORK") - sum := crc32.ChecksumIEEE([]byte(works)) - sid = strconv.FormatUint(uint64(sum), 16) - env.Setenv("SID", sid) - } - - // setup logger - var logger *slog.Logger - { - logger = tm2Log.NewNoopLogger() - if persistWorkDir || os.Getenv("LOG_PATH_DIR") != "" { - logname := fmt.Sprintf("txtar-gnoland-%s.log", sid) - logger, err = getTestingLogger(env, logname) - if err != nil { - return fmt.Errorf("unable to setup logger: %w", err) - } - } - - env.Values[envKeyLogger] = logger - } - - // Track new user balances added via the `adduser` - // command and packages added with the `loadpkg` command. - // This genesis will be use when node is started. - - genesis := gnoland.DefaultGenState() - genesis.Balances = LoadDefaultGenesisBalanceFile(t, gnoRootDir) - genesis.Params = LoadDefaultGenesisParamFile(t, gnoRootDir) - genesis.Auth.Params.InitialGasPrice = std.GasPrice{Gas: 0, Price: std.Coin{Amount: 0, Denom: "ugnot"}} - genesis.Txs = []gnoland.TxWithMetadata{} - - // test1 must be created outside of the loop below because it is already included in genesis so - // attempting to recreate results in it getting overwritten and breaking existing tests that - // rely on its address being static. - kb.CreateAccount(DefaultAccount_Name, DefaultAccount_Seed, "", "", 0, 0) - env.Setenv("USER_SEED_"+DefaultAccount_Name, DefaultAccount_Seed) - env.Setenv("USER_ADDR_"+DefaultAccount_Name, DefaultAccount_Address) - - env.Values[envKeyGenesis] = &genesis - env.Values[envKeyPkgsLoader] = newPkgsLoader() - - env.Setenv("GNOROOT", gnoRootDir) - env.Setenv("GNOHOME", gnoHomeDir) - - return nil - }, - Cmds: map[string]func(ts *testscript.TestScript, neg bool, args []string){ - "gnoland": func(ts *testscript.TestScript, neg bool, args []string) { - if len(args) == 0 { - tsValidateError(ts, "gnoland", neg, fmt.Errorf("syntax: gnoland [start|stop|restart]")) - return - } - - logger := ts.Value(envKeyLogger).(*slog.Logger) // grab logger - sid := getNodeSID(ts) // grab session id - - var cmd string - cmd, args = args[0], args[1:] - - var err error - switch cmd { - case "start": - if nodeIsRunning(nodes, sid) { - err = fmt.Errorf("node already started") - break - } - - // parse flags - fs := flag.NewFlagSet("start", flag.ContinueOnError) - nonVal := fs.Bool("non-validator", false, "set up node as a non-validator") - if err := fs.Parse(args); err != nil { - ts.Fatalf("unable to parse `gnoland start` flags: %s", err) - } - - // get packages - pkgs := ts.Value(envKeyPkgsLoader).(*pkgsLoader) // grab logger - creator := crypto.MustAddressFromString(DefaultAccount_Address) // test1 - defaultFee := std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) - // we need to define a new err1 otherwise the out err would be shadowed in the case "start": - pkgsTxs, loadErr := pkgs.LoadPackages(creator, defaultFee, nil) - - if loadErr != nil { - ts.Fatalf("unable to load packages txs: %s", err) - } - - // Warp up `ts` so we can pass it to other testing method - t := TSTestingT(ts) - - // Generate config and node - cfg := TestingMinimalNodeConfig(t, gnoRootDir) - genesis := ts.Value(envKeyGenesis).(*gnoland.GnoGenesisState) - genesis.Txs = append(pkgsTxs, genesis.Txs...) - - // setup genesis state - cfg.Genesis.AppState = *genesis - if *nonVal { - // re-create cfg.Genesis.Validators with a throwaway pv, so we start as a - // non-validator. - pv := gnoland.NewMockedPrivValidator() - cfg.Genesis.Validators = []bft.GenesisValidator{ - { - Address: pv.GetPubKey().Address(), - PubKey: pv.GetPubKey(), - Power: 10, - Name: "none", - }, - } - } - cfg.DB = memdb.NewMemDB() // so it can be reused when restarting. - - n, remoteAddr := TestingInMemoryNode(t, logger, cfg) - - // Register cleanup - nodes[sid] = &testNode{Node: n, cfg: cfg} - - // Add default environments - ts.Setenv("RPC_ADDR", remoteAddr) - - fmt.Fprintln(ts.Stdout(), "node started successfully") - case "restart": - n, ok := nodes[sid] - if !ok { - err = fmt.Errorf("node must be started before being restarted") - break - } - - if stopErr := n.Stop(); stopErr != nil { - err = fmt.Errorf("error stopping node: %w", stopErr) - break - } - - // Create new node with same config. - newNode, newRemoteAddr := TestingInMemoryNode(t, logger, n.cfg) - - // Update testNode and environment variables. - n.Node = newNode - ts.Setenv("RPC_ADDR", newRemoteAddr) - - fmt.Fprintln(ts.Stdout(), "node restarted successfully") - case "stop": - n, ok := nodes[sid] - if !ok { - err = fmt.Errorf("node not started cannot be stopped") - break - } - if err = n.Stop(); err == nil { - delete(nodes, sid) - - // Unset gnoland environments - ts.Setenv("RPC_ADDR", "") - fmt.Fprintln(ts.Stdout(), "node stopped successfully") - } - default: - err = fmt.Errorf("invalid gnoland subcommand: %q", cmd) - } - - tsValidateError(ts, "gnoland "+cmd, neg, err) - }, - "gnokey": func(ts *testscript.TestScript, neg bool, args []string) { - logger := ts.Value(envKeyLogger).(*slog.Logger) // grab logger - sid := ts.Getenv("SID") // grab session id - - // Unquote args enclosed in `"` to correctly handle `\n` or similar escapes. - args, err := unquote(args) - if err != nil { - tsValidateError(ts, "gnokey", neg, err) - } - - // Setup IO command - io := commands.NewTestIO() - io.SetOut(commands.WriteNopCloser(ts.Stdout())) - io.SetErr(commands.WriteNopCloser(ts.Stderr())) - cmd := keyscli.NewRootCmd(io, client.DefaultBaseOptions) - - io.SetIn(strings.NewReader("\n")) // Inject empty password to stdin. - defaultArgs := []string{ - "-home", gnoHomeDir, - "-insecure-password-stdin=true", // There no use to not have this param by default. - } - - if n, ok := nodes[sid]; ok { - if raddr := n.Config().RPC.ListenAddress; raddr != "" { - defaultArgs = append(defaultArgs, "-remote", raddr) - } - - n.nGnoKeyExec++ - headerlog := fmt.Sprintf("%.02d!EXEC_GNOKEY", n.nGnoKeyExec) - - // Log the command inside gnoland logger, so we can better scope errors. - logger.Info(headerlog, "args", strings.Join(args, " ")) - defer logger.Info(headerlog, "delimiter", "END") - } - - // Inject default argument, if duplicate - // arguments, it should be override by the ones - // user provided. - args = append(defaultArgs, args...) - - err = cmd.ParseAndRun(context.Background(), args) - tsValidateError(ts, "gnokey", neg, err) - }, - // adduser command must be executed before starting the node; it errors out otherwise. - "adduser": func(ts *testscript.TestScript, neg bool, args []string) { - if nodeIsRunning(nodes, getNodeSID(ts)) { - tsValidateError(ts, "adduser", neg, errors.New("adduser must be used before starting node")) - return - } - - if len(args) == 0 { - ts.Fatalf("new user name required") - } - - kb, err := keys.NewKeyBaseFromDir(gnoHomeDir) - if err != nil { - ts.Fatalf("unable to get keybase") - } - - balance, err := createAccount(ts, kb, args[0]) - if err != nil { - ts.Fatalf("error creating account %s: %s", args[0], err) - } - - // Add balance to genesis - genesis := ts.Value(envKeyGenesis).(*gnoland.GnoGenesisState) - genesis.Balances = append(genesis.Balances, balance) - }, - // adduserfrom commands must be executed before starting the node; it errors out otherwise. - "adduserfrom": func(ts *testscript.TestScript, neg bool, args []string) { - if nodeIsRunning(nodes, getNodeSID(ts)) { - tsValidateError(ts, "adduserfrom", neg, errors.New("adduserfrom must be used before starting node")) - return - } - - var account, index uint64 - var err error - - switch len(args) { - case 2: - // expected user input - // adduserfrom 'username 'menmonic' - // no need to do anything - - case 4: - // expected user input - // adduserfrom 'username 'menmonic' 'account' 'index' - - // parse 'index' first, then fallghrough to `case 3` to parse 'account' - index, err = strconv.ParseUint(args[3], 10, 32) - if err != nil { - ts.Fatalf("invalid index number %s", args[3]) - } - - fallthrough // parse 'account' - case 3: - // expected user input - // adduserfrom 'username 'menmonic' 'account' - - account, err = strconv.ParseUint(args[2], 10, 32) - if err != nil { - ts.Fatalf("invalid account number %s", args[2]) - } - default: - ts.Fatalf("to create account from metadatas, user name and mnemonic are required ( account and index are optional )") - } - - kb, err := keys.NewKeyBaseFromDir(gnoHomeDir) - if err != nil { - ts.Fatalf("unable to get keybase") - } - - balance, err := createAccountFrom(ts, kb, args[0], args[1], uint32(account), uint32(index)) - if err != nil { - ts.Fatalf("error creating wallet %s", err) - } - - // Add balance to genesis - genesis := ts.Value(envKeyGenesis).(*gnoland.GnoGenesisState) - genesis.Balances = append(genesis.Balances, balance) - - fmt.Fprintf(ts.Stdout(), "Added %s(%s) to genesis", args[0], balance.Address) - }, - // `patchpkg` Patch any loaded files by packages by replacing all occurrences of the - // first argument with the second. - // This is mostly use to replace hardcoded address inside txtar file. - "patchpkg": func(ts *testscript.TestScript, neg bool, args []string) { - args, err := unquote(args) - if err != nil { - tsValidateError(ts, "patchpkg", neg, err) - } - - if len(args) != 2 { - ts.Fatalf("`patchpkg`: should have exactly 2 arguments") - } - - pkgs := ts.Value(envKeyPkgsLoader).(*pkgsLoader) - replace, with := args[0], args[1] - pkgs.SetPatch(replace, with) - }, - // `loadpkg` load a specific package from the 'examples' or working directory. - "loadpkg": func(ts *testscript.TestScript, neg bool, args []string) { - // special dirs - workDir := ts.Getenv("WORK") - examplesDir := filepath.Join(gnoRootDir, "examples") - - pkgs := ts.Value(envKeyPkgsLoader).(*pkgsLoader) - - var path, name string - switch len(args) { - case 2: - name = args[0] - path = filepath.Clean(args[1]) - case 1: - path = filepath.Clean(args[0]) - case 0: - ts.Fatalf("`loadpkg`: no arguments specified") - default: - ts.Fatalf("`loadpkg`: too many arguments specified") - } - - // If `all` is specified, fully load 'examples' directory. - // NOTE: In 99% of cases, this is not needed, and - // packages should be loaded individually. - if path == "all" { - ts.Logf("warning: loading all packages") - if err := pkgs.LoadAllPackagesFromDir(examplesDir); err != nil { - ts.Fatalf("unable to load packages from %q: %s", examplesDir, err) - } - - return - } - - if !strings.HasPrefix(path, workDir) { - path = filepath.Join(examplesDir, path) - } - - if err := pkgs.LoadPackage(examplesDir, path, name); err != nil { - ts.Fatalf("`loadpkg` unable to load package(s) from %q: %s", args[0], err) - } - - ts.Logf("%q package was added to genesis", args[0]) - }, - }, - } -} - -// `unquote` takes a slice of strings, resulting from splitting a string block by spaces, and -// processes them. The function handles quoted phrases and escape characters within these strings. -func unquote(args []string) ([]string, error) { - const quote = '"' - - parts := []string{} - var inQuote bool - - var part strings.Builder - for _, arg := range args { - var escaped bool - for _, c := range arg { - if escaped { - // If the character is meant to be escaped, it is processed with Unquote. - // We use `Unquote` here for two main reasons: - // 1. It will validate that the escape sequence is correct - // 2. It converts the escaped string to its corresponding raw character. - // For example, "\\t" becomes '\t'. - uc, err := strconv.Unquote(`"\` + string(c) + `"`) - if err != nil { - return nil, fmt.Errorf("unhandled escape sequence `\\%c`: %w", c, err) - } - - part.WriteString(uc) - escaped = false - continue - } - - // If we are inside a quoted string and encounter an escape character, - // flag the next character as `escaped` - if inQuote && c == '\\' { - escaped = true - continue - } - - // Detect quote and toggle inQuote state - if c == quote { - inQuote = !inQuote - continue - } - - // Handle regular character - part.WriteRune(c) - } - - // If we're inside a quote, add a single space. - // It reflects one or multiple spaces between args in the original string. - if inQuote { - part.WriteRune(' ') - continue - } - - // Finalize part, add to parts, and reset for next part - parts = append(parts, part.String()) - part.Reset() - } - - // Check if a quote is left open - if inQuote { - return nil, errors.New("unfinished quote") - } - - return parts, nil -} - -func getNodeSID(ts *testscript.TestScript) string { - return ts.Getenv("SID") -} - -func nodeIsRunning(nodes map[string]*testNode, sid string) bool { - _, ok := nodes[sid] - return ok -} - -func getTestingLogger(env *testscript.Env, logname string) (*slog.Logger, error) { - var path string - - if logdir := os.Getenv("LOG_PATH_DIR"); logdir != "" { - if err := os.MkdirAll(logdir, 0o755); err != nil { - return nil, fmt.Errorf("unable to make log directory %q", logdir) - } - - var err error - if path, err = filepath.Abs(filepath.Join(logdir, logname)); err != nil { - return nil, fmt.Errorf("unable to get absolute path of logdir %q", logdir) - } - } else if workdir := env.Getenv("WORK"); workdir != "" { - path = filepath.Join(workdir, logname) - } else { - return tm2Log.NewNoopLogger(), nil - } - - f, err := os.Create(path) - if err != nil { - return nil, fmt.Errorf("unable to create log file %q: %w", path, err) - } - - env.Defer(func() { - if err := f.Close(); err != nil { - panic(fmt.Errorf("unable to close log file %q: %w", path, err)) - } - }) - - // Initialize the logger - logLevel, err := zapcore.ParseLevel(strings.ToLower(os.Getenv("LOG_LEVEL"))) - if err != nil { - return nil, fmt.Errorf("unable to parse log level, %w", err) - } - - // Build zap logger for testing - zapLogger := log.NewZapTestingLogger(f, logLevel) - env.Defer(func() { zapLogger.Sync() }) - - env.T().Log("starting logger", path) - return log.ZapLoggerToSlog(zapLogger), nil -} - -func tsValidateError(ts *testscript.TestScript, cmd string, neg bool, err error) { - if err != nil { - fmt.Fprintf(ts.Stderr(), "%q error: %+v\n", cmd, err) - if !neg { - ts.Fatalf("unexpected %q command failure: %s", cmd, err) - } - } else { - if neg { - ts.Fatalf("unexpected %q command success", cmd) - } - } -} - -type envSetter interface { - Setenv(key, value string) -} - -// createAccount creates a new account with the given name and adds it to the keybase. -func createAccount(env envSetter, kb keys.Keybase, accountName string) (gnoland.Balance, error) { - var balance gnoland.Balance - entropy, err := bip39.NewEntropy(256) - if err != nil { - return balance, fmt.Errorf("error creating entropy: %w", err) - } - - mnemonic, err := bip39.NewMnemonic(entropy) - if err != nil { - return balance, fmt.Errorf("error generating mnemonic: %w", err) - } - - var keyInfo keys.Info - if keyInfo, err = kb.CreateAccount(accountName, mnemonic, "", "", 0, 0); err != nil { - return balance, fmt.Errorf("unable to create account: %w", err) - } - - address := keyInfo.GetAddress() - env.Setenv("USER_SEED_"+accountName, mnemonic) - env.Setenv("USER_ADDR_"+accountName, address.String()) - - return gnoland.Balance{ - Address: address, - Amount: std.Coins{std.NewCoin(ugnot.Denom, 10e6)}, - }, nil -} - -// createAccountFrom creates a new account with the given metadata and adds it to the keybase. -func createAccountFrom(env envSetter, kb keys.Keybase, accountName, mnemonic string, account, index uint32) (gnoland.Balance, error) { - var balance gnoland.Balance - - // check if mnemonic is valid - if !bip39.IsMnemonicValid(mnemonic) { - return balance, fmt.Errorf("invalid mnemonic") - } - - keyInfo, err := kb.CreateAccount(accountName, mnemonic, "", "", account, index) - if err != nil { - return balance, fmt.Errorf("unable to create account: %w", err) - } - - address := keyInfo.GetAddress() - env.Setenv("USER_SEED_"+accountName, mnemonic) - env.Setenv("USER_ADDR_"+accountName, address.String()) - - return gnoland.Balance{ - Address: address, - Amount: std.Coins{std.NewCoin(ugnot.Denom, 10e6)}, - }, nil -} - -type pkgsLoader struct { - pkgs []gnomod.Pkg - visited map[string]struct{} - - // list of occurrences to patchs with the given value - // XXX: find a better way - patchs map[string]string -} - -func newPkgsLoader() *pkgsLoader { - return &pkgsLoader{ - pkgs: make([]gnomod.Pkg, 0), - visited: make(map[string]struct{}), - patchs: make(map[string]string), - } -} - -func (pl *pkgsLoader) List() gnomod.PkgList { - return pl.pkgs -} - -func (pl *pkgsLoader) SetPatch(replace, with string) { - pl.patchs[replace] = with -} - -func (pl *pkgsLoader) LoadPackages(creator bft.Address, fee std.Fee, deposit std.Coins) ([]gnoland.TxWithMetadata, error) { - pkgslist, err := pl.List().Sort() // sorts packages by their dependencies. - if err != nil { - return nil, fmt.Errorf("unable to sort packages: %w", err) - } - - txs := make([]gnoland.TxWithMetadata, len(pkgslist)) - for i, pkg := range pkgslist { - tx, err := gnoland.LoadPackage(pkg, creator, fee, deposit) - if err != nil { - return nil, fmt.Errorf("unable to load pkg %q: %w", pkg.Name, err) - } - - // If any replace value is specified, apply them - if len(pl.patchs) > 0 { - for _, msg := range tx.Msgs { - addpkg, ok := msg.(vm.MsgAddPackage) - if !ok { - continue - } - - if addpkg.Package == nil { - continue - } - - for _, file := range addpkg.Package.Files { - for replace, with := range pl.patchs { - file.Body = strings.ReplaceAll(file.Body, replace, with) - } - } - } - } - - txs[i] = gnoland.TxWithMetadata{ - Tx: tx, - } - } - - return txs, nil -} - -func (pl *pkgsLoader) LoadAllPackagesFromDir(path string) error { - // list all packages from target path - pkgslist, err := gnomod.ListPkgs(path) - if err != nil { - return fmt.Errorf("listing gno packages: %w", err) - } - - for _, pkg := range pkgslist { - if !pl.exist(pkg) { - pl.add(pkg) - } - } - - return nil -} - -func (pl *pkgsLoader) LoadPackage(modroot string, path, name string) error { - // Initialize a queue with the root package - queue := []gnomod.Pkg{{Dir: path, Name: name}} - - for len(queue) > 0 { - // Dequeue the first package - currentPkg := queue[0] - queue = queue[1:] - - if currentPkg.Dir == "" { - return fmt.Errorf("no path specified for package") - } - - if currentPkg.Name == "" { - // Load `gno.mod` information - gnoModPath := filepath.Join(currentPkg.Dir, "gno.mod") - gm, err := gnomod.ParseGnoMod(gnoModPath) - if err != nil { - return fmt.Errorf("unable to load %q: %w", gnoModPath, err) - } - gm.Sanitize() - - // Override package info with mod infos - currentPkg.Name = gm.Module.Mod.Path - currentPkg.Draft = gm.Draft - - pkg, err := gnolang.ReadMemPackage(currentPkg.Dir, currentPkg.Name) - if err != nil { - return fmt.Errorf("unable to read package at %q: %w", currentPkg.Dir, err) - } - imports, err := packages.Imports(pkg, nil) - if err != nil { - return fmt.Errorf("unable to load package imports in %q: %w", currentPkg.Dir, err) - } - for _, imp := range imports { - if imp.PkgPath == currentPkg.Name || gnolang.IsStdlib(imp.PkgPath) { - continue - } - currentPkg.Imports = append(currentPkg.Imports, imp.PkgPath) - } - } - - if currentPkg.Draft { - continue // Skip draft package - } - - if pl.exist(currentPkg) { - continue - } - pl.add(currentPkg) - - // Add requirements to the queue - for _, pkgPath := range currentPkg.Imports { - fullPath := filepath.Join(modroot, pkgPath) - queue = append(queue, gnomod.Pkg{Dir: fullPath}) - } - } - - return nil -} - -func (pl *pkgsLoader) add(pkg gnomod.Pkg) { - pl.visited[pkg.Name] = struct{}{} - pl.pkgs = append(pl.pkgs, pkg) -} - -func (pl *pkgsLoader) exist(pkg gnomod.Pkg) (ok bool) { - _, ok = pl.visited[pkg.Name] - return -} diff --git a/gno.land/pkg/integration/testscript_gnoland.go b/gno.land/pkg/integration/testscript_gnoland.go new file mode 100644 index 00000000000..ae484a07669 --- /dev/null +++ b/gno.land/pkg/integration/testscript_gnoland.go @@ -0,0 +1,787 @@ +package integration + +import ( + "bytes" + "context" + "errors" + "flag" + "fmt" + "hash/crc32" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/gnolang/gno/gno.land/pkg/gnoland" + "github.com/gnolang/gno/gno.land/pkg/gnoland/ugnot" + "github.com/gnolang/gno/gno.land/pkg/keyscli" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + bft "github.com/gnolang/gno/tm2/pkg/bft/types" + "github.com/gnolang/gno/tm2/pkg/commands" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/bip39" + "github.com/gnolang/gno/tm2/pkg/crypto/ed25519" + "github.com/gnolang/gno/tm2/pkg/crypto/hd" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" + "github.com/gnolang/gno/tm2/pkg/crypto/keys/client" + "github.com/gnolang/gno/tm2/pkg/crypto/secp256k1" + "github.com/gnolang/gno/tm2/pkg/std" + "github.com/rogpeppe/go-internal/testscript" + "github.com/stretchr/testify/require" +) + +const nodeMaxLifespan = time.Second * 30 + +type envKey int + +const ( + envKeyGenesis envKey = iota + envKeyLogger + envKeyPkgsLoader + envKeyPrivValKey + envKeyExecCommand + envKeyExecBin +) + +type commandkind int + +const ( + // commandKindBin builds and uses an integration binary to run the testscript + // in a separate process. This should be used for any external package that + // wants to use test scripts. + commandKindBin commandkind = iota + // commandKindTesting uses the current testing binary to run the testscript + // in a separate process. This command cannot be used outside this package. + commandKindTesting + // commandKindInMemory runs testscripts in memory. + commandKindInMemory +) + +type tNodeProcess struct { + NodeProcess + cfg *gnoland.InMemoryNodeConfig + nGnoKeyExec uint // Counter for execution of gnokey. +} + +// NodesManager manages access to the nodes map with synchronization. +type NodesManager struct { + nodes map[string]*tNodeProcess + mu sync.RWMutex +} + +// NewNodesManager creates a new instance of NodesManager. +func NewNodesManager() *NodesManager { + return &NodesManager{ + nodes: make(map[string]*tNodeProcess), + } +} + +func (nm *NodesManager) IsNodeRunning(sid string) bool { + nm.mu.RLock() + defer nm.mu.RUnlock() + + _, ok := nm.nodes[sid] + return ok +} + +// Get retrieves a node by its SID. +func (nm *NodesManager) Get(sid string) (*tNodeProcess, bool) { + nm.mu.RLock() + defer nm.mu.RUnlock() + node, exists := nm.nodes[sid] + return node, exists +} + +// Set adds or updates a node in the map. +func (nm *NodesManager) Set(sid string, node *tNodeProcess) { + nm.mu.Lock() + defer nm.mu.Unlock() + nm.nodes[sid] = node +} + +// Delete removes a node from the map. +func (nm *NodesManager) Delete(sid string) { + nm.mu.Lock() + defer nm.mu.Unlock() + delete(nm.nodes, sid) +} + +func SetupGnolandTestscript(t *testing.T, p *testscript.Params) error { + t.Helper() + + gnoRootDir := gnoenv.RootDir() + + nodesManager := NewNodesManager() + + defaultPK, err := generatePrivKeyFromMnemonic(DefaultAccount_Seed, "", 0, 0) + require.NoError(t, err) + + var buildOnce sync.Once + var gnolandBin string + + // Store the original setup scripts for potential wrapping + origSetup := p.Setup + p.Setup = func(env *testscript.Env) error { + // If there's an original setup, execute it + if origSetup != nil { + if err := origSetup(env); err != nil { + return err + } + } + + cmd, isSet := env.Values[envKeyExecCommand].(commandkind) + switch { + case !isSet: + cmd = commandKindBin // fallback on commandKindBin + fallthrough + case cmd == commandKindBin: + buildOnce.Do(func() { + t.Logf("building the gnoland integration node") + start := time.Now() + gnolandBin = buildGnoland(t, gnoRootDir) + t.Logf("time to build the node: %v", time.Since(start).String()) + }) + + env.Values[envKeyExecBin] = gnolandBin + } + + tmpdir, dbdir := t.TempDir(), t.TempDir() + gnoHomeDir := filepath.Join(tmpdir, "gno") + + kb, err := keys.NewKeyBaseFromDir(gnoHomeDir) + if err != nil { + return err + } + + kb.ImportPrivKey(DefaultAccount_Name, defaultPK, "") + env.Setenv("USER_SEED_"+DefaultAccount_Name, DefaultAccount_Seed) + env.Setenv("USER_ADDR_"+DefaultAccount_Name, DefaultAccount_Address) + + // New private key + env.Values[envKeyPrivValKey] = ed25519.GenPrivKey() + env.Setenv("GNO_DBDIR", dbdir) + + // Generate node short id + var sid string + { + works := env.Getenv("WORK") + sum := crc32.ChecksumIEEE([]byte(works)) + sid = strconv.FormatUint(uint64(sum), 16) + env.Setenv("SID", sid) + } + + balanceFile := LoadDefaultGenesisBalanceFile(t, gnoRootDir) + genesisParamFile := LoadDefaultGenesisParamFile(t, gnoRootDir) + + // Track new user balances added via the `adduser` + // command and packages added with the `loadpkg` command. + // This genesis will be use when node is started. + genesis := &gnoland.GnoGenesisState{ + Balances: balanceFile, + Params: genesisParamFile, + Txs: []gnoland.TxWithMetadata{}, + } + + env.Values[envKeyGenesis] = genesis + env.Values[envKeyPkgsLoader] = NewPkgsLoader() + + env.Setenv("GNOROOT", gnoRootDir) + env.Setenv("GNOHOME", gnoHomeDir) + + env.Defer(func() { + // Gracefully stop the node, if any + n, exist := nodesManager.Get(sid) + if !exist { + return + } + + if err := n.Stop(); err != nil { + err = fmt.Errorf("unable to stop the node gracefully: %w", err) + env.T().Fatal(err.Error()) + } + }) + + return nil + } + + cmds := map[string]func(ts *testscript.TestScript, neg bool, args []string){ + "gnoland": gnolandCmd(t, nodesManager, gnoRootDir), + "gnokey": gnokeyCmd(nodesManager), + "adduser": adduserCmd(nodesManager), + "adduserfrom": adduserfromCmd(nodesManager), + "patchpkg": patchpkgCmd(), + "loadpkg": loadpkgCmd(gnoRootDir), + } + + // Initialize cmds map if needed + if p.Cmds == nil { + p.Cmds = make(map[string]func(ts *testscript.TestScript, neg bool, args []string)) + } + + // Register gnoland command + for cmd, call := range cmds { + if _, exist := p.Cmds[cmd]; exist { + panic(fmt.Errorf("unable register %q: command already exist", cmd)) + } + + p.Cmds[cmd] = call + } + + return nil +} + +func gnolandCmd(t *testing.T, nodesManager *NodesManager, gnoRootDir string) func(ts *testscript.TestScript, neg bool, args []string) { + t.Helper() + + return func(ts *testscript.TestScript, neg bool, args []string) { + sid := getNodeSID(ts) + + cmd, cmdargs := "", []string{} + if len(args) > 0 { + cmd, cmdargs = args[0], args[1:] + } + + var err error + switch cmd { + case "": + err = errors.New("no command provided") + case "start": + if nodesManager.IsNodeRunning(sid) { + err = fmt.Errorf("node already started") + break + } + + // XXX: this is a bit hacky, we should consider moving + // gnoland into his own package to be able to use it + // directly or use the config command for this. + fs := flag.NewFlagSet("start", flag.ContinueOnError) + nonVal := fs.Bool("non-validator", false, "set up node as a non-validator") + if err := fs.Parse(cmdargs); err != nil { + ts.Fatalf("unable to parse `gnoland start` flags: %s", err) + } + + pkgs := ts.Value(envKeyPkgsLoader).(*PkgsLoader) + creator := crypto.MustAddressFromString(DefaultAccount_Address) + defaultFee := std.NewFee(50000, std.MustParseCoin(ugnot.ValueString(1000000))) + pkgsTxs, err := pkgs.LoadPackages(creator, defaultFee, nil) + if err != nil { + ts.Fatalf("unable to load packages txs: %s", err) + } + + cfg := TestingMinimalNodeConfig(gnoRootDir) + genesis := ts.Value(envKeyGenesis).(*gnoland.GnoGenesisState) + genesis.Txs = append(pkgsTxs, genesis.Txs...) + + cfg.Genesis.AppState = *genesis + if *nonVal { + pv := gnoland.NewMockedPrivValidator() + cfg.Genesis.Validators = []bft.GenesisValidator{ + { + Address: pv.GetPubKey().Address(), + PubKey: pv.GetPubKey(), + Power: 10, + Name: "none", + }, + } + } + + ctx, cancel := context.WithTimeout(context.Background(), nodeMaxLifespan) + ts.Defer(cancel) + + dbdir := ts.Getenv("GNO_DBDIR") + priv := ts.Value(envKeyPrivValKey).(ed25519.PrivKeyEd25519) + nodep := setupNode(ts, ctx, &ProcessNodeConfig{ + ValidatorKey: priv, + DBDir: dbdir, + RootDir: gnoRootDir, + TMConfig: cfg.TMConfig, + Genesis: NewMarshalableGenesisDoc(cfg.Genesis), + }) + + nodesManager.Set(sid, &tNodeProcess{NodeProcess: nodep, cfg: cfg}) + + ts.Setenv("RPC_ADDR", nodep.Address()) + fmt.Fprintln(ts.Stdout(), "node started successfully") + + case "restart": + node, exists := nodesManager.Get(sid) + if !exists { + err = fmt.Errorf("node must be started before being restarted") + break + } + + if err := node.Stop(); err != nil { + err = fmt.Errorf("unable to stop the node gracefully: %w", err) + break + } + + ctx, cancel := context.WithTimeout(context.Background(), nodeMaxLifespan) + ts.Defer(cancel) + + priv := ts.Value(envKeyPrivValKey).(ed25519.PrivKeyEd25519) + dbdir := ts.Getenv("GNO_DBDIR") + nodep := setupNode(ts, ctx, &ProcessNodeConfig{ + ValidatorKey: priv, + DBDir: dbdir, + RootDir: gnoRootDir, + TMConfig: node.cfg.TMConfig, + Genesis: NewMarshalableGenesisDoc(node.cfg.Genesis), + }) + + ts.Setenv("RPC_ADDR", nodep.Address()) + nodesManager.Set(sid, &tNodeProcess{NodeProcess: nodep, cfg: node.cfg}) + + fmt.Fprintln(ts.Stdout(), "node restarted successfully") + + case "stop": + node, exists := nodesManager.Get(sid) + if !exists { + err = fmt.Errorf("node not started cannot be stopped") + break + } + + if err := node.Stop(); err != nil { + err = fmt.Errorf("unable to stop the node gracefully: %w", err) + break + } + + fmt.Fprintln(ts.Stdout(), "node stopped successfully") + nodesManager.Delete(sid) + + default: + err = fmt.Errorf("not supported command: %q", cmd) + // XXX: support gnoland other commands + } + + tsValidateError(ts, strings.TrimSpace("gnoland "+cmd), neg, err) + } +} + +func gnokeyCmd(nodes *NodesManager) func(ts *testscript.TestScript, neg bool, args []string) { + return func(ts *testscript.TestScript, neg bool, args []string) { + gnoHomeDir := ts.Getenv("GNOHOME") + + sid := getNodeSID(ts) + + args, err := unquote(args) + if err != nil { + tsValidateError(ts, "gnokey", neg, err) + } + + io := commands.NewTestIO() + io.SetOut(commands.WriteNopCloser(ts.Stdout())) + io.SetErr(commands.WriteNopCloser(ts.Stderr())) + cmd := keyscli.NewRootCmd(io, client.DefaultBaseOptions) + + io.SetIn(strings.NewReader("\n")) + defaultArgs := []string{ + "-home", gnoHomeDir, + "-insecure-password-stdin=true", + } + + if n, ok := nodes.Get(sid); ok { + if raddr := n.Address(); raddr != "" { + defaultArgs = append(defaultArgs, "-remote", raddr) + } + + n.nGnoKeyExec++ + } + + args = append(defaultArgs, args...) + + err = cmd.ParseAndRun(context.Background(), args) + tsValidateError(ts, "gnokey", neg, err) + } +} + +func adduserCmd(nodesManager *NodesManager) func(ts *testscript.TestScript, neg bool, args []string) { + return func(ts *testscript.TestScript, neg bool, args []string) { + gnoHomeDir := ts.Getenv("GNOHOME") + + sid := getNodeSID(ts) + if nodesManager.IsNodeRunning(sid) { + tsValidateError(ts, "adduser", neg, errors.New("adduser must be used before starting node")) + return + } + + if len(args) == 0 { + ts.Fatalf("new user name required") + } + + kb, err := keys.NewKeyBaseFromDir(gnoHomeDir) + if err != nil { + ts.Fatalf("unable to get keybase") + } + + balance, err := createAccount(ts, kb, args[0]) + if err != nil { + ts.Fatalf("error creating account %s: %s", args[0], err) + } + + genesis := ts.Value(envKeyGenesis).(*gnoland.GnoGenesisState) + genesis.Balances = append(genesis.Balances, balance) + } +} + +func adduserfromCmd(nodesManager *NodesManager) func(ts *testscript.TestScript, neg bool, args []string) { + return func(ts *testscript.TestScript, neg bool, args []string) { + gnoHomeDir := ts.Getenv("GNOHOME") + + sid := getNodeSID(ts) + if nodesManager.IsNodeRunning(sid) { + tsValidateError(ts, "adduserfrom", neg, errors.New("adduserfrom must be used before starting node")) + return + } + + var account, index uint64 + var err error + + switch len(args) { + case 2: + case 4: + index, err = strconv.ParseUint(args[3], 10, 32) + if err != nil { + ts.Fatalf("invalid index number %s", args[3]) + } + fallthrough + case 3: + account, err = strconv.ParseUint(args[2], 10, 32) + if err != nil { + ts.Fatalf("invalid account number %s", args[2]) + } + default: + ts.Fatalf("to create account from metadatas, user name and mnemonic are required ( account and index are optional )") + } + + kb, err := keys.NewKeyBaseFromDir(gnoHomeDir) + if err != nil { + ts.Fatalf("unable to get keybase") + } + + balance, err := createAccountFrom(ts, kb, args[0], args[1], uint32(account), uint32(index)) + if err != nil { + ts.Fatalf("error creating wallet %s", err) + } + + genesis := ts.Value(envKeyGenesis).(*gnoland.GnoGenesisState) + genesis.Balances = append(genesis.Balances, balance) + + fmt.Fprintf(ts.Stdout(), "Added %s(%s) to genesis", args[0], balance.Address) + } +} + +func patchpkgCmd() func(ts *testscript.TestScript, neg bool, args []string) { + return func(ts *testscript.TestScript, neg bool, args []string) { + args, err := unquote(args) + if err != nil { + tsValidateError(ts, "patchpkg", neg, err) + } + + if len(args) != 2 { + ts.Fatalf("`patchpkg`: should have exactly 2 arguments") + } + + pkgs := ts.Value(envKeyPkgsLoader).(*PkgsLoader) + replace, with := args[0], args[1] + pkgs.SetPatch(replace, with) + } +} + +func loadpkgCmd(gnoRootDir string) func(ts *testscript.TestScript, neg bool, args []string) { + return func(ts *testscript.TestScript, neg bool, args []string) { + workDir := ts.Getenv("WORK") + examplesDir := filepath.Join(gnoRootDir, "examples") + + pkgs := ts.Value(envKeyPkgsLoader).(*PkgsLoader) + + var path, name string + switch len(args) { + case 2: + name = args[0] + path = filepath.Clean(args[1]) + case 1: + path = filepath.Clean(args[0]) + case 0: + ts.Fatalf("`loadpkg`: no arguments specified") + default: + ts.Fatalf("`loadpkg`: too many arguments specified") + } + + if path == "all" { + ts.Logf("warning: loading all packages") + if err := pkgs.LoadAllPackagesFromDir(examplesDir); err != nil { + ts.Fatalf("unable to load packages from %q: %s", examplesDir, err) + } + + return + } + + if !strings.HasPrefix(path, workDir) { + path = filepath.Join(examplesDir, path) + } + + if err := pkgs.LoadPackage(examplesDir, path, name); err != nil { + ts.Fatalf("`loadpkg` unable to load package(s) from %q: %s", args[0], err) + } + + ts.Logf("%q package was added to genesis", args[0]) + } +} + +type tsLogWriter struct { + ts *testscript.TestScript +} + +func (l *tsLogWriter) Write(p []byte) (n int, err error) { + l.ts.Logf(string(p)) + return len(p), nil +} + +func setupNode(ts *testscript.TestScript, ctx context.Context, cfg *ProcessNodeConfig) NodeProcess { + pcfg := ProcessConfig{ + Node: cfg, + Stdout: &tsLogWriter{ts}, + Stderr: ts.Stderr(), + } + + // Setup coverdir provided + if coverdir := ts.Getenv("GOCOVERDIR"); coverdir != "" { + pcfg.CoverDir = coverdir + } + + val := ts.Value(envKeyExecCommand) + + switch cmd := val.(commandkind); cmd { + case commandKindInMemory: + nodep, err := RunInMemoryProcess(ctx, pcfg) + if err != nil { + ts.Fatalf("unable to start in memory node: %s", err) + } + + return nodep + + case commandKindTesting: + if !testing.Testing() { + ts.Fatalf("unable to invoke testing process while not testing") + } + + return runTestingNodeProcess(&testingTS{ts}, ctx, pcfg) + + case commandKindBin: + bin := ts.Value(envKeyExecBin).(string) + nodep, err := RunNodeProcess(ctx, pcfg, bin) + if err != nil { + ts.Fatalf("unable to start process node: %s", err) + } + + return nodep + + default: + ts.Fatalf("unknown command kind: %+v", cmd) + } + + return nil +} + +// `unquote` takes a slice of strings, resulting from splitting a string block by spaces, and +// processes them. The function handles quoted phrases and escape characters within these strings. +func unquote(args []string) ([]string, error) { + const quote = '"' + + parts := []string{} + var inQuote bool + + var part strings.Builder + for _, arg := range args { + var escaped bool + for _, c := range arg { + if escaped { + // If the character is meant to be escaped, it is processed with Unquote. + // We use `Unquote` here for two main reasons: + // 1. It will validate that the escape sequence is correct + // 2. It converts the escaped string to its corresponding raw character. + // For example, "\\t" becomes '\t'. + uc, err := strconv.Unquote(`"\` + string(c) + `"`) + if err != nil { + return nil, fmt.Errorf("unhandled escape sequence `\\%c`: %w", c, err) + } + + part.WriteString(uc) + escaped = false + continue + } + + // If we are inside a quoted string and encounter an escape character, + // flag the next character as `escaped` + if inQuote && c == '\\' { + escaped = true + continue + } + + // Detect quote and toggle inQuote state + if c == quote { + inQuote = !inQuote + continue + } + + // Handle regular character + part.WriteRune(c) + } + + // If we're inside a quote, add a single space. + // It reflects one or multiple spaces between args in the original string. + if inQuote { + part.WriteRune(' ') + continue + } + + // Finalize part, add to parts, and reset for next part + parts = append(parts, part.String()) + part.Reset() + } + + // Check if a quote is left open + if inQuote { + return nil, errors.New("unfinished quote") + } + + return parts, nil +} + +func getNodeSID(ts *testscript.TestScript) string { + return ts.Getenv("SID") +} + +func tsValidateError(ts *testscript.TestScript, cmd string, neg bool, err error) { + if err != nil { + fmt.Fprintf(ts.Stderr(), "%q error: %+v\n", cmd, err) + if !neg { + ts.Fatalf("unexpected %q command failure: %s", cmd, err) + } + } else { + if neg { + ts.Fatalf("unexpected %q command success", cmd) + } + } +} + +type envSetter interface { + Setenv(key, value string) +} + +// createAccount creates a new account with the given name and adds it to the keybase. +func createAccount(env envSetter, kb keys.Keybase, accountName string) (gnoland.Balance, error) { + var balance gnoland.Balance + entropy, err := bip39.NewEntropy(256) + if err != nil { + return balance, fmt.Errorf("error creating entropy: %w", err) + } + + mnemonic, err := bip39.NewMnemonic(entropy) + if err != nil { + return balance, fmt.Errorf("error generating mnemonic: %w", err) + } + + var keyInfo keys.Info + if keyInfo, err = kb.CreateAccount(accountName, mnemonic, "", "", 0, 0); err != nil { + return balance, fmt.Errorf("unable to create account: %w", err) + } + + address := keyInfo.GetAddress() + env.Setenv("USER_SEED_"+accountName, mnemonic) + env.Setenv("USER_ADDR_"+accountName, address.String()) + + return gnoland.Balance{ + Address: address, + Amount: std.Coins{std.NewCoin(ugnot.Denom, 10e6)}, + }, nil +} + +// createAccountFrom creates a new account with the given metadata and adds it to the keybase. +func createAccountFrom(env envSetter, kb keys.Keybase, accountName, mnemonic string, account, index uint32) (gnoland.Balance, error) { + var balance gnoland.Balance + + // check if mnemonic is valid + if !bip39.IsMnemonicValid(mnemonic) { + return balance, fmt.Errorf("invalid mnemonic") + } + + keyInfo, err := kb.CreateAccount(accountName, mnemonic, "", "", account, index) + if err != nil { + return balance, fmt.Errorf("unable to create account: %w", err) + } + + address := keyInfo.GetAddress() + env.Setenv("USER_SEED_"+accountName, mnemonic) + env.Setenv("USER_ADDR_"+accountName, address.String()) + + return gnoland.Balance{ + Address: address, + Amount: std.Coins{std.NewCoin(ugnot.Denom, 10e6)}, + }, nil +} + +func buildGnoland(t *testing.T, rootdir string) string { + t.Helper() + + bin := filepath.Join(t.TempDir(), "gnoland-test") + + t.Log("building gnoland integration binary...") + + // Build a fresh gno binary in a temp directory + gnoArgsBuilder := []string{"build", "-o", bin} + + os.Executable() + + // Forward `-covermode` settings if set + if coverMode := testing.CoverMode(); coverMode != "" { + gnoArgsBuilder = append(gnoArgsBuilder, + "-covermode", coverMode, + ) + } + + // Append the path to the gno command source + gnoArgsBuilder = append(gnoArgsBuilder, filepath.Join(rootdir, + "gno.land", "pkg", "integration", "process")) + + t.Logf("build command: %s", strings.Join(gnoArgsBuilder, " ")) + + cmd := exec.Command("go", gnoArgsBuilder...) + + var buff bytes.Buffer + cmd.Stderr, cmd.Stdout = &buff, &buff + defer buff.Reset() + + if err := cmd.Run(); err != nil { + require.FailNowf(t, "unable to build binary", "%q\n%s", + err.Error(), buff.String()) + } + + return bin +} + +// GeneratePrivKeyFromMnemonic generates a crypto.PrivKey from a mnemonic. +func generatePrivKeyFromMnemonic(mnemonic, bip39Passphrase string, account, index uint32) (crypto.PrivKey, error) { + // Generate Seed from Mnemonic + seed, err := bip39.NewSeedWithErrorChecking(mnemonic, bip39Passphrase) + if err != nil { + return nil, fmt.Errorf("failed to generate seed: %w", err) + } + + // Derive Private Key + coinType := crypto.CoinType // ensure this is set correctly in your context + hdPath := hd.NewFundraiserParams(account, coinType, index) + masterPriv, ch := hd.ComputeMastersFromSeed(seed) + derivedPriv, err := hd.DerivePrivateKeyForPath(masterPriv, ch, hdPath.String()) + if err != nil { + return nil, fmt.Errorf("failed to derive private key: %w", err) + } + + // Convert to secp256k1 private key + privKey := secp256k1.PrivKeySecp256k1(derivedPriv) + return privKey, nil +} diff --git a/gno.land/pkg/integration/integration_test.go b/gno.land/pkg/integration/testscript_gnoland_test.go similarity index 93% rename from gno.land/pkg/integration/integration_test.go rename to gno.land/pkg/integration/testscript_gnoland_test.go index 99a3e6c7eca..2c301064969 100644 --- a/gno.land/pkg/integration/integration_test.go +++ b/gno.land/pkg/integration/testscript_gnoland_test.go @@ -8,12 +8,6 @@ import ( "github.com/stretchr/testify/require" ) -func TestTestdata(t *testing.T) { - t.Parallel() - - RunGnolandTestscripts(t, "testdata") -} - func TestUnquote(t *testing.T) { t.Parallel() diff --git a/gno.land/pkg/integration/testing.go b/gno.land/pkg/integration/testscript_testing.go similarity index 95% rename from gno.land/pkg/integration/testing.go rename to gno.land/pkg/integration/testscript_testing.go index 0cd3152d888..9eed180dd8b 100644 --- a/gno.land/pkg/integration/testing.go +++ b/gno.land/pkg/integration/testscript_testing.go @@ -2,6 +2,7 @@ package integration import ( "errors" + "testing" "github.com/rogpeppe/go-internal/testscript" "github.com/stretchr/testify/assert" @@ -16,6 +17,7 @@ var errFailNow = errors.New("fail now!") //nolint:stylecheck var ( _ require.TestingT = (*testingTS)(nil) _ assert.TestingT = (*testingTS)(nil) + _ TestingTS = &testing.T{} ) type TestingTS = require.TestingT diff --git a/gnovm/cmd/gno/testdata_test.go b/gnovm/cmd/gno/testdata_test.go index 6b1bbd1d459..c5cb0def04e 100644 --- a/gnovm/cmd/gno/testdata_test.go +++ b/gnovm/cmd/gno/testdata_test.go @@ -3,7 +3,6 @@ package main import ( "os" "path/filepath" - "strconv" "testing" "github.com/gnolang/gno/gnovm/pkg/integration" @@ -18,25 +17,23 @@ func Test_Scripts(t *testing.T) { testdirs, err := os.ReadDir(testdata) require.NoError(t, err) + homeDir, buildDir := t.TempDir(), t.TempDir() for _, dir := range testdirs { if !dir.IsDir() { continue } name := dir.Name() + t.Logf("testing: %s", name) t.Run(name, func(t *testing.T) { - updateScripts, _ := strconv.ParseBool(os.Getenv("UPDATE_SCRIPTS")) - p := testscript.Params{ - UpdateScripts: updateScripts, - Dir: filepath.Join(testdata, name), - } - + testdir := filepath.Join(testdata, name) + p := integration.NewTestingParams(t, testdir) if coverdir, ok := integration.ResolveCoverageDir(); ok { err := integration.SetupTestscriptsCoverage(&p, coverdir) require.NoError(t, err) } - err := integration.SetupGno(&p, t.TempDir()) + err := integration.SetupGno(&p, homeDir, buildDir) require.NoError(t, err) testscript.Run(t, p) diff --git a/gnovm/pkg/integration/testscript.go b/gnovm/pkg/integration/testscript.go new file mode 100644 index 00000000000..5b7c5c81a44 --- /dev/null +++ b/gnovm/pkg/integration/testscript.go @@ -0,0 +1,36 @@ +package integration + +import ( + "os" + "strconv" + "testing" + + "github.com/rogpeppe/go-internal/testscript" +) + +// NewTestingParams setup and initialize base params for testing. +func NewTestingParams(t *testing.T, testdir string) testscript.Params { + t.Helper() + + var params testscript.Params + params.Dir = testdir + + params.UpdateScripts, _ = strconv.ParseBool(os.Getenv("UPDATE_SCRIPTS")) + params.TestWork, _ = strconv.ParseBool(os.Getenv("TESTWORK")) + if deadline, ok := t.Deadline(); ok && params.Deadline.IsZero() { + params.Deadline = deadline + } + + // Store the original setup scripts for potential wrapping + params.Setup = func(env *testscript.Env) error { + // Set the UPDATE_SCRIPTS environment variable + env.Setenv("UPDATE_SCRIPTS", strconv.FormatBool(params.UpdateScripts)) + + // Set the environment variable + env.Setenv("TESTWORK", strconv.FormatBool(params.TestWork)) + + return nil + } + + return params +} diff --git a/gnovm/pkg/integration/coverage.go b/gnovm/pkg/integration/testscript_coverage.go similarity index 100% rename from gnovm/pkg/integration/coverage.go rename to gnovm/pkg/integration/testscript_coverage.go diff --git a/gnovm/pkg/integration/gno.go b/gnovm/pkg/integration/testscript_gno.go similarity index 87% rename from gnovm/pkg/integration/gno.go rename to gnovm/pkg/integration/testscript_gno.go index a389b6a9b24..03a3fcd6056 100644 --- a/gnovm/pkg/integration/gno.go +++ b/gnovm/pkg/integration/testscript_gno.go @@ -17,7 +17,7 @@ import ( // If the `gno` binary doesn't exist, it's built using the `go build` command into the specified buildDir. // The function also include the `gno` command into `p.Cmds` to and wrap environment into p.Setup // to correctly set up the environment variables needed for the `gno` command. -func SetupGno(p *testscript.Params, buildDir string) error { +func SetupGno(p *testscript.Params, homeDir, buildDir string) error { // Try to fetch `GNOROOT` from the environment variables gnoroot := gnoenv.RootDir() @@ -62,18 +62,10 @@ func SetupGno(p *testscript.Params, buildDir string) error { // Set the GNOROOT environment variable env.Setenv("GNOROOT", gnoroot) - // Create a temporary home directory because certain commands require access to $HOME/.cache/go-build - home, err := os.MkdirTemp("", "gno") - if err != nil { - return fmt.Errorf("unable to create temporary home directory: %w", err) - } - env.Setenv("HOME", home) + env.Setenv("HOME", homeDir) // Avoids go command printing errors relating to lack of go.mod. env.Setenv("GO111MODULE", "off") - // Cleanup home folder - env.Defer(func() { os.RemoveAll(home) }) - return nil } diff --git a/tm2/pkg/bft/config/config.go b/tm2/pkg/bft/config/config.go index f9e9a0cd899..d290dba6b26 100644 --- a/tm2/pkg/bft/config/config.go +++ b/tm2/pkg/bft/config/config.go @@ -372,6 +372,10 @@ func (cfg BaseConfig) NodeKeyFile() string { // DBDir returns the full path to the database directory func (cfg BaseConfig) DBDir() string { + if filepath.IsAbs(cfg.DBPath) { + return cfg.DBPath + } + return filepath.Join(cfg.RootDir, cfg.DBPath) } diff --git a/tm2/pkg/bft/config/config_test.go b/tm2/pkg/bft/config/config_test.go index 77f7c0d5e16..ea37e6e1763 100644 --- a/tm2/pkg/bft/config/config_test.go +++ b/tm2/pkg/bft/config/config_test.go @@ -185,3 +185,28 @@ func TestConfig_ValidateBaseConfig(t *testing.T) { assert.ErrorIs(t, c.BaseConfig.ValidateBasic(), errInvalidProfListenAddress) }) } + +func TestConfig_DBDir(t *testing.T) { + t.Parallel() + + t.Run("DB path is absolute", func(t *testing.T) { + t.Parallel() + + c := DefaultConfig() + c.RootDir = "/root" + c.DBPath = "/abs/path" + + assert.Equal(t, c.DBPath, c.DBDir()) + assert.NotEqual(t, filepath.Join(c.RootDir, c.DBPath), c.DBDir()) + }) + + t.Run("DB path is relative", func(t *testing.T) { + t.Parallel() + + c := DefaultConfig() + c.RootDir = "/root" + c.DBPath = "relative/path" + + assert.Equal(t, filepath.Join(c.RootDir, c.DBPath), c.DBDir()) + }) +}