diff --git a/.github/workflows/contribs.yml b/.github/workflows/contribs.yml index 1b705f20354..4e07a4e8afb 100644 --- a/.github/workflows/contribs.yml +++ b/.github/workflows/contribs.yml @@ -33,5 +33,43 @@ jobs: - uses: actions/setup-go@v5 with: go-version: ${{ matrix.goversion }} - - run: make install ${{ matrix.program }} + - run: make install.${{ matrix.program }} working-directory: contribs + + test: + strategy: + fail-fast: false + matrix: + goversion: # two latest versions + - "1.21.x" + - "1.22.x" + program: + - "gnodev" + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.goversion }} + - run: make test.${{ matrix.program }} + working-directory: contribs + + lint: + strategy: + fail-fast: false + matrix: + goversion: + - "1.22.x" + program: + - "gnodev" + runs-on: ubuntu-latest + timeout-minutes: 5 + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-go@v4 + with: + go-version: ${{ matrix.goversion }} + - run: make lint.${{ matrix.program }} + working-directory: contribs + diff --git a/contribs/Makefile b/contribs/Makefile index e0f31a0cc18..bfd1fcb9889 100644 --- a/contribs/Makefile +++ b/contribs/Makefile @@ -55,5 +55,13 @@ tidy: ######################################## # Test suite .PHONY: test -test: - @echo "nothing to do." +test: test.gnodev +test.gnodev: + $(MAKE) -C ./gnodev test + +######################################## +# Lint +.PHONY: test +lint: lint.gnodev +lint.gnodev: + $(MAKE) -C ./gnodev test diff --git a/contribs/gnodev/Makefile b/contribs/gnodev/Makefile index b98ce0fb44b..01801064d1f 100644 --- a/contribs/gnodev/Makefile +++ b/contribs/gnodev/Makefile @@ -1,5 +1,6 @@ GNOROOT_DIR ?= $(abspath $(lastword $(MAKEFILE_LIST))/../../../) GOBUILD_FLAGS ?= -ldflags "-X github.com/gnolang/gno/gnovm/pkg/gnoenv._GNOROOT=$(GNOROOT_DIR)" +GOTEST_FLAGS ?= $(GOBUILD_FLAGS) -v -p 1 -timeout=5m rundep := go run -modfile ../../misc/devdeps/go.mod golangci_lint := $(rundep) github.com/golangci/golangci-lint/cmd/golangci-lint @@ -12,3 +13,7 @@ build: lint: $(golangci_lint) --config ../../.github/golangci.yml run ./... + +test: + go test $(GOTEST_FLAGS) -v ./... + diff --git a/contribs/gnodev/cmd/gnodev/main.go b/contribs/gnodev/cmd/gnodev/main.go index 652b4a862a3..9b769321c83 100644 --- a/contribs/gnodev/cmd/gnodev/main.go +++ b/contribs/gnodev/cmd/gnodev/main.go @@ -161,7 +161,7 @@ func (c *devCfg) RegisterFlags(fs *flag.FlagSet) { fs.BoolVar( &c.verbose, - "verbose", + "v", defaultDevOptions.verbose, "enable verbose output for development", ) @@ -251,7 +251,7 @@ func execDev(cfg *devCfg, args []string, io commands.IO) (err error) { server := http.Server{ Handler: mux, Addr: cfg.webListenerAddr, - ReadHeaderTimeout: time.Minute, + ReadHeaderTimeout: time.Second * 60, } defer server.Close() diff --git a/contribs/gnodev/internal/mock/server_emitter.go b/contribs/gnodev/internal/mock/server_emitter.go new file mode 100644 index 00000000000..d093d8855fe --- /dev/null +++ b/contribs/gnodev/internal/mock/server_emitter.go @@ -0,0 +1,35 @@ +package emitter + +import ( + "sync" + + "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" + "github.com/gnolang/gno/contribs/gnodev/pkg/events" +) + +// ServerEmitter is an `emitter.Emitter` +var _ emitter.Emitter = (*ServerEmitter)(nil) + +type ServerEmitter struct { + events []events.Event + muEvents sync.Mutex +} + +func (m *ServerEmitter) Emit(evt events.Event) { + m.muEvents.Lock() + defer m.muEvents.Unlock() + + m.events = append(m.events, evt) +} + +func (m *ServerEmitter) NextEvent() (evt events.Event) { + m.muEvents.Lock() + defer m.muEvents.Unlock() + + if len(m.events) > 0 { + // pull next event from the list + evt, m.events = m.events[0], m.events[1:] + } + + return evt +} diff --git a/contribs/gnodev/pkg/address/book_test.go b/contribs/gnodev/pkg/address/book_test.go index 80249762455..4ae0552a806 100644 --- a/contribs/gnodev/pkg/address/book_test.go +++ b/contribs/gnodev/pkg/address/book_test.go @@ -32,16 +32,12 @@ func TestAddEmptyName(t *testing.T) { } func TestAdd(t *testing.T) { - t.Parallel() - bk := NewBook() // Add address bk.Add(testAddr, "testname") t.Run("get by address", func(t *testing.T) { - t.Parallel() - names, ok := bk.GetByAddress(testAddr) require.True(t, ok) require.Equal(t, 1, len(names)) @@ -49,8 +45,6 @@ func TestAdd(t *testing.T) { }) t.Run("get by name", func(t *testing.T) { - t.Parallel() - addrFromName, ok := bk.GetByName("testname") assert.True(t, ok) assert.True(t, addrFromName.Compare(testAddr) == 0) @@ -60,8 +54,6 @@ func TestAdd(t *testing.T) { bk.Add(testAddr, "testname2") t.Run("get two names with same address", func(t *testing.T) { - t.Parallel() - // Get by name addr1, ok := bk.GetByName("testname") require.True(t, ok) diff --git a/contribs/gnodev/pkg/dev/node.go b/contribs/gnodev/pkg/dev/node.go index 66971980b73..7756a1c866d 100644 --- a/contribs/gnodev/pkg/dev/node.go +++ b/contribs/gnodev/pkg/dev/node.go @@ -6,6 +6,7 @@ import ( "log/slog" "path/filepath" "strings" + "sync" "unicode" "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" @@ -64,6 +65,7 @@ func DefaultNodeConfig(rootdir string) *NodeConfig { // Node is not thread safe type Node struct { *node.Node + muNode sync.RWMutex config *NodeConfig emitter emitter.Emitter @@ -87,61 +89,119 @@ func NewDevNode(ctx context.Context, logger *slog.Logger, emitter emitter.Emitte if err != nil { return nil, fmt.Errorf("unable to load genesis packages: %w", err) } - logger.Info("pkgs loaded", "path", cfg.PackagesPathList) - // generate genesis state - genesis := gnoland.GnoGenesisState{ - Balances: cfg.BalancesList, - Txs: pkgsTxs, - } - devnode := &Node{ config: cfg, - emitter: emitter, client: client.NewLocal(), + emitter: emitter, pkgs: mpkgs, logger: logger, loadedPackages: len(pkgsTxs), } - if err := devnode.reset(ctx, genesis); err != nil { + // generate genesis state + genesis := gnoland.GnoGenesisState{ + Balances: cfg.BalancesList, + Txs: pkgsTxs, + } + + if err := devnode.rebuildNode(ctx, genesis); err != nil { return nil, fmt.Errorf("unable to initialize the node: %w", err) } return devnode, nil } -func (n *Node) getLatestBlockNumber() uint64 { - return uint64(n.Node.BlockStore().Height()) -} - func (n *Node) Close() error { + n.muNode.Lock() + defer n.muNode.Unlock() + return n.Node.Stop() } func (n *Node) ListPkgs() []gnomod.Pkg { + n.muNode.RLock() + defer n.muNode.RUnlock() + return n.pkgs.toList() } -func (n *Node) GetNodeReadiness() <-chan struct{} { - return gnoland.GetNodeReadiness(n.Node) +func (n *Node) Client() client.Client { + n.muNode.RLock() + defer n.muNode.RUnlock() + + return n.client } func (n *Node) GetRemoteAddress() string { return n.Node.Config().RPC.ListenAddress } +// GetBlockTransactions returns the transactions contained +// within the specified block, if any +func (n *Node) GetBlockTransactions(blockNum uint64) ([]std.Tx, error) { + n.muNode.RLock() + defer n.muNode.RUnlock() + + return n.getBlockTransactions(blockNum) +} + +// GetBlockTransactions returns the transactions contained +// within the specified block, if any +func (n *Node) getBlockTransactions(blockNum uint64) ([]std.Tx, error) { + int64BlockNum := int64(blockNum) + b, err := n.client.Block(&int64BlockNum) + if err != nil { + return []std.Tx{}, fmt.Errorf("unable to load block at height %d: %w", blockNum, err) // nothing to see here + } + + txs := make([]std.Tx, len(b.Block.Data.Txs)) + for i, encodedTx := range b.Block.Data.Txs { + var tx std.Tx + if unmarshalErr := amino.Unmarshal(encodedTx, &tx); unmarshalErr != nil { + return nil, fmt.Errorf("unable to unmarshal amino tx, %w", unmarshalErr) + } + + txs[i] = tx + } + + return txs, nil +} + +// GetBlockTransactions returns the transactions contained +// within the specified block, if any +// GetLatestBlockNumber returns the latest block height from the chain +func (n *Node) GetLatestBlockNumber() (uint64, error) { + n.muNode.RLock() + defer n.muNode.RUnlock() + + return n.getLatestBlockNumber(), nil +} + +func (n *Node) getLatestBlockNumber() uint64 { + return uint64(n.Node.BlockStore().Height()) +} + // UpdatePackages updates the currently known packages. It will be taken into // consideration in the next reload of the node. func (n *Node) UpdatePackages(paths ...string) error { - var i int + n.muNode.Lock() + defer n.muNode.Unlock() + + return n.updatePackages(paths...) +} + +func (n *Node) updatePackages(paths ...string) error { + var pkgsUpdated int for _, path := range paths { abspath, err := filepath.Abs(path) if err != nil { return fmt.Errorf("unable to resolve abs path of %q: %w", path, err) } + // Check if we already know the path (or its parent) and set + // associated deployer and deposit deployer := n.config.DefaultDeployer var deposit std.Coins for _, ppath := range n.config.PackagesPathList { @@ -170,16 +230,19 @@ func (n *Node) UpdatePackages(paths ...string) error { n.logger.Debug("pkgs update", "name", pkg.Name, "path", pkg.Dir) } - i += len(pkgslist) + pkgsUpdated += len(pkgslist) } - n.logger.Info(fmt.Sprintf("updated %d pacakges", i)) + n.logger.Info(fmt.Sprintf("updated %d pacakges", pkgsUpdated)) return nil } // Reset stops the node, if running, and reloads it with a new genesis state, // effectively ignoring the current state. func (n *Node) Reset(ctx context.Context) error { + n.muNode.Lock() + defer n.muNode.Unlock() + // Stop the node if it's currently running. if err := n.stopIfRunning(); err != nil { return fmt.Errorf("unable to stop the node: %w", err) @@ -197,7 +260,7 @@ func (n *Node) Reset(ctx context.Context) error { } // Reset the node with the new genesis state. - err = n.reset(ctx, genesis) + err = n.rebuildNode(ctx, genesis) if err != nil { return fmt.Errorf("unable to initialize a new node: %w", err) } @@ -207,18 +270,22 @@ func (n *Node) Reset(ctx context.Context) error { } // ReloadAll updates all currently known packages and then reloads the node. +// It's actually a simple combination between `UpdatePackage` and `Reload` method. func (n *Node) ReloadAll(ctx context.Context) error { - pkgs := n.ListPkgs() + n.muNode.Lock() + defer n.muNode.Unlock() + + pkgs := n.pkgs.toList() paths := make([]string, len(pkgs)) for i, pkg := range pkgs { paths[i] = pkg.Dir } - if err := n.UpdatePackages(paths...); err != nil { + if err := n.updatePackages(paths...); err != nil { return fmt.Errorf("unable to reload packages: %w", err) } - return n.Reload(ctx) + return n.rebuildNodeFromState(ctx) } // Reload saves the current state, stops the node if running, starts a new node, @@ -226,113 +293,18 @@ func (n *Node) ReloadAll(ctx context.Context) error { // If any transaction, including 'addpkg', fails, it will be ignored. // Use 'Reset' to completely reset the node's state in case of persistent errors. func (n *Node) Reload(ctx context.Context) error { - if n.config.NoReplay { - // If NoReplay is true, reload as the same effect as reset - n.logger.Warn("replay disable") - return n.Reset(ctx) - } - - // Get current blockstore state - state, err := n.getBlockStoreState(ctx) - if err != nil { - return fmt.Errorf("unable to save state: %s", err.Error()) - } - - // Stop the node if it's currently running. - if err := n.stopIfRunning(); err != nil { - return fmt.Errorf("unable to stop the node: %w", err) - } - - // Load genesis packages - pkgsTxs, err := n.pkgs.Load(DefaultFee) - if err != nil { - return fmt.Errorf("unable to load pkgs: %w", err) - } - - // Create genesis with loaded pkgs + previous state - genesis := gnoland.GnoGenesisState{ - Balances: n.config.BalancesList, - Txs: append(pkgsTxs, state...), - } - - // Reset the node with the new genesis state. - err = n.reset(ctx, genesis) - n.logger.Info("reload done", "pkgs", len(pkgsTxs), "state applied", len(state)) - - // Update node infos - n.loadedPackages = len(pkgsTxs) - - n.emitter.Emit(&events.Reload{}) - return nil -} - -func (n *Node) genesisTxHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) { - if res.IsErr() { - // XXX: for now, this is only way to catch the error - before, after, found := strings.Cut(res.Log, "\n") - if !found { - n.logger.Error("unable to send tx", "err", res.Error, "log", res.Log) - return - } - - var attrs []slog.Attr - - // Add error - attrs = append(attrs, slog.Any("err", res.Error)) - - // Fetch first line as error message - msg := strings.TrimFunc(before, func(r rune) bool { - return unicode.IsSpace(r) || r == ':' - }) - attrs = append(attrs, slog.String("err", msg)) - - // If debug is enable, also append stack - if n.logger.Enabled(context.Background(), slog.LevelDebug) { - attrs = append(attrs, slog.String("stack", after)) - } - - n.logger.LogAttrs(context.Background(), slog.LevelError, "unable to deliver tx", attrs...) - } -} - -// GetBlockTransactions returns the transactions contained -// within the specified block, if any -func (n *Node) GetBlockTransactions(blockNum uint64) ([]std.Tx, error) { - int64BlockNum := int64(blockNum) - b, err := n.client.Block(&int64BlockNum) - if err != nil { - return []std.Tx{}, fmt.Errorf("unable to load block at height %d: %w", blockNum, err) // nothing to see here - } - - txs := make([]std.Tx, len(b.Block.Data.Txs)) - for i, encodedTx := range b.Block.Data.Txs { - var tx std.Tx - if unmarshalErr := amino.Unmarshal(encodedTx, &tx); unmarshalErr != nil { - return nil, fmt.Errorf("unable to unmarshal amino tx, %w", unmarshalErr) - } - - txs[i] = tx - } + n.muNode.Lock() + defer n.muNode.Unlock() - return txs, nil -} - -// GetBlockTransactions returns the transactions contained -// within the specified block, if any -func (n *Node) CurrentBalances(blockNum uint64) ([]std.Tx, error) { - return nil, nil -} - -// GetBlockTransactions returns the transactions contained -// within the specified block, if any -// GetLatestBlockNumber returns the latest block height from the chain -func (n *Node) GetLatestBlockNumber() (uint64, error) { - return n.getLatestBlockNumber(), nil + return n.rebuildNodeFromState(ctx) } // SendTransaction executes a broadcast commit send // of the specified transaction to the chain func (n *Node) SendTransaction(tx *std.Tx) error { + n.muNode.RLock() + defer n.muNode.RUnlock() + aminoTx, err := amino.Marshal(tx) if err != nil { return fmt.Errorf("unable to marshal transaction to amino binary, %w", err) @@ -371,7 +343,7 @@ func (n *Node) getBlockStoreState(ctx context.Context) ([]std.Tx, error) { default: } - txs, txErr := n.GetBlockTransactions(blocnum) + txs, txErr := n.getBlockTransactions(blocnum) if txErr != nil { return nil, fmt.Errorf("unable to fetch block transactions, %w", txErr) } @@ -393,7 +365,55 @@ func (n *Node) stopIfRunning() error { return nil } -func (n *Node) reset(ctx context.Context, genesis gnoland.GnoGenesisState) (err error) { +func (n *Node) rebuildNodeFromState(ctx context.Context) error { + if n.config.NoReplay { + // If NoReplay is true, simply reset the node to its initial state + n.logger.Warn("replay disabled") + + txs, err := n.pkgs.Load(DefaultFee) + if err != nil { + return fmt.Errorf("unable to load pkgs: %w", err) + } + + return n.rebuildNode(ctx, gnoland.GnoGenesisState{ + Balances: n.config.BalancesList, Txs: txs, + }) + } + + state, err := n.getBlockStoreState(ctx) + if err != nil { + return fmt.Errorf("unable to save state: %s", err.Error()) + } + + // Load genesis packages + pkgsTxs, err := n.pkgs.Load(DefaultFee) + if err != nil { + return fmt.Errorf("unable to load pkgs: %w", err) + } + + // Create genesis with loaded pkgs + previous state + genesis := gnoland.GnoGenesisState{ + Balances: n.config.BalancesList, + Txs: append(pkgsTxs, state...), + } + + // Reset the node with the new genesis state. + err = n.rebuildNode(ctx, genesis) + n.logger.Info("reload done", "pkgs", len(pkgsTxs), "state applied", len(state)) + + // Update node infos + n.loadedPackages = len(pkgsTxs) + + n.emitter.Emit(&events.Reload{}) + return nil +} + +func (n *Node) rebuildNode(ctx context.Context, genesis gnoland.GnoGenesisState) (err error) { + // Stop the node if it's currently running. + if err := n.stopIfRunning(); err != nil { + return fmt.Errorf("unable to stop the node: %w", err) + } + // Setup node config nodeConfig := newNodeConfig(n.config.TMConfig, n.config.ChainID, genesis) nodeConfig.GenesisTxHandler = n.genesisTxHandler @@ -423,7 +443,7 @@ func (n *Node) reset(ctx context.Context, genesis gnoland.GnoGenesisState) (err // Wait for the node to be ready select { - case <-gnoland.GetNodeReadiness(node): // Ok + case <-node.Ready(): // Ok n.Node = node case <-ctx.Done(): return ctx.Err() @@ -432,6 +452,39 @@ func (n *Node) reset(ctx context.Context, genesis gnoland.GnoGenesisState) (err return nil } +func (n *Node) genesisTxHandler(ctx sdk.Context, tx std.Tx, res sdk.Result) { + if !res.IsErr() { + return + } + + // XXX: for now, this is only way to catch the error + before, after, found := strings.Cut(res.Log, "\n") + if !found { + n.logger.Error("unable to send tx", "err", res.Error, "log", res.Log) + return + } + + var attrs []slog.Attr + + // Add error + attrs = append(attrs, slog.Any("err", res.Error)) + + // Fetch first line as error message + msg := strings.TrimFunc(before, func(r rune) bool { + return unicode.IsSpace(r) || r == ':' + }) + attrs = append(attrs, slog.String("err", msg)) + + // If debug is enable, also append stack + if n.logger.Enabled(context.Background(), slog.LevelDebug) { + attrs = append(attrs, slog.String("stack", after)) + } + + n.logger.LogAttrs(context.Background(), slog.LevelError, "unable to deliver tx", attrs...) + + return +} + var noopLogger = log.NewNoopLogger() func buildNode(logger *slog.Logger, emitter emitter.Emitter, cfg *gnoland.InMemoryNodeConfig) (*node.Node, error) { diff --git a/contribs/gnodev/pkg/dev/node_test.go b/contribs/gnodev/pkg/dev/node_test.go new file mode 100644 index 00000000000..d4868a5766f --- /dev/null +++ b/contribs/gnodev/pkg/dev/node_test.go @@ -0,0 +1,314 @@ +package dev + +import ( + "context" + "os" + "path/filepath" + "testing" + + mock "github.com/gnolang/gno/contribs/gnodev/internal/mock" + + "github.com/gnolang/gno/contribs/gnodev/pkg/emitter" + "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gnolang/gno/gno.land/pkg/gnoclient" + "github.com/gnolang/gno/gno.land/pkg/integration" + "github.com/gnolang/gno/gnovm/pkg/gnoenv" + core_types "github.com/gnolang/gno/tm2/pkg/bft/rpc/core/types" + "github.com/gnolang/gno/tm2/pkg/crypto" + "github.com/gnolang/gno/tm2/pkg/crypto/keys" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// XXX: We should probably use txtar to test this package. + +var nodeTestingAddress = crypto.MustAddressFromString("g1jg8mtutu9khhfwc4nxmuhcpftf0pajdhfvsqf5") + +// TestNewNode_NoPackages tests the NewDevNode method with no package. +func TestNewNode_NoPackages(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewTestingLogger(t) + + // Call NewDevNode with no package should work + cfg := DefaultNodeConfig(gnoenv.RootDir()) + node, err := NewDevNode(ctx, logger, &emitter.NoopServer{}, cfg) + require.NoError(t, err) + + assert.Len(t, node.ListPkgs(), 0) + + require.NoError(t, node.Close()) +} + +// TestNewNode_WithPackage tests the NewDevNode with a single package. +func TestNewNode_WithPackage(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + const ( + // foobar package + testGnoMod = "module gno.land/r/dev/foobar\n" + testFile = `package foobar +func Render(_ string) string { return "foo" } +` + ) + + // Generate package + pkgpath := generateTestingPackage(t, "gno.mod", testGnoMod, "foobar.gno", testFile) + logger := log.NewTestingLogger(t) + + // Call NewDevNode with no package should work + cfg := DefaultNodeConfig(gnoenv.RootDir()) + cfg.PackagesPathList = []PackagePath{pkgpath} + node, err := NewDevNode(ctx, logger, &emitter.NoopServer{}, cfg) + require.NoError(t, err) + assert.Len(t, node.ListPkgs(), 1) + + // Test rendering + render, err := testingRenderRealm(t, node, "gno.land/r/dev/foobar") + require.NoError(t, err) + assert.Equal(t, render, "foo") + + require.NoError(t, node.Close()) +} + +func TestNodeAddPackage(t *testing.T) { + // Setup a Node instance + const ( + // foo package + fooGnoMod = "module gno.land/r/dev/foo\n" + fooFile = `package foo +func Render(_ string) string { return "foo" } +` + // bar package + barGnoMod = "module gno.land/r/dev/bar\n" + barFile = `package bar +func Render(_ string) string { return "bar" } +` + ) + + // Generate package foo + foopkg := generateTestingPackage(t, "gno.mod", fooGnoMod, "foo.gno", fooFile) + + // Call NewDevNode with no package should work + node, emitter := newTestingDevNode(t, foopkg) + assert.Len(t, node.ListPkgs(), 1) + + // Test render + render, err := testingRenderRealm(t, node, "gno.land/r/dev/foo") + require.NoError(t, err) + require.Equal(t, render, "foo") + + // Generate package bar + barpkg := generateTestingPackage(t, "gno.mod", barGnoMod, "bar.gno", barFile) + err = node.UpdatePackages(barpkg.Path) + require.NoError(t, err) + assert.Len(t, node.ListPkgs(), 2) + + // Render should fail as the node hasn't reloaded + render, err = testingRenderRealm(t, node, "gno.land/r/dev/bar") + require.Error(t, err) + + err = node.Reload(context.Background()) + require.NoError(t, err) + assert.Equal(t, emitter.NextEvent().Type(), events.EvtReload) + + // After a reload, render should succeed + render, err = testingRenderRealm(t, node, "gno.land/r/dev/bar") + require.NoError(t, err) + require.Equal(t, render, "bar") +} + +func TestNodeUpdatePackage(t *testing.T) { + // Setup a Node instance + const ( + // foo package + foobarGnoMod = "module gno.land/r/dev/foobar\n" + fooFile = `package foobar +func Render(_ string) string { return "foo" } +` + barFile = `package foobar +func Render(_ string) string { return "bar" } +` + ) + + // Generate package foo + foopkg := generateTestingPackage(t, "gno.mod", foobarGnoMod, "foo.gno", fooFile) + + // Call NewDevNode with no package should work + node, emitter := newTestingDevNode(t, foopkg) + assert.Len(t, node.ListPkgs(), 1) + + // Test that render is correct + render, err := testingRenderRealm(t, node, "gno.land/r/dev/foobar") + require.NoError(t, err) + require.Equal(t, render, "foo") + + // Override `foo.gno` file with bar content + err = os.WriteFile(filepath.Join(foopkg.Path, "foo.gno"), []byte(barFile), 0o700) + require.NoError(t, err) + + err = node.Reload(context.Background()) + require.NoError(t, err) + + // Check reload event + assert.Equal(t, emitter.NextEvent().Type(), events.EvtReload) + + // After a reload, render should succeed + render, err = testingRenderRealm(t, node, "gno.land/r/dev/foobar") + require.NoError(t, err) + require.Equal(t, render, "bar") + + assert.Nil(t, emitter.NextEvent()) +} + +func TestNodeReset(t *testing.T) { + const ( + // foo package + foobarGnoMod = "module gno.land/r/dev/foo\n" + fooFile = `package foo +var str string = "foo" +func UpdateStr(newStr string) { str = newStr } // method to update 'str' variable +func Render(_ string) string { return str } +` + ) + + // Generate package foo + foopkg := generateTestingPackage(t, "gno.mod", foobarGnoMod, "foo.gno", fooFile) + + // Call NewDevNode with no package should work + node, emitter := newTestingDevNode(t, foopkg) + assert.Len(t, node.ListPkgs(), 1) + + // Test rendering + render, err := testingRenderRealm(t, node, "gno.land/r/dev/foo") + require.NoError(t, err) + require.Equal(t, render, "foo") + + // Call `UpdateStr` to update `str` value with "bar" + msg := gnoclient.MsgCall{ + PkgPath: "gno.land/r/dev/foo", + FuncName: "UpdateStr", + Args: []string{"bar"}, + Send: "", + } + res, err := testingCallRealm(t, node, msg) + require.NoError(t, err) + require.NoError(t, res.CheckTx.Error) + require.NoError(t, res.DeliverTx.Error) + assert.Equal(t, emitter.NextEvent().Type(), events.EvtTxResult) + + // Check for correct render update + render, err = testingRenderRealm(t, node, "gno.land/r/dev/foo") + require.NoError(t, err) + require.Equal(t, render, "bar") + + // Reset state + err = node.Reset(context.Background()) + require.NoError(t, err) + assert.Equal(t, emitter.NextEvent().Type(), events.EvtReset) + + // Test rendering should return initial `str` value + render, err = testingRenderRealm(t, node, "gno.land/r/dev/foo") + require.NoError(t, err) + require.Equal(t, render, "foo") + + assert.Nil(t, emitter.NextEvent()) +} + +func testingRenderRealm(t *testing.T, node *Node, rlmpath string) (string, error) { + t.Helper() + + signer := newInMemorySigner(t, node.Config().ChainID()) + cli := gnoclient.Client{ + Signer: signer, + RPCClient: node.Client(), + } + + render, res, err := cli.Render(rlmpath, "") + if err == nil { + err = res.Response.Error + } + + return render, err +} + +func testingCallRealm(t *testing.T, node *Node, msgs ...gnoclient.MsgCall) (*core_types.ResultBroadcastTxCommit, error) { + t.Helper() + + signer := newInMemorySigner(t, node.Config().ChainID()) + cli := gnoclient.Client{ + Signer: signer, + RPCClient: node.Client(), + } + + txcfg := gnoclient.BaseTxCfg{ + GasFee: "1000000ugnot", // Gas fee + GasWanted: 2_000_000, // Gas wanted + } + + return cli.Call(txcfg, msgs...) +} + +func generateTestingPackage(t *testing.T, nameFile ...string) PackagePath { + t.Helper() + workdir := t.TempDir() + + if len(nameFile)%2 != 0 { + require.FailNow(t, "Generate testing packages require paired arguments.") + } + + for i := 0; i < len(nameFile); i += 2 { + name := nameFile[i] + content := nameFile[i+1] + + err := os.WriteFile(filepath.Join(workdir, name), []byte(content), 0o700) + require.NoError(t, err) + } + + return PackagePath{ + Path: workdir, + Creator: nodeTestingAddress, + } +} + +func newTestingDevNode(t *testing.T, pkgslist ...PackagePath) (*Node, *mock.ServerEmitter) { + t.Helper() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + logger := log.NewTestingLogger(t) + + emitter := &mock.ServerEmitter{} + + // Call NewDevNode with no package should work + cfg := DefaultNodeConfig(gnoenv.RootDir()) + cfg.PackagesPathList = pkgslist + node, err := NewDevNode(ctx, logger, emitter, cfg) + require.NoError(t, err) + assert.Len(t, node.ListPkgs(), len(pkgslist)) + + t.Cleanup(func() { node.Close() }) + + return node, emitter +} + +func newInMemorySigner(t *testing.T, chainid string) *gnoclient.SignerFromKeybase { + t.Helper() + + mnemonic := integration.DefaultAccount_Seed + name := integration.DefaultAccount_Name + + kb := keys.NewInMemory() + _, err := kb.CreateAccount(name, mnemonic, "", "", uint32(0), uint32(0)) + require.NoError(t, err) + + return &gnoclient.SignerFromKeybase{ + Keybase: kb, // Stores keys in memory + Account: name, // Account name + Password: "", // Password for encryption + ChainID: chainid, // Chain ID for transaction signing + } +} diff --git a/contribs/gnodev/pkg/dev/packages.go b/contribs/gnodev/pkg/dev/packages.go index f58775277e9..7b560c21e09 100644 --- a/contribs/gnodev/pkg/dev/packages.go +++ b/contribs/gnodev/pkg/dev/packages.go @@ -27,7 +27,7 @@ func ResolvePackagePathQuery(bk *address.Book, path string) (PackagePath, error) if err != nil { return ppath, fmt.Errorf("malformed path/query: %w", err) } - ppath.Path = upath.Path + ppath.Path = filepath.Clean(upath.Path) // Check for creator option creator := upath.Query().Get("creator") diff --git a/contribs/gnodev/pkg/dev/packages_test.go b/contribs/gnodev/pkg/dev/packages_test.go index dbae99b4484..605db312429 100644 --- a/contribs/gnodev/pkg/dev/packages_test.go +++ b/contribs/gnodev/pkg/dev/packages_test.go @@ -35,28 +35,31 @@ func TestResolvePackagePathQuery(t *testing.T) { {"/ambiguo/u//s/path///", PackagePath{ Path: "/ambiguo/u/s/path", }, false}, - {"/path/with/deployer?deployer=testAccount", PackagePath{ - Path: "/path/with/deployer", + {"/path/with/creator?creator=testAccount", PackagePath{ + Path: "/path/with/creator", Creator: testingAddress, }, false}, {"/path/with/deposit?deposit=100ugnot", PackagePath{ Path: "/path/with/deposit", Deposit: std.MustParseCoins("100ugnot"), }, false}, - {".?deployer=g1hr3dl82qdy84a5h3dmckh0suc7zgwm5rnns6na&deposit=100ugnot", PackagePath{ + {".?creator=g1hr3dl82qdy84a5h3dmckh0suc7zgwm5rnns6na&deposit=100ugnot", PackagePath{ Path: ".", Creator: testingAddress, Deposit: std.MustParseCoins("100ugnot"), }, false}, // errors cases - {"/invalid/account?deployer=UnknownAccount", PackagePath{}, true}, - {"/invalid/address?deployer=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", PackagePath{}, true}, + {"/invalid/account?creator=UnknownAccount", PackagePath{}, true}, + {"/invalid/address?creator=zzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzzz", PackagePath{}, true}, {"/invalid/deposit?deposit=abcd", PackagePath{}, true}, } for _, tc := range cases { + tc := tc t.Run(tc.Path, func(t *testing.T) { + t.Parallel() + result, err := ResolvePackagePathQuery(book, tc.Path) if tc.ShouldFail { assert.Error(t, err) diff --git a/contribs/gnodev/pkg/emitter/server.go b/contribs/gnodev/pkg/emitter/server.go index 68a304d38fe..68faf27122b 100644 --- a/contribs/gnodev/pkg/emitter/server.go +++ b/contribs/gnodev/pkg/emitter/server.go @@ -17,7 +17,7 @@ type Server struct { logger *slog.Logger upgrader websocket.Upgrader clients map[*websocket.Conn]struct{} - muClients sync.RWMutex + muClients sync.Mutex } func NewServer(logger *slog.Logger) *Server { @@ -60,14 +60,16 @@ func (s *Server) Emit(evt events.Event) { go s.emit(evt) } +type eventJSON struct { + Type events.Type `json:"type"` + Data any `json:"data"` +} + func (s *Server) emit(evt events.Event) { - s.muClients.RLock() - defer s.muClients.RUnlock() + s.muClients.Lock() + defer s.muClients.Unlock() - jsonEvt := struct { - Type events.Type `json:"type"` - Data any `json:"data"` - }{evt.Type(), evt} + jsonEvt := eventJSON{evt.Type(), evt} s.logger.Info("sending event to clients", "clients", len(s.clients), diff --git a/contribs/gnodev/pkg/emitter/server_noop.go b/contribs/gnodev/pkg/emitter/server_noop.go new file mode 100644 index 00000000000..67c46f04435 --- /dev/null +++ b/contribs/gnodev/pkg/emitter/server_noop.go @@ -0,0 +1,7 @@ +package emitter + +import "github.com/gnolang/gno/contribs/gnodev/pkg/events" + +type NoopServer struct{} + +func (*NoopServer) Emit(evt events.Event) {} diff --git a/contribs/gnodev/pkg/emitter/server_test.go b/contribs/gnodev/pkg/emitter/server_test.go new file mode 100644 index 00000000000..6e1ba71b097 --- /dev/null +++ b/contribs/gnodev/pkg/emitter/server_test.go @@ -0,0 +1,46 @@ +package emitter + +import ( + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gnolang/gno/contribs/gnodev/pkg/events" + "github.com/gnolang/gno/tm2/pkg/log" + "github.com/gorilla/websocket" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestServer_New(t *testing.T) { + t.Parallel() + + svr := NewServer(log.NewTestingLogger(t)) + assert.Len(t, svr.clients, 0) +} + +func TestServer_ServeHTTP(t *testing.T) { + t.Parallel() + + svr := NewServer(log.NewTestingLogger(t)) + + s := httptest.NewServer(http.HandlerFunc(svr.ServeHTTP)) + defer s.Close() + + u := "ws" + strings.TrimPrefix(s.URL, "http") + c, _, err := websocket.DefaultDialer.Dial(u, nil) + if err != nil { + t.Fatalf("client Dial failed: %v", err) + } + defer c.Close() + + sendEvt := events.Custom("TEST") + assert.Len(t, svr.clients, 1) + svr.Emit(sendEvt) // simulate reload + + var recvEvt eventJSON + err = c.ReadJSON(&recvEvt) + require.NoError(t, err) + assert.Equal(t, sendEvt.Type(), recvEvt.Type) +} diff --git a/contribs/gnodev/pkg/events/events_custom.go b/contribs/gnodev/pkg/events/events_custom.go new file mode 100644 index 00000000000..ee98cc90586 --- /dev/null +++ b/contribs/gnodev/pkg/events/events_custom.go @@ -0,0 +1,23 @@ +package events + +import "fmt" + +type customEvent struct { + name string +} + +// Custom create a new event with the given name, prefixing it with "CUSTOM_". +// If the name is empty, it will panic. +func Custom(name string) Event { + if name == "" { + panic("custom event cannot have an empty name") + } + + return &customEvent{name: fmt.Sprintf("CUSTOM_%s", name)} +} + +func (customEvent) assertEvent() {} + +func (c *customEvent) Type() Type { + return Type(c.name) +}