diff --git a/tests/antithesis/avalanchego/main.go b/tests/antithesis/avalanchego/main.go index e89c58cef5d9..99b1e623b30c 100644 --- a/tests/antithesis/avalanchego/main.go +++ b/tests/antithesis/avalanchego/main.go @@ -20,6 +20,7 @@ import ( "github.com/ava-labs/avalanchego/ids" "github.com/ava-labs/avalanchego/tests" "github.com/ava-labs/avalanchego/tests/antithesis" + "github.com/ava-labs/avalanchego/tests/e2e/banff" "github.com/ava-labs/avalanchego/tests/fixture/e2e" "github.com/ava-labs/avalanchego/tests/fixture/tmpnet" "github.com/ava-labs/avalanchego/utils/constants" @@ -44,10 +45,13 @@ import ( const NumKeys = 5 +// TODO(marun) Switch to using zap for logging +// TODO(marun) Extract the common elements of test execution for reuse across test setups + func main() { // TODO(marun) Support choosing the log format - tc := tests.NewTestContext(tests.NewDefaultLogger("")) - defer tc.Cleanup() + tc := antithesis.NewInstrumentedTestContext(tests.NewDefaultLogger("")) + defer tc.RecoverAndExit() require := require.New(tc) c := antithesis.NewConfig( @@ -57,15 +61,12 @@ func main() { }, ) ctx := tests.DefaultNotifyContext(c.Duration, tc.DeferCleanup) + // Ensure contexts sourced from the test context use the notify context as their parent + tc.SetDefaultContextParent(ctx) kc := secp256k1fx.NewKeychain(genesis.EWOQKey) walletSyncStartTime := time.Now() - wallet, err := primary.MakeWallet(ctx, &primary.WalletConfig{ - URI: c.URIs[0], - AVAXKeychain: kc, - EthKeychain: kc, - }) - require.NoError(err, "failed to initialize wallet") + wallet := e2e.NewWallet(tc, kc, tmpnet.NodeURI{URI: c.URIs[0]}) tc.Log().Info("synced wallet", zap.Duration("duration", time.Since(walletSyncStartTime)), ) @@ -120,12 +121,7 @@ func main() { uri := c.URIs[i%len(c.URIs)] kc := secp256k1fx.NewKeychain(key) walletSyncStartTime := time.Now() - wallet, err := primary.MakeWallet(ctx, &primary.WalletConfig{ - URI: uri, - AVAXKeychain: kc, - EthKeychain: kc, - }) - require.NoError(err, "failed to initialize wallet") + wallet := e2e.NewWallet(tc, kc, tmpnet.NodeURI{URI: uri}) tc.Log().Info("synced wallet", zap.Duration("duration", time.Since(walletSyncStartTime)), ) @@ -158,12 +154,25 @@ type workload struct { uris []string } +// newTestContext returns a test context that ensures that log output and assertions are +// associated with this worker. +func (w *workload) newTestContext(ctx context.Context) *tests.SimpleTestContext { + return antithesis.NewInstrumentedTestContextWithArgs( + ctx, + w.log, + map[string]any{ + "worker": w.id, + }, + ) +} + func (w *workload) run(ctx context.Context) { timer := timerpkg.StoppedTimer() - tc := tests.NewTestContext(w.log) - defer tc.Cleanup() - require := require.New(tc) + tc := w.newTestContext(ctx) + // Any assertion failure from this test context will result in process exit due to the + // panic being rethrown. This ensures that failures in test setup are fatal. + defer tc.Recover(true /* rethrow */) xAVAX, pAVAX := e2e.GetWalletBalances(tc, w.wallet) assert.Reachable("wallet starting", map[string]any{ @@ -172,32 +181,30 @@ func (w *workload) run(ctx context.Context) { "pBalance": pAVAX, }) + defaultExecutionDelay := big.NewInt(int64(time.Second)) for { - val, err := rand.Int(rand.Reader, big.NewInt(5)) - require.NoError(err, "failed to read randomness") + w.executeTest(ctx) - flowID := val.Int64() - w.log.Info("executing test", - zap.Int("workerID", w.id), - zap.Int64("flowID", flowID), - ) - switch flowID { - case 0: - w.issueXChainBaseTx(ctx) - case 1: - w.issueXChainCreateAssetTx(ctx) - case 2: - w.issueXChainOperationTx(ctx) - case 3: - w.issueXToPTransfer(ctx) - case 4: - w.issuePToXTransfer(ctx) + // Delay execution of the next test by a random duration + rawExecutionDelay, err := rand.Int(rand.Reader, defaultExecutionDelay) + // Avoid using require.NoError since the execution delay is not critical and an + // assertion failure in this function is fatal. + if err != nil { + w.log.Error("failed to read randomness", + zap.Error(err), + ) + assert.Unreachable("failed to read randomness", map[string]any{ + "worker": w.id, + "err": err, + }) + rawExecutionDelay = defaultExecutionDelay } + executionDelay := time.Duration(rawExecutionDelay.Int64()) + w.log.Info("waiting", + zap.Duration("duration", executionDelay), + ) + timer.Reset(executionDelay) - val, err = rand.Int(rand.Reader, big.NewInt(int64(time.Second))) - require.NoError(err, "failed to read randomness") - - timer.Reset(time.Duration(val.Int64())) select { case <-ctx.Done(): return @@ -206,6 +213,44 @@ func (w *workload) run(ctx context.Context) { } } +// executeTest executes a test at random. +func (w *workload) executeTest(ctx context.Context) { + tc := w.newTestContext(ctx) + // Panics will be recovered without being rethrown, ensuring that test failures are not fatal. + defer tc.Recover(false /* rethrow */) + require := require.New(tc) + + val, err := rand.Int(rand.Reader, big.NewInt(6)) + require.NoError(err, "failed to read randomness") + + // TODO(marun) + flowID := val.Int64() + switch flowID { + case 0: + // TODO(marun) Create abstraction for a test that supports a name e.g. `aTest{name: "foo", mytestfunc}` + w.log.Info("executing issueXChainBaseTx") + w.issueXChainBaseTx(ctx) + case 1: + w.log.Info("executing issueXChainCreateAssetTx") + w.issueXChainCreateAssetTx(ctx) + case 2: + w.log.Info("executing issueXChainOperationTx") + w.issueXChainOperationTx(ctx) + case 3: + w.log.Info("executing issueXToPTransfer") + w.issueXToPTransfer(ctx) + case 4: + w.log.Info("executing issuePToXTransfer") + w.issuePToXTransfer(ctx) + case 5: + w.log.Info("executing banff.TestCustomAssetTransfer") + addr, _ := w.addrs.Peek() + banff.TestCustomAssetTransfer(tc, w.wallet, addr) + case 6: + w.log.Info("sleeping") + } +} + func (w *workload) issueXChainBaseTx(ctx context.Context) { var ( xWallet = w.wallet.X() diff --git a/tests/antithesis/context.go b/tests/antithesis/context.go new file mode 100644 index 000000000000..2a9f1b0056f9 --- /dev/null +++ b/tests/antithesis/context.go @@ -0,0 +1,40 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package antithesis + +import ( + "context" + "fmt" + "maps" + + "github.com/antithesishq/antithesis-sdk-go/assert" + + "github.com/ava-labs/avalanchego/tests" + "github.com/ava-labs/avalanchego/utils/logging" +) + +// NewInstrumentedTestContext returns a test context that makes antithesis SDK assertions. +func NewInstrumentedTestContext(log logging.Logger) *tests.SimpleTestContext { + return NewInstrumentedTestContextWithArgs(context.Background(), log, nil) +} + +// NewInstrumentedTestContextWithArgs returns a test context that makes antithesis SDK assertions. +func NewInstrumentedTestContextWithArgs( + ctx context.Context, + log logging.Logger, + details map[string]any, +) *tests.SimpleTestContext { + return tests.NewTestContextWithArgs( + ctx, + log, + func(format string, args ...any) { + assert.Unreachable(fmt.Sprintf("Assertion failure: "+format, args...), details) + }, + func(r any) { + detailsClone := maps.Clone(details) + details["panic"] = r + assert.Unreachable("unexpected panic", detailsClone) + }, + ) +} diff --git a/tests/antithesis/xsvm/main.go b/tests/antithesis/xsvm/main.go index e9f735f8f5e4..a2a5e1cf789e 100644 --- a/tests/antithesis/xsvm/main.go +++ b/tests/antithesis/xsvm/main.go @@ -39,8 +39,8 @@ const ( func main() { // TODO(marun) Support choosing the log format - tc := tests.NewTestContext(tests.NewDefaultLogger("")) - defer tc.Cleanup() + tc := antithesis.NewInstrumentedTestContext(tests.NewDefaultLogger("")) + defer tc.RecoverAndExit() require := require.New(tc) c := antithesis.NewConfigWithSubnets( @@ -55,6 +55,8 @@ func main() { }, ) ctx := tests.DefaultNotifyContext(c.Duration, tc.DeferCleanup) + // Ensure contexts sourced from the test context use the notify context as their parent + tc.SetDefaultContextParent(ctx) require.Len(c.ChainIDs, 1) tc.Log().Debug("raw chain ID", @@ -140,8 +142,16 @@ type workload struct { func (w *workload) run(ctx context.Context) { timer := timerpkg.StoppedTimer() - tc := tests.NewTestContext(w.log) - defer tc.Cleanup() + tc := antithesis.NewInstrumentedTestContextWithArgs( + ctx, + w.log, + map[string]any{ + "worker": w.id, + }, + ) + // Any assertion failure from this test context will result in process exit due to the + // panic being rethrown. This ensures that failures in test setup are fatal. + defer tc.Recover(true /* rethrow */) require := require.New(tc) uri := w.uris[w.id%len(w.uris)] diff --git a/tests/context_helpers.go b/tests/context_helpers.go index cea6d46b893c..ee5086aeaa98 100644 --- a/tests/context_helpers.go +++ b/tests/context_helpers.go @@ -16,7 +16,11 @@ const DefaultTimeout = 2 * time.Minute // Helper simplifying use of a timed context by canceling the context with the test context. func ContextWithTimeout(tc TestContext, duration time.Duration) context.Context { - ctx, cancel := context.WithTimeout(context.Background(), duration) + parent := tc.GetDefaultContextParent() + if parent == nil { + parent = context.Background() + } + ctx, cancel := context.WithTimeout(parent, duration) tc.DeferCleanup(cancel) return ctx } diff --git a/tests/e2e/banff/suites.go b/tests/e2e/banff/suites.go index 4cc3700cfc4f..18e93a29a516 100644 --- a/tests/e2e/banff/suites.go +++ b/tests/e2e/banff/suites.go @@ -9,115 +9,117 @@ import ( "github.com/stretchr/testify/require" "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/tests" "github.com/ava-labs/avalanchego/tests/fixture/e2e" "github.com/ava-labs/avalanchego/utils/constants" "github.com/ava-labs/avalanchego/utils/units" "github.com/ava-labs/avalanchego/vms/components/avax" "github.com/ava-labs/avalanchego/vms/components/verify" "github.com/ava-labs/avalanchego/vms/secp256k1fx" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" ) var _ = ginkgo.Describe("[Banff]", func() { - tc := e2e.NewTestContext() + ginkgo.It("can send custom assets X->P and P->X", func() { + e2e.ExecuteAPITest(TestCustomAssetTransfer) + }) +}) + +func TestCustomAssetTransfer( + tc tests.TestContext, + wallet primary.Wallet, + ownerAddress ids.ShortID, +) { require := require.New(tc) - ginkgo.It("can send custom assets X->P and P->X", - func() { - env := e2e.GetEnv(tc) - keychain := env.NewKeychain() - wallet := e2e.NewWallet(tc, keychain, env.GetRandomNodeURI()) + // Get the P-chain and the X-chain wallets + pWallet := wallet.P() + xWallet := wallet.X() + xBuilder := xWallet.Builder() + xContext := xBuilder.Context() - // Get the P-chain and the X-chain wallets - pWallet := wallet.P() - xWallet := wallet.X() - xBuilder := xWallet.Builder() - xContext := xBuilder.Context() + // Pull out useful constants to use when issuing transactions. + xChainID := xContext.BlockchainID + owner := &secp256k1fx.OutputOwners{ + Threshold: 1, + Addrs: []ids.ShortID{ + ownerAddress, + }, + } - // Pull out useful constants to use when issuing transactions. - xChainID := xContext.BlockchainID - owner := &secp256k1fx.OutputOwners{ - Threshold: 1, - Addrs: []ids.ShortID{ - keychain.Keys[0].Address(), + var assetID ids.ID + tc.By("creating new X-chain asset", func() { + tx, err := xWallet.IssueCreateAssetTx( + "RnM", + "RNM", + 9, + map[uint32][]verify.State{ + 0: { + &secp256k1fx.TransferOutput{ + Amt: 100 * units.Schmeckle, + OutputOwners: *owner, + }, }, - } + }, + tc.WithDefaultContext(), + ) + require.NoError(err) + assetID = tx.ID() + }) - var assetID ids.ID - tc.By("create new X-chain asset", func() { - tx, err := xWallet.IssueCreateAssetTx( - "RnM", - "RNM", - 9, - map[uint32][]verify.State{ - 0: { - &secp256k1fx.TransferOutput{ - Amt: 100 * units.Schmeckle, - OutputOwners: *owner, - }, - }, + tc.By("exporting new X-chain asset to P-chain", func() { + _, err := xWallet.IssueExportTx( + constants.PlatformChainID, + []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: assetID, }, - tc.WithDefaultContext(), - ) - require.NoError(err) - assetID = tx.ID() - }) - - tc.By("export new X-chain asset to P-chain", func() { - _, err := xWallet.IssueExportTx( - constants.PlatformChainID, - []*avax.TransferableOutput{ - { - Asset: avax.Asset{ - ID: assetID, - }, - Out: &secp256k1fx.TransferOutput{ - Amt: 100 * units.Schmeckle, - OutputOwners: *owner, - }, - }, + Out: &secp256k1fx.TransferOutput{ + Amt: 100 * units.Schmeckle, + OutputOwners: *owner, }, - tc.WithDefaultContext(), - ) - require.NoError(err) - }) + }, + }, + tc.WithDefaultContext(), + ) + require.NoError(err) + }) - tc.By("import new asset from X-chain on the P-chain", func() { - _, err := pWallet.IssueImportTx( - xChainID, - owner, - tc.WithDefaultContext(), - ) - require.NoError(err) - }) + tc.By("importing new asset from X-chain on the P-chain", func() { + _, err := pWallet.IssueImportTx( + xChainID, + owner, + tc.WithDefaultContext(), + ) + require.NoError(err) + }) - tc.By("export asset from P-chain to the X-chain", func() { - _, err := pWallet.IssueExportTx( - xChainID, - []*avax.TransferableOutput{ - { - Asset: avax.Asset{ - ID: assetID, - }, - Out: &secp256k1fx.TransferOutput{ - Amt: 100 * units.Schmeckle, - OutputOwners: *owner, - }, - }, + tc.By("exporting asset from P-chain to the X-chain", func() { + _, err := pWallet.IssueExportTx( + xChainID, + []*avax.TransferableOutput{ + { + Asset: avax.Asset{ + ID: assetID, }, - tc.WithDefaultContext(), - ) - require.NoError(err) - }) - - tc.By("import asset from P-chain on the X-chain", func() { - _, err := xWallet.IssueImportTx( - constants.PlatformChainID, - owner, - tc.WithDefaultContext(), - ) - require.NoError(err) - }) + Out: &secp256k1fx.TransferOutput{ + Amt: 100 * units.Schmeckle, + OutputOwners: *owner, + }, + }, + }, + tc.WithDefaultContext(), + ) + require.NoError(err) + }) - _ = e2e.CheckBootstrapIsPossible(tc, env.GetNetwork()) - }) -}) + tc.By("importing asset from P-chain on the X-chain", func() { + _, err := xWallet.IssueImportTx( + constants.PlatformChainID, + owner, + tc.WithDefaultContext(), + ) + require.NoError(err) + }) +} diff --git a/tests/fixture/e2e/apitest.go b/tests/fixture/e2e/apitest.go new file mode 100644 index 000000000000..6004d80b4653 --- /dev/null +++ b/tests/fixture/e2e/apitest.go @@ -0,0 +1,24 @@ +// Copyright (C) 2019-2024, Ava Labs, Inc. All rights reserved. +// See the file LICENSE for licensing terms. + +package e2e + +import ( + "github.com/ava-labs/avalanchego/ids" + "github.com/ava-labs/avalanchego/tests" + "github.com/ava-labs/avalanchego/wallet/subnet/primary" +) + +// TODO(marun) What else does a test need? e.g. node URIs? +type APITestFunction func(tc tests.TestContext, wallet primary.Wallet, ownerAddress ids.ShortID) + +// ExecuteAPITest executes a test primary dependency is being able to access the API of one or +// more avalanchego nodes. +func ExecuteAPITest(apiTest APITestFunction) { + tc := NewTestContext() + env := GetEnv(tc) + keychain := env.NewKeychain() + wallet := NewWallet(tc, keychain, env.GetRandomNodeURI()) + apiTest(tc, wallet, keychain.Keys[0].Address()) + _ = CheckBootstrapIsPossible(tc, env.GetNetwork()) +} diff --git a/tests/fixture/e2e/ginkgo_test_context.go b/tests/fixture/e2e/ginkgo_test_context.go index 2f70fa9afcd1..ad8849ea39dd 100644 --- a/tests/fixture/e2e/ginkgo_test_context.go +++ b/tests/fixture/e2e/ginkgo_test_context.go @@ -15,6 +15,8 @@ import ( "github.com/ava-labs/avalanchego/wallet/subnet/primary/common" ) +var _ tests.TestContext = (*GinkgoTestContext)(nil) + type ginkgoWriteCloser struct{} func (*ginkgoWriteCloser) Write(p []byte) (n int, err error) { @@ -76,6 +78,10 @@ func (tc *GinkgoTestContext) WithDefaultContext() common.Option { return tests.WithDefaultContext(tc) } +func (*GinkgoTestContext) GetDefaultContextParent() context.Context { + return nil +} + // Re-implementation of testify/require.Eventually that is compatible with ginkgo. testify's // version calls the condition function with a goroutine and ginkgo assertions don't work // properly in goroutines. diff --git a/tests/simple_test_context.go b/tests/simple_test_context.go index 3cadba134b98..a672d5709a58 100644 --- a/tests/simple_test_context.go +++ b/tests/simple_test_context.go @@ -10,41 +10,64 @@ import ( "time" "github.com/stretchr/testify/require" + "go.uber.org/zap" "github.com/ava-labs/avalanchego/utils/logging" "github.com/ava-labs/avalanchego/wallet/subnet/primary/common" ) +var _ TestContext = (*SimpleTestContext)(nil) + const failNowMessage = "SimpleTestContext.FailNow called" +type ErrorfHandler func(format string, args ...any) + +type PanicHandler func(any) + type SimpleTestContext struct { - log logging.Logger + defaultContextParent context.Context + log logging.Logger + cleanupFuncs []func() cleanupCalled bool + + errorfHandler ErrorfHandler + panicHandler PanicHandler } func NewTestContext(log logging.Logger) *SimpleTestContext { + return NewTestContextWithArgs(context.Background(), log, nil, nil) +} + +func NewTestContextWithArgs( + ctx context.Context, + log logging.Logger, + errorfHandler ErrorfHandler, + panicHandler PanicHandler, +) *SimpleTestContext { return &SimpleTestContext{ - log: log, + defaultContextParent: ctx, + log: log, + errorfHandler: errorfHandler, + panicHandler: panicHandler, } } -func (tc *SimpleTestContext) Errorf(format string, args ...interface{}) { +func (tc *SimpleTestContext) Errorf(format string, args ...any) { tc.log.Error(fmt.Sprintf(format, args...)) + if tc.errorfHandler != nil { + tc.errorfHandler(format, args...) + } } func (*SimpleTestContext) FailNow() { panic(failNowMessage) } -// Cleanup is intended to be deferred by the caller to ensure cleanup is performed even -// in the event that a panic occurs. -func (tc *SimpleTestContext) Cleanup() { - if tc.cleanupCalled { - return - } - tc.cleanupCalled = true - +// RecoverAndExit is intended to be deferred by the caller to ensure +// cleanup functions are called before exit or re-panic (in the event +// of an unexpected panic or a panic during cleanup). +func (tc *SimpleTestContext) RecoverAndExit() { // Only exit non-zero if a cleanup caused a panic exitNonZero := false @@ -52,41 +75,100 @@ func (tc *SimpleTestContext) Cleanup() { if r := recover(); r != nil { errorString, ok := r.(string) if !ok || errorString != failNowMessage { + tc.log.Error("unexpected panic", + zap.Any("panic", r), + ) + if tc.panicHandler != nil { + tc.panicHandler(r) + } // Retain the panic data to raise after cleanup panicData = r } else { + // Ensure a non-zero exit due to an assertion failure exitNonZero = true } } + if panicDuringCleanup := tc.cleanup(); panicDuringCleanup { + exitNonZero = true + } + + if panicData != nil { + // Re-throw an unexpected (non-assertion) panic + panic(panicData) + } + if exitNonZero { + os.Exit(1) + } +} + +// Recover is intended to be deferred in a function executing a test +// whose assertions may result in panics. Such a panic is intended to +// be recovered to allow cleanup functions to be called. A panic can +// be optionally rethrown by setting `rethrow` to true. +func (tc *SimpleTestContext) Recover(rethrow bool) { + // Recover from test failure + var panicData any + if panicData := recover(); panicData != nil { + errorString, ok := panicData.(string) + if !ok || errorString != failNowMessage { + tc.log.Error("unexpected panic", + zap.Any("panic", panicData), + ) + if tc.panicHandler != nil { + tc.panicHandler(panicData) + } + } + } + // Ensure cleanup functions are called + _ = tc.cleanup() + + if rethrow && panicData != nil { + panic(panicData) + } +} + +// cleanup ensures that the registered cleanup functions have been +// called. Cleanup functions will be called at most once. Returns a +// boolean indication of whether a panic results from executing one or +// more cleanup functions e.g. allow a non-zero exit. +func (tc *SimpleTestContext) cleanup() bool { + if tc.cleanupCalled { + return false + } + tc.cleanupCalled = true + + panicDuringCleanup := false for _, cleanupFunc := range tc.cleanupFuncs { func() { // Ensure a failed cleanup doesn't prevent subsequent cleanup functions from running defer func() { if r := recover(); r != nil { - exitNonZero = true - fmt.Println("Recovered from panic during cleanup:", r) + panicDuringCleanup = true + tc.log.Error("recovered from panic during cleanup", + zap.Any("panic", r), + ) } }() cleanupFunc() }() } - - if panicData != nil { - panic(panicData) - } - if exitNonZero { - os.Exit(1) - } + return panicDuringCleanup } func (tc *SimpleTestContext) DeferCleanup(cleanup func()) { tc.cleanupFuncs = append(tc.cleanupFuncs, cleanup) } -func (tc *SimpleTestContext) By(_ string, _ ...func()) { - tc.Errorf("By not yet implemented") - tc.FailNow() +func (tc *SimpleTestContext) By(msg string, callback ...func()) { + tc.log.Info("Step: " + msg) + + if len(callback) == 1 { + callback[0]() + } else if len(callback) > 1 { + tc.Errorf("just one callback per By, please") + tc.FailNow() + } } func (tc *SimpleTestContext) Log() logging.Logger { @@ -108,6 +190,14 @@ func (tc *SimpleTestContext) WithDefaultContext() common.Option { return WithDefaultContext(tc) } +func (tc *SimpleTestContext) GetDefaultContextParent() context.Context { + return tc.defaultContextParent +} + +func (tc *SimpleTestContext) SetDefaultContextParent(parent context.Context) { + tc.defaultContextParent = parent +} + func (tc *SimpleTestContext) Eventually(condition func() bool, waitFor time.Duration, tick time.Duration, msg string) { require.Eventually(tc, condition, waitFor, tick, msg) } diff --git a/tests/test_context.go b/tests/test_context.go index 2eff4adbbfcf..7f01762fa7c5 100644 --- a/tests/test_context.go +++ b/tests/test_context.go @@ -31,6 +31,9 @@ type TestContext interface { DefaultContext() context.Context WithDefaultContext() common.Option + // The parent context (if non-nil) to use as the parent of default contexts + GetDefaultContextParent() context.Context + // Ensures compatibility with require.Eventually Eventually(condition func() bool, waitFor time.Duration, tick time.Duration, msg string) }