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()) + }) +}