diff --git a/.changeset/breezy-carrots-bow.md b/.changeset/breezy-carrots-bow.md new file mode 100644 index 00000000000..640484f6f83 --- /dev/null +++ b/.changeset/breezy-carrots-bow.md @@ -0,0 +1,8 @@ +--- +"@fuel-ts/utils": patch +"@fuel-ts/account": minor +"@fuel-ts/contract": patch +"fuels": patch +--- + +feat!: add `launchTestNode` utility diff --git a/apps/docs-snippets/src/guide/testing/launching-a-test-node.test.ts b/apps/docs-snippets/src/guide/testing/launching-a-test-node.test.ts new file mode 100644 index 00000000000..e7614b0a44c --- /dev/null +++ b/apps/docs-snippets/src/guide/testing/launching-a-test-node.test.ts @@ -0,0 +1,251 @@ +/* eslint-disable @typescript-eslint/no-unused-vars */ +import { WalletUnlocked } from 'fuels'; +import { AssetId, TestMessage, launchTestNode } from 'fuels/test-utils'; +import { join } from 'path'; + +import { CounterAbi__factory as TestContract__factory } from '../../../test/typegen/contracts'; +import bytecode from '../../../test/typegen/contracts/CounterAbi.hex'; + +/** + * @group node + */ +describe('launching a test node', () => { + test(`instantiating test nodes - automatic cleanup`, async () => { + // #region automatic-cleanup + // #import { launchTestNode }; + + using launched = await launchTestNode(); + + /* + The method `launched.cleanup()` will be automatically + called when the variable `launched` goes out of block scope. + */ + + // #endregion automatic-cleanup + }); + + test('instantiating test nodes - manual cleanup', async () => { + // #region manual-cleanup + // #import { launchTestNode }; + + const launched = await launchTestNode(); + + /* + Do your things, run your tests, and then call + `launched.cleanup()` to dispose of everything. + */ + + launched.cleanup(); + // #endregion manual-cleanup + }); + + test('options', async () => { + // #region options + // #import { launchTestNode }; + + using launched = await launchTestNode(/* options */); + // #endregion options + }); + + test('simple contract deployment', async () => { + // #region basic-example + // #import { launchTestNode }; + + // #context import { TestContract__factory } from 'path/to/typegen/output'; + // #context import bytecode from 'path/to/typegen/output/TestContract.hex.ts'; + + using launched = await launchTestNode({ + contractsConfigs: [ + { + deployer: TestContract__factory, + bytecode, + }, + ], + }); + + const { + contracts: [contract], + provider, + wallets, + } = launched; + + const response = await contract.functions.get_count().call(); + // #endregion basic-example + expect(response.value.toNumber()).toBe(0); + expect(provider).toBeDefined(); + expect(wallets).toBeDefined(); + }); + + test('multiple contracts and wallets', async () => { + // #region advanced-example + // #import { launchTestNode, AssetId, TestMessage }; + + // #context import { TestContract__factory } from 'path/to/typegen/output'; + // #context import bytecode from 'path/to/typegen/output/TestContract.hex.ts'; + + const assets = AssetId.random(2); + const message = new TestMessage({ amount: 1000 }); + + using launched = await launchTestNode({ + walletsConfig: { + count: 4, + assets, + coinsPerAsset: 2, + amountPerCoin: 1_000_000, + messages: [message], + }, + contractsConfigs: [ + { + deployer: TestContract__factory, + bytecode, + walletIndex: 3, + options: { storageSlots: [] }, + }, + ], + }); + + const { + contracts: [contract], + wallets: [wallet1, wallet2, wallet3, wallet4], + } = launched; + // #endregion advanced-example + + expect(contract).toBeDefined(); + expect(wallet1).toBeDefined(); + expect(wallet2).toBeDefined(); + expect(wallet3).toBeDefined(); + expect(wallet4).toBeDefined(); + }); + + test('configuring custom fuel-core args', async () => { + // #region custom-fuel-core-args + // #import { launchTestNode }; + + process.env.DEFAULT_FUEL_CORE_ARGS = `--tx-max-depth 20`; + + // `nodeOptions.args` will override the above values if provided. + + using launched = await launchTestNode(); + // #endregion custom-fuel-core-args + + const { provider } = launched; + + expect(provider.getNode().maxDepth.toNumber()).toEqual(20); + process.env.DEFAULT_FUEL_CORE_ARGS = ''; + }); + + test('configuring a base chain config', async () => { + const snapshotDirPath = join(__dirname, '../../../../../', '.fuel-core', 'configs'); + + // #region custom-chain-config + // #import { launchTestNode }; + + process.env.DEFAULT_CHAIN_SNAPSHOT_DIR = snapshotDirPath; + + using launched = await launchTestNode(); + // #endregion custom-chain-config + + const { provider } = launched; + + const { name } = await provider.fetchChain(); + + expect(name).toEqual('local_testnet'); + }); + + test('customizing node options', async () => { + // #region custom-node-options + // #import { launchTestNode, AssetId }; + + const [baseAssetId] = AssetId.random(); + + using launched = await launchTestNode({ + nodeOptions: { + snapshotConfig: { + chainConfig: { + consensus_parameters: { + V1: { + base_asset_id: baseAssetId.value, + }, + }, + }, + }, + }, + }); + // #endregion custom-node-options + }); + + test('using assetId', async () => { + // #region asset-ids + // #import { launchTestNode, AssetId }; + + const assets = AssetId.random(); + + using launched = await launchTestNode({ + walletsConfig: { + assets, + }, + }); + + const { + wallets: [wallet], + } = launched; + + const coins = await wallet.getCoins(assets[0].value); + // #endregion asset-ids + expect(coins[0].assetId).toEqual(assets[0].value); + }); + + test('generating test messages', async () => { + // #region test-messages + // #import { launchTestNode, TestMessage }; + + const testMessage = new TestMessage({ amount: 1000 }); + + using launched = await launchTestNode({ + walletsConfig: { + messages: [testMessage], + }, + }); + + const { + wallets: [wallet], + } = launched; + + const [message] = await wallet.getMessages(); + // message.nonce === testMessage.nonce + // #endregion test-messages + + expect(message.nonce).toEqual(testMessage.nonce); + }); + + test('generating test messages directly on chain', async () => { + // #region test-messages-chain + // #import { launchTestNode, TestMessage, WalletUnlocked }; + + const recipient = WalletUnlocked.generate(); + const testMessage = new TestMessage({ + amount: 1000, + recipient: recipient.address, + }); + + using launched = await launchTestNode({ + nodeOptions: { + snapshotConfig: { + stateConfig: { + messages: [testMessage.toChainMessage()], + }, + }, + }, + }); + + const { provider } = launched; + + recipient.provider = provider; + + const [message] = await recipient.getMessages(); + // message.nonce === testMessage.nonce + // #endregion test-messages-chain + + expect(message.nonce).toEqual(testMessage.nonce); + }); +}); diff --git a/apps/docs-snippets/src/guide/testing/tweaking-the-blockchain.test.ts b/apps/docs-snippets/src/guide/testing/tweaking-the-blockchain.test.ts index 89c07677412..7b0fbe83271 100644 --- a/apps/docs-snippets/src/guide/testing/tweaking-the-blockchain.test.ts +++ b/apps/docs-snippets/src/guide/testing/tweaking-the-blockchain.test.ts @@ -1,24 +1,45 @@ -import { launchNode } from '@fuel-ts/account/test-utils'; -import { Provider, DateTime } from 'fuels'; +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import { DateTime } from 'fuels'; +import { launchTestNode } from 'fuels/test-utils'; /** * @group node */ -test('produceBlocks with custom timestamp docs snippet', async () => { - // TODO: reevaluate/replace after #1356 - const { cleanup, ip, port } = await launchNode({}); - const url = `http://${ip}:${port}/v1/graphql`; - const provider = await Provider.create(url); - const latestBlock = await provider.getBlock('latest'); - if (!latestBlock) { - throw new Error('No latest block'); - } - const lastBlockNumber = latestBlock.height; - // #region Provider-produceBlocks-custom-timestamp - const lastBlockTimestamp = DateTime.fromTai64(latestBlock.time).toUnixMilliseconds(); - const latestBlockNumber = await provider.produceBlocks(3, lastBlockTimestamp + 1000); - // #endregion Provider-produceBlocks-custom-timestamp - expect(latestBlockNumber.toHex()).toBe(lastBlockNumber.add(3).toHex()); +describe('tweaking the blockchain', () => { + test('produceBlocks', async () => { + // #region produce-blocks + using launched = await launchTestNode(); + const { provider } = launched; + const block = await provider.getBlock('latest'); + if (!block) { + throw new Error('No latest block'); + } + const { time: timeLastBlockProduced } = block; - cleanup(); + const producedBlockHeight = await provider.produceBlocks(3); + + const producedBlock = await provider.getBlock(producedBlockHeight.toNumber()); + + const oldest = DateTime.fromTai64(timeLastBlockProduced); + const newest = DateTime.fromTai64(producedBlock!.time); + // newest >= oldest + // #endregion produce-blocks + expect(producedBlock).toBeDefined(); + expect(newest >= oldest).toBeTruthy(); + }); + + test('produceBlocks with custom timestamp docs snippet', async () => { + // #region produceBlocks-custom-timestamp + using launched = await launchTestNode(); + const { provider } = launched; + + const latestBlock = await provider.getBlock('latest'); + if (!latestBlock) { + throw new Error('No latest block'); + } + const latestBlockTimestamp = DateTime.fromTai64(latestBlock.time).toUnixMilliseconds(); + const newBlockHeight = await provider.produceBlocks(3, latestBlockTimestamp + 1000); + // #endregion produceBlocks-custom-timestamp + expect(newBlockHeight.toHex()).toBe(latestBlock.height.add(3).toHex()); + }); }); diff --git a/apps/docs/.vitepress/config.ts b/apps/docs/.vitepress/config.ts index 8ccb9a333f0..b45913eb731 100644 --- a/apps/docs/.vitepress/config.ts +++ b/apps/docs/.vitepress/config.ts @@ -172,10 +172,6 @@ export default defineConfig({ text: 'Locking and Unlocking', link: '/guide/wallets/locking-and-unlocking', }, - { - text: 'Test Wallets', - link: '/guide/wallets/test-wallets', - }, ], }, { @@ -378,16 +374,28 @@ export default defineConfig({ collapsed: true, items: [ { - text: 'Testing in TS', - link: '/guide/testing/testing-in-ts', + text: 'Launching a Test Node', + link: '/guide/testing/launching-a-test-node', + }, + { + text: 'Test Node Options', + link: '/guide/testing/test-node-options', + }, + { + text: 'Fuel Core Options', + link: '/guide/testing/fuel-core-options', + }, + { + text: 'Basic Example', + link: '/guide/testing/basic-example', }, { - text: 'Setting Up a Custom Chain', - link: '/guide/testing/setting-up-a-custom-chain', + text: 'Advanced Example', + link: '/guide/testing/advanced-example', }, { - text: 'Tweaking the Blockchain', - link: '/guide/testing/tweaking-the-blockchain', + text: 'Custom Blocks', + link: '/guide/testing/custom-blocks', }, ], }, diff --git a/apps/docs/spell-check-custom-words.txt b/apps/docs/spell-check-custom-words.txt index 1870177fe30..2b34039178c 100644 --- a/apps/docs/spell-check-custom-words.txt +++ b/apps/docs/spell-check-custom-words.txt @@ -302,6 +302,7 @@ MULTICALL TTL Bech CLIs +typedoc backoff extractable SHA-256 @@ -314,4 +315,10 @@ BigNumber Gwei onchain Vercel -hardcoded \ No newline at end of file +hardcoded +tsconfig +deployer +overriden +typesafe +launchTestNode +Vitest \ No newline at end of file diff --git a/apps/docs/src/guide/getting-started/connecting-to-a-local-node.md b/apps/docs/src/guide/getting-started/connecting-to-a-local-node.md index 705592612b2..b9c907291d1 100644 --- a/apps/docs/src/guide/getting-started/connecting-to-a-local-node.md +++ b/apps/docs/src/guide/getting-started/connecting-to-a-local-node.md @@ -2,8 +2,8 @@ Firstly, you will need a local node running on your machine. We recommend one of the following methods: -- [Testing utilities](../testing/index.md#wallet-test-utilities) can assist in programmatically launching a short-lived node. -- Running [fuel-core](https://docs.fuel.network/guides/running-a-node/running-a-local-node/) locally. +- [Testing utilities](../testing/launching-a-test-node.md) can assist in programmatically launching a short-lived node. +- Running [fuel-core](https://docs.fuel.network/guides/running-a-node/running-a-local-node/) directly. In the following example, we create a provider to connect to the local node and sign a message. diff --git a/apps/docs/src/guide/testing/advanced-example.md b/apps/docs/src/guide/testing/advanced-example.md new file mode 100644 index 00000000000..7989c31b613 --- /dev/null +++ b/apps/docs/src/guide/testing/advanced-example.md @@ -0,0 +1,18 @@ + + +# Advanced Example + +A more complex example showcasing genesis block state configuration with [`walletsConfig`](./test-node-options.md#walletsconfig) and deployment of multiple contracts is shown below. + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#advanced-example{ts:line-numbers} + +## Summary + +1. All points listed in the [basic example](./basic-example.md#summary) apply here as well. +1. Multiple wallets were generated with highly-specific coins and messages. +1. It's possible to specify the wallet to be used for contract deployment via `walletIndex`. +1. The test contract can be deployed with all the options available for real contract deployment. diff --git a/apps/docs/src/guide/testing/basic-example.md b/apps/docs/src/guide/testing/basic-example.md new file mode 100644 index 00000000000..df30c0b8fcf --- /dev/null +++ b/apps/docs/src/guide/testing/basic-example.md @@ -0,0 +1,20 @@ + + +# Basic Example + +Let's use `launchTestNode` with the counter contract from the [Fuel dApp tutorial](../creating-a-fuel-dapp/index.md). + +_Note: you will have to change the import paths of the contract factory and bytecode to match your folder structure._ + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#basic-example{ts:line-numbers} + +## Summary + +1. The `launched` variable was instantiated with the [`using`](https://www.typescriptlang.org/docs/handbook/variable-declarations.html#using-declarations) keyword. +1. `launchTestNode` spun up a short-lived `fuel-core` node, deployed a contract to it and returned it for testing. +1. The deployed contract is fully typesafe because of `launchTestNode`'s type-level integration with `typegen` outputs. +1. Besides the contract, you've got the [provider](../provider/index.md) and [wallets](../wallets/index.md) at your disposal. diff --git a/apps/docs/src/guide/testing/tweaking-the-blockchain.md b/apps/docs/src/guide/testing/custom-blocks.md similarity index 62% rename from apps/docs/src/guide/testing/tweaking-the-blockchain.md rename to apps/docs/src/guide/testing/custom-blocks.md index 904fd128396..a00447c9ad0 100644 --- a/apps/docs/src/guide/testing/tweaking-the-blockchain.md +++ b/apps/docs/src/guide/testing/custom-blocks.md @@ -1,11 +1,11 @@ -# Producing Blocks +# Custom Blocks You can force-produce blocks using the `produceBlocks` helper to achieve an arbitrary block height. This is especially useful when you want to do some testing regarding transaction maturity. -<<< @/../../../packages/account/src/providers/provider.test.ts#Provider-produce-blocks{ts:line-numbers} +<<< @/../../docs-snippets/src/guide/testing/tweaking-the-blockchain.test.ts#produce-blocks{ts:line-numbers} -# Producing Blocks With Custom Timestamps +# Blocks With Custom Timestamps You can also produce blocks with a custom block time using the `produceBlocks` helper by specifying the second optional parameter. -<<< @/../../docs-snippets/src/guide/testing/tweaking-the-blockchain.test.ts#Provider-produceBlocks-custom-timestamp{ts:line-numbers} +<<< @/../../docs-snippets/src/guide/testing/tweaking-the-blockchain.test.ts#produceBlocks-custom-timestamp{ts:line-numbers} diff --git a/apps/docs/src/guide/testing/fuel-core-options.md b/apps/docs/src/guide/testing/fuel-core-options.md new file mode 100644 index 00000000000..aa6bd11405b --- /dev/null +++ b/apps/docs/src/guide/testing/fuel-core-options.md @@ -0,0 +1,35 @@ + + +# Fuel-Core Options + +The `launchTestNode` creates a temporary snapshot directory and configurations every time it runs. The path to this directory is passed to `fuel-core` via the `--snapshot` flag. + +## Default Snapshot + +The default snapshot used is that of the current testnet network iteration. + +Click [here](https://github.com/FuelLabs/fuels-ts/blob/master/.fuel-core/configs) to see what it looks like. + +## Custom Snapshot + +If you need a different snapshot, you can specify a `DEFAULT_CHAIN_SNAPSHOT_DIR` environment variable which points to your snapshot directory. `launchTestNode` will read that config and work with it instead, integrating all the functionality with it the same way it'd do with the default config. + +How and where you specify the environment variable depends on your testing tool. + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#custom-chain-config{ts:line-numbers} + +## Fuel-Core Node Options + +Besides the snapshot, you can provide arguments to the `fuel-core` node via the `nodeOptions.args` property. For a detailed list of all possible arguments run: + +```shell +fuel-core run --help +``` + +If you want _all_ your tests to run with the same arguments, consider specifying the `DEFAULT_FUEL_CORE_ARGS` environment variable. + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#custom-fuel-core-args{ts:line-numbers} diff --git a/apps/docs/src/guide/testing/index.md b/apps/docs/src/guide/testing/index.md index 92575ff3286..3a28ccd7936 100644 --- a/apps/docs/src/guide/testing/index.md +++ b/apps/docs/src/guide/testing/index.md @@ -6,38 +6,13 @@ # Testing -In order to test your Sway and TS-SDK applications, you can test your code in a number of ways: +This guide will teach you how to test Sway applications using the Typescript SDK. -1. Testing with TS-SDK: Compiling you Sway code and connecting to the methods using TS-SDK and JS testing frameworks -2. Using `forc test` see the Sway docs for more info -3. Using [the Rust SDK](https://docs.fuel.network/docs/fuels-rs/testing/) +While we use [Vitest](https://vitest.dev/) internally, we don't enforce any specific testing library or framework, so you can pick whichever you feel comfortable with. -### Testing with TS-SDK - -To test your Sway applications using the TS-SDK, you can pick whatever testing library or framework you feel comfortable with. There isn't any specific testing framework needed, it is entirely up to the user. That being said, the TS-SDK uses [`Vitest`](https://vitest.dev/) for its tests. - -### Wallet Test Utilities - -You'll often want to create one or more test wallets when testing your contracts. - -For this, you can find two simple utilities on the account package: - -- [`@fuel-ts/account`](https://github.com/FuelLabs/fuels-ts/tree/master/packages/account#test-utilities) - -On top of these two utilities, if you want to quickly get up and running with a local node, you can use the `launchNodeAndGetWallets` from the `@fuel-ts/account/test-utils` package. - -```ts -import { launchNodeAndGetWallets } from "@fuel-ts/account/test-utils"; - -const { stop, wallets, provider } = await launchNodeAndGetWallets(); - -// ... do your tests - deploy contracts using the wallets, fetch info from the provider, etc. - -// stop the node when you're done -stop(); -``` +### Not using Typescript? See also: -1. [Setting up test wallets](../wallets/test-wallets.md) -2. [Testing in TS](./testing-in-ts.md) +1. Using [`forc test`](https://docs.fuel.network/docs/forc/commands/forc%5ftest/#forc-test) +1. Using [the Rust SDK](https://docs.fuel.network/docs/fuels-rs/testing/) diff --git a/apps/docs/src/guide/testing/launching-a-test-node.md b/apps/docs/src/guide/testing/launching-a-test-node.md new file mode 100644 index 00000000000..09228ae205c --- /dev/null +++ b/apps/docs/src/guide/testing/launching-a-test-node.md @@ -0,0 +1,36 @@ +# Launching a Test Node + +To simplify testing in isolation, we provide a utility called `launchTestNode`. + +It allows you to spin up a short-lived `fuel-core` node, set up a custom provider, wallets, deploy contracts, and much more in one go. + +## Explicit Resource Management + +We support [explicit resource management](https://www.typescriptlang.org/docs/handbook/variable-declarations.html#using-declarations), introduced in TypeScript 5.2, which automatically calls a `cleanup` function after a variable instantiated with the `using` keyword goes out of block scope: + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#automatic-cleanup{ts:line-numbers} + +### Configuring Typescript + +To use [explicit resource management](https://www.typescriptlang.org/docs/handbook/variable-declarations.html#using-declarations), you must: + +1. Set your TypeScript version to `5.2` or above +2. Set the compilation target to `es2022` or below +3. Configure your lib setting to either include `esnext` or `esnext.disposable` + +```json +{ + "compilerOptions": { + "target": "es2022", + "lib": ["es2022", "esnext.disposable"] + } +} +``` + +## Standard API + +If you don't want, or can't use [explicit resource management](https://www.typescriptlang.org/docs/handbook/variable-declarations.html#using-declarations), you can use `const` as usual. + +In this case, remember you must call `.cleanup()` to dispose of the node. + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#manual-cleanup{ts:line-numbers} diff --git a/apps/docs/src/guide/testing/setting-up-a-custom-chain.md b/apps/docs/src/guide/testing/setting-up-a-custom-chain.md deleted file mode 100644 index 20e8b960b64..00000000000 --- a/apps/docs/src/guide/testing/setting-up-a-custom-chain.md +++ /dev/null @@ -1,31 +0,0 @@ -# Setting up a custom chain - -The `launchNodeAndGetWallets` method lets you launch a local Fuel node with various customizations. - -In the code snippet below, we provide a snapshot directory containing a couple of files: - -- `chainConfig.json` -- `stateCondig.json` -- `metadata.json` - -You can use custom snapshots to customize things like the chain's consensus parameters or specify some initial states for the chain. - -Here are some examples: - -- [`chainConfig.json`](https://github.com/FuelLabs/fuels-ts/blob/master/.fuel-core/configs/chainConfig.json) - - -<<< @/../../../packages/account/src/test-utils/launchNodeAndGetWallets.test.ts#launchNode-custom-config{ts:line-numbers} - -## Customization options - -As you can see in the previous code snippet, you can optionally pass in a `walletCount` and some `launchNodeOptions` to the `launchNodeAndGetWallets` method. - -The `walletCount` option lets you specify how many wallets you want to generate. The default value is 10. - -The `launchNodeOptions` option lets you specify some additional options for the node. The available options are: - -<<< @/../../../packages/account/src/test-utils/launchNode.ts#launchNode-launchNodeOptions{ts:line-numbers} - -> Note: You can see all the available fuel-core args by running `fuel-core run -h`. diff --git a/apps/docs/src/guide/testing/test-node-options.md b/apps/docs/src/guide/testing/test-node-options.md new file mode 100644 index 00000000000..7c7b2eb6d7f --- /dev/null +++ b/apps/docs/src/guide/testing/test-node-options.md @@ -0,0 +1,62 @@ +# Test Node Options + +This reference describes all the options of the [`launchTestNode`](./launching-a-test-node.md) utility: + +- [`walletsConfig`](./test-node-options.md#walletsconfig) +- [`contractsConfigs`](./test-node-options.md#contractsconfigs) +- [`nodeOptions`](./test-node-options.md#nodeoptions) +- [`providerOptions`](./test-node-options.md#provideroptions) + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#options{ts:line-numbers} + +## `walletsConfig` + +Used to set the node's genesis block state (coins and messages). + +- `count`: number of wallets/addresses to generate on the genesis block. +- `assets`: configure how many unique assets each wallet will own with the base asset included. Can be `number` or `AssetId[]`. + - The `AssetId` utility simplifies testing when different assets are necessary. +- `coinsPerAsset`: number of coins (UTXOs) per asset id. +- `amountPerCoin`: for each coin, the amount it'll contain. +- `messages`: messages to assign to the wallets. + +### `walletsConfig.assets` + +The `AssetId` utility integrates with [`walletsConfig`](./test-node-options.md#walletsconfig) and gives you an easy way to generate multiple random asset ids via the `AssetId.random` static method. + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#asset-ids{ts:line-numbers} + +### `walletsConfig.messages` + +The `TestMessage` helper class is used to create messages for testing purposes. When passed via `walletsConfig.messages`, the `recipient` field of the message is overriden to be the wallet's address. + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#test-messages{ts:line-numbers} + +It can also be used standalone and passed into the initial state of the chain via the `TestMessage.toChainMessage` instance method. + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#test-messages-chain{ts:line-numbers} + +## `contractsConfigs` + +Used to deploy contracts on the node the `launchTestNode` utility launches. It's an array of objects with the following properties: + +- `deployer`: contract deployer object compatible with factories outputted by `pnpm fuels typegen`. You can also pass in your custom object that satisfies the interface. +- `bytecode`: the contract's bytecode. +- `walletIndex`: the index of the wallets generated by [`walletsConfig`](./test-node-options.md#walletsconfig) that you want to deploy the contract with. +- `options`: options for [contract deployment](../contracts/deploying-contracts.md#4-deploying-the-contract) that get passed to the [`ContractFactory.deployContract`](../../api/Contract/ContractFactory.md#deploycontract) method. + +## `nodeOptions` + + + +Options to modify the behavior of the node. + +For example, you can specify your own base asset id of the chain like below: + +<<< @/../../docs-snippets/src/guide/testing/launching-a-test-node.test.ts#custom-node-options{ts:line-numbers} + +_Note: The API for these options is still not fully complete and better documentation will come in the future._ + +## `providerOptions` + +Provider options passed on `Provider` instantiation. More on them [here](../provider/provider-options.md). diff --git a/apps/docs/src/guide/testing/testing-in-ts.md b/apps/docs/src/guide/testing/testing-in-ts.md deleted file mode 100644 index fa8eec5a01f..00000000000 --- a/apps/docs/src/guide/testing/testing-in-ts.md +++ /dev/null @@ -1,13 +0,0 @@ -# Testing in TS - -As noted in [the testing intro](./index.md), you are free to test your Sway and TS-SDK code with any JS framework available. Below we have an example of how to load and test a contract using `Vitest`, but the general principles and steps are the same for any testing harness. - -Here is a simple Sway program that takes an input and then returns it: - -<<< @/../../demo-typegen/contract/src/main.sw#Testing-in-ts-rust{rust:line-numbers} - -Here is JavaScript code testing the above program using a conventional `Vitest` setup: - -<<< @/../../demo-typegen/src/demo.test.ts#Testing-in-ts-ts{ts:line-numbers} - -> **Note:** The TS-SDK has recently migrated to `Vitest` however it follows a very similar API to Jest, and the above example applies to Jest also. \ No newline at end of file diff --git a/apps/docs/src/guide/wallets/test-wallets.md b/apps/docs/src/guide/wallets/test-wallets.md deleted file mode 100644 index aa340946e49..00000000000 --- a/apps/docs/src/guide/wallets/test-wallets.md +++ /dev/null @@ -1,13 +0,0 @@ -# Setting up test wallets - -You'll often want to create one or more test wallets when testing your contracts. Here's how to do it. - -## Create a single wallet - -<<< @/../../docs-snippets/src/guide/wallets/access.test.ts#wallets{ts:line-numbers} - -## Setting up multiple test wallets - -If you need multiple test wallets, they can be set up as follows: - -<<< @/../../docs-snippets/src/guide/wallets/test-wallets.test.ts#wallet-setup{ts:line-numbers} diff --git a/packages/account/package.json b/packages/account/package.json index 42b86d2f8ba..69aaec08ad7 100644 --- a/packages/account/package.json +++ b/packages/account/package.json @@ -71,6 +71,7 @@ "uuid": "^9.0.0" }, "devDependencies": { + "type-fest": "^4.6.0", "@fuel-ts/hasher": "workspace:*", "@fuel-ts/math": "workspace:*", "@fuel-ts/utils": "workspace:*", diff --git a/packages/account/src/providers/provider.test.ts b/packages/account/src/providers/provider.test.ts index 41e76d42952..5eeaefc189d 100644 --- a/packages/account/src/providers/provider.test.ts +++ b/packages/account/src/providers/provider.test.ts @@ -7,7 +7,7 @@ import type { BytesLike } from '@fuel-ts/interfaces'; import { BN, bn } from '@fuel-ts/math'; import type { Receipt } from '@fuel-ts/transactions'; import { InputType, ReceiptType, TransactionType } from '@fuel-ts/transactions'; -import { DateTime, arrayify, hexlify } from '@fuel-ts/utils'; +import { DateTime, arrayify, hexlify, sleep } from '@fuel-ts/utils'; import { versions } from '@fuel-ts/versions'; import * as fuelTsVersionsMod from '@fuel-ts/versions'; @@ -16,6 +16,7 @@ import { MESSAGE_PROOF_RAW_RESPONSE, MESSAGE_PROOF, } from '../../test/fixtures'; +import { setupTestProviderAndWallets, launchNode } from '../test-utils'; import type { ChainInfo, NodeInfo } from './provider'; import Provider from './provider'; @@ -26,7 +27,6 @@ import type { import { ScriptTransactionRequest, CreateTransactionRequest } from './transaction-request'; import { TransactionResponse } from './transaction-response'; import type { SubmittedStatus } from './transaction-summary/types'; -import { sleep } from './utils'; import * as gasMod from './utils/gas'; afterEach(() => { @@ -58,7 +58,8 @@ const FUEL_NETWORK_URL = 'http://127.0.0.1:4000/v1/graphql'; */ describe('Provider', () => { it('can getVersion()', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const version = await provider.getVersion(); @@ -66,7 +67,8 @@ describe('Provider', () => { }); it('can call()', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const baseAssetId = provider.getBaseAssetId(); const CoinInputs: CoinTransactionRequestInput[] = [ @@ -133,7 +135,8 @@ describe('Provider', () => { // as we test this in other modules like call contract its ok to // skip for now it.skip('can sendTransaction()', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const response = await provider.sendTransaction({ type: TransactionType.Script, @@ -179,7 +182,9 @@ describe('Provider', () => { }); it('can get all chain info', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; + const { consensusParameters } = provider.getChain(); expect(consensusParameters.version).toBeDefined(); @@ -228,34 +233,25 @@ describe('Provider', () => { }); it('can change the provider url of the current instance', async () => { - const providerUrl1 = FUEL_NETWORK_URL; - const providerUrl2 = 'http://127.0.0.1:8080/graphql'; - - const provider = await Provider.create(providerUrl1, { - fetch: (url: string, options?: RequestInit) => - getCustomFetch('getVersion', { nodeInfo: { nodeVersion: url } })(url, options), - }); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; - expect(provider.url).toBe(providerUrl1); - expect(await provider.getVersion()).toEqual(providerUrl1); + const { cleanup, url } = await launchNode({ port: '0' }); - const spyFetchChainAndNodeInfo = vi - .spyOn(Provider.prototype, 'fetchChainAndNodeInfo') - .mockResolvedValue({ - chain: {} as ChainInfo, - nodeInfo: {} as NodeInfo, - }); - - await provider.connect(providerUrl2); - expect(provider.url).toBe(providerUrl2); + const spyFetchChainAndNodeInfo = vi.spyOn(Provider.prototype, 'fetchChainAndNodeInfo'); - expect(await provider.getVersion()).toEqual(providerUrl2); + await provider.connect(url); + expect(provider.url).toBe(url); expect(spyFetchChainAndNodeInfo).toHaveBeenCalledTimes(1); + cleanup(); }); it('can accept a custom fetch function', async () => { - const providerUrl = FUEL_NETWORK_URL; + using launched = await setupTestProviderAndWallets(); + const { provider: providerForUrl } = launched; + + const providerUrl = providerForUrl.url; const provider = await Provider.create(providerUrl, { fetch: getCustomFetch('getVersion', { nodeInfo: { nodeVersion: '0.30.0' } }), @@ -265,7 +261,10 @@ describe('Provider', () => { }); it('can accept options override in connect method', async () => { - const providerUrl = FUEL_NETWORK_URL; + using launched = await setupTestProviderAndWallets(); + const { provider: providerForUrl } = launched; + + const providerUrl = providerForUrl.url; /** * Mocking and initializing Provider with an invalid fetcher just @@ -299,8 +298,8 @@ describe('Provider', () => { }); it('can force-produce blocks', async () => { - // #region Provider-produce-blocks - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const block = await provider.getBlock('latest'); if (!block) { @@ -319,14 +318,14 @@ describe('Provider', () => { const newest: Date = DateTime.fromTai64(producedBlock?.time || DateTime.TAI64_NULL); expect(newest >= oldest).toBeTruthy(); - // #endregion Provider-produce-blocks }); // TODO: Add back support for producing blocks with intervals by supporting the new // `block_production` config option for `fuel_core`. // See: https://github.com/FuelLabs/fuel-core/blob/def8878b986aedad8434f2d1abf059c8cbdbb8e2/crates/services/consensus_module/poa/src/config.rs#L20 it.skip('can force-produce blocks with custom timestamps', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const block = await provider.getBlock('latest'); if (!block) { @@ -368,27 +367,30 @@ describe('Provider', () => { }); it('can cacheUtxo [undefined]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; expect(provider.cache).toEqual(undefined); }); it('can cacheUtxo [numerical]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL, { - cacheUtxo: 2500, - }); + using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 2500 } }); + const { provider } = launched; expect(provider.cache).toBeTruthy(); expect(provider.cache?.ttl).toEqual(2_500); }); it('can cacheUtxo [invalid numerical]', async () => { - const { error } = await safeExec(() => Provider.create(FUEL_NETWORK_URL, { cacheUtxo: -500 })); + const { error } = await safeExec(async () => { + await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: -500 } }); + }); expect(error?.message).toMatch(/Invalid TTL: -500\. Use a value greater than zero/); }); it('can cacheUtxo [will not cache inputs if no cache]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const transactionRequest = new ScriptTransactionRequest(); const { error } = await safeExec(() => provider.sendTransaction(transactionRequest)); @@ -398,9 +400,13 @@ describe('Provider', () => { }); it('can cacheUtxo [will not cache inputs cache enabled + no coins]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL, { - cacheUtxo: 1, + using launched = await setupTestProviderAndWallets({ + providerOptions: { + cacheUtxo: 1, + }, }); + const { provider } = launched; + const baseAssetId = provider.getBaseAssetId(); const MessageInput: MessageTransactionRequestInput = { type: InputType.Message, @@ -422,9 +428,9 @@ describe('Provider', () => { }); it('can cacheUtxo [will cache inputs cache enabled + coins]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL, { - cacheUtxo: 10000, - }); + using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); + const { provider } = launched; + const baseAssetId = provider.getBaseAssetId(); const EXPECTED: BytesLike[] = [ '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c500', @@ -482,9 +488,9 @@ describe('Provider', () => { }); it('can cacheUtxo [will cache inputs and also use in exclude list]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL, { - cacheUtxo: 10000, - }); + using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); + const { provider } = launched; + const baseAssetId = provider.getBaseAssetId(); const EXPECTED: BytesLike[] = [ '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c503', @@ -558,9 +564,9 @@ describe('Provider', () => { }); it('can cacheUtxo [will cache inputs cache enabled + coins]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL, { - cacheUtxo: 10000, - }); + using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); + const { provider } = launched; + const baseAssetId = provider.getBaseAssetId(); const EXPECTED: BytesLike[] = [ '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c500', @@ -618,9 +624,9 @@ describe('Provider', () => { }); it('can cacheUtxo [will cache inputs and also merge/de-dupe in exclude list]', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL, { - cacheUtxo: 10000, - }); + using launched = await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: 10000 } }); + const { provider } = launched; + const baseAssetId = provider.getBaseAssetId(); const EXPECTED: BytesLike[] = [ '0xbc90ada45d89ec6648f8304eaf8fa2b03384d3c0efabc192b849658f4689b9c503', @@ -706,7 +712,8 @@ describe('Provider', () => { }); it('can getBlocks', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; // Force-producing some blocks to make sure that 10 blocks exist await provider.produceBlocks(10); const blocks = await provider.getBlocks({ @@ -760,11 +767,11 @@ describe('Provider', () => { }); it('can connect', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; // check if the provider was initialized properly expect(provider).toBeInstanceOf(Provider); - expect(provider.url).toEqual(FUEL_NETWORK_URL); expect(provider.getChain()).toBeDefined(); expect(provider.getNode()).toBeDefined(); }); @@ -772,7 +779,8 @@ describe('Provider', () => { it('should cache chain and node info', async () => { Provider.clearChainAndNodeCaches(); - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; expect(provider.getChain()).toBeDefined(); expect(provider.getNode()).toBeDefined(); @@ -785,7 +793,8 @@ describe('Provider', () => { const spyFetchChain = vi.spyOn(Provider.prototype, 'fetchChain'); const spyFetchNode = vi.spyOn(Provider.prototype, 'fetchNode'); - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; expect(spyFetchChainAndNodeInfo).toHaveBeenCalledTimes(1); expect(spyFetchChain).toHaveBeenCalledTimes(1); @@ -806,7 +815,8 @@ describe('Provider', () => { const spyFetchChain = vi.spyOn(Provider.prototype, 'fetchChain'); const spyFetchNode = vi.spyOn(Provider.prototype, 'fetchNode'); - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; expect(spyFetchChainAndNodeInfo).toHaveBeenCalledTimes(1); expect(spyFetchChain).toHaveBeenCalledTimes(1); @@ -820,7 +830,8 @@ describe('Provider', () => { }); it('should ensure getGasConfig return essential gas related data', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const gasConfig = provider.getGasConfig(); @@ -831,7 +842,8 @@ describe('Provider', () => { }); it('should throws when using getChain or getNode and without cached data', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; Provider.clearChainAndNodeCaches(); @@ -917,7 +929,8 @@ Supported fuel-core version: ${mock.supportedVersion}.` }); it('An invalid subscription request throws a FuelError and does not hold the test runner (closes all handles)', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; await expectToThrowFuelError( async () => { @@ -940,18 +953,20 @@ Supported fuel-core version: ${mock.supportedVersion}.` }); it('default timeout is undefined', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; expect(provider.options.timeout).toBeUndefined(); }); it('throws TimeoutError on timeout when calling an operation', async () => { const timeout = 500; - const provider = await Provider.create(FUEL_NETWORK_URL, { timeout }); + using launched = await setupTestProviderAndWallets({ providerOptions: { timeout } }); vi.spyOn(global, 'fetch').mockImplementationOnce((...args: unknown[]) => sleep(timeout).then(() => fetch(args[0] as RequestInfo | URL, args[1] as RequestInit | undefined) ) ); + const { provider } = launched; const { error } = await safeExec(async () => { await provider.getBlocks({}); @@ -966,7 +981,8 @@ Supported fuel-core version: ${mock.supportedVersion}.` it('throws TimeoutError on timeout when calling a subscription', async () => { const timeout = 500; - const provider = await Provider.create(FUEL_NETWORK_URL, { timeout }); + using launched = await setupTestProviderAndWallets({ providerOptions: { timeout } }); + const { provider } = launched; vi.spyOn(global, 'fetch').mockImplementationOnce((...args: unknown[]) => sleep(timeout).then(() => @@ -989,7 +1005,8 @@ Supported fuel-core version: ${mock.supportedVersion}.` }); }); it('should ensure calculateMaxgas considers gasLimit for ScriptTransactionRequest', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const { gasPerByte, maxGasPerTx } = provider.getGasConfig(); const gasLimit = bn(1000); @@ -1018,7 +1035,8 @@ Supported fuel-core version: ${mock.supportedVersion}.` }); it('should ensure calculateMaxgas does NOT considers gasLimit for CreateTransactionRequest', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const { gasPerByte, maxGasPerTx } = provider.getGasConfig(); const transactionRequest = new CreateTransactionRequest({ @@ -1048,8 +1066,8 @@ Supported fuel-core version: ${mock.supportedVersion}.` // TODO: validate if this test still makes sense it.skip('should ensure estimated fee values on getTransactionCost are never 0', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); - + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; const request = new ScriptTransactionRequest(); // forcing calculatePriceWithFactor to return 0 @@ -1064,7 +1082,9 @@ Supported fuel-core version: ${mock.supportedVersion}.` }); it('should accept string addresses in methods that require an address', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await setupTestProviderAndWallets(); + const { provider } = launched; + const baseAssetId = provider.getBaseAssetId(); const b256Str = Address.fromRandom().toB256(); diff --git a/packages/account/src/providers/utils/auto-retry-fetch.ts b/packages/account/src/providers/utils/auto-retry-fetch.ts index a3a7a9ef981..125d31fa87d 100644 --- a/packages/account/src/providers/utils/auto-retry-fetch.ts +++ b/packages/account/src/providers/utils/auto-retry-fetch.ts @@ -1,6 +1,6 @@ -import type { ProviderOptions } from '../provider'; +import { sleep } from '@fuel-ts/utils'; -import { sleep } from './sleep'; +import type { ProviderOptions } from '../provider'; type Backoff = 'linear' | 'exponential' | 'fixed'; diff --git a/packages/account/src/providers/utils/index.ts b/packages/account/src/providers/utils/index.ts index e4bc16151f5..9c5bdc8879f 100644 --- a/packages/account/src/providers/utils/index.ts +++ b/packages/account/src/providers/utils/index.ts @@ -2,5 +2,4 @@ export * from './receipts'; export * from './block-explorer'; export * from './gas'; export * from './json'; -export * from './sleep'; export * from './extract-tx-error'; diff --git a/packages/account/src/test-utils/asset-id.ts b/packages/account/src/test-utils/asset-id.ts new file mode 100644 index 00000000000..0d138f85361 --- /dev/null +++ b/packages/account/src/test-utils/asset-id.ts @@ -0,0 +1,22 @@ +import { randomBytes } from '@fuel-ts/crypto'; +import { hexlify } from '@fuel-ts/utils'; + +export class AssetId { + public static A = new AssetId( + '0x0101010101010101010101010101010101010101010101010101010101010101' + ); + + public static B = new AssetId( + '0x0202020202020202020202020202020202020202020202020202020202020202' + ); + + private constructor(public value: string) {} + + public static random(count: number = 1) { + const assetIds = []; + for (let i = 0; i < count; i++) { + assetIds.push(new AssetId(hexlify(randomBytes(32)))); + } + return assetIds; + } +} diff --git a/packages/account/src/test-utils/index.ts b/packages/account/src/test-utils/index.ts index d90225587ac..6d93d2aa485 100644 --- a/packages/account/src/test-utils/index.ts +++ b/packages/account/src/test-utils/index.ts @@ -1,3 +1,7 @@ export * from './generateTestWallet'; export * from './seedTestWallet'; export * from './launchNode'; +export * from './setup-test-provider-and-wallets'; +export * from './wallet-config'; +export * from './test-message'; +export * from './asset-id'; diff --git a/packages/account/src/test-utils/launchNode.test.ts b/packages/account/src/test-utils/launchNode.test.ts index cf88c28d7d4..5067a68158b 100644 --- a/packages/account/src/test-utils/launchNode.test.ts +++ b/packages/account/src/test-utils/launchNode.test.ts @@ -1,4 +1,5 @@ import { safeExec } from '@fuel-ts/errors/test-utils'; +import { waitUntilUnreachable } from '@fuel-ts/utils/test-utils'; import * as childProcessMod from 'child_process'; import type { LaunchNodeOptions } from './launchNode'; @@ -31,7 +32,7 @@ function mockSpawn(params: { shouldError: boolean } = { shouldError: false }) { // The `Binding GraphQL provider to` message simulates a fuel-core // successful startup log message, usually meaning that the node // is up and waiting for connections - fn('Binding GraphQL provider to'); + fn('Binding GraphQL provider to 0.0.0.0:4000'); } } }; @@ -63,6 +64,35 @@ const defaultLaunchNodeConfig: Partial = { * @group node */ describe('launchNode', () => { + test('using ephemeral port 0 is possible', async () => { + const { cleanup, port, url } = await launchNode({ port: '0' }); + expect(await fetch(url)).toBeTruthy(); + expect(port).not.toEqual('0'); + + cleanup(); + }); + + it('cleanup kills the started node', async () => { + const { cleanup, url } = await launchNode({}); + expect(await fetch(url)).toBeTruthy(); + + cleanup(); + + await waitUntilUnreachable(url); + }); + + test('should start `fuel-core` node using built-in binary', async () => { + mockSpawn(); + + const { cleanup, ip, port } = await launchNode({ + ...defaultLaunchNodeConfig, + }); + + expect(ip).toBe('0.0.0.0'); + expect(port).toBe('4000'); + cleanup(); + }); + test('should start `fuel-core` node using system binary', async () => { mockSpawn(); diff --git a/packages/account/src/test-utils/launchNode.ts b/packages/account/src/test-utils/launchNode.ts index 9f72705ac9c..a2bf58f1dcb 100644 --- a/packages/account/src/test-utils/launchNode.ts +++ b/packages/account/src/test-utils/launchNode.ts @@ -1,6 +1,7 @@ import { BYTES_32 } from '@fuel-ts/abi-coder'; import { randomBytes } from '@fuel-ts/crypto'; -import { defaultSnapshotConfigs, defaultConsensusKey, hexlify } from '@fuel-ts/utils'; +import type { SnapshotConfigs } from '@fuel-ts/utils'; +import { defaultConsensusKey, hexlify, defaultSnapshotConfigs } from '@fuel-ts/utils'; import type { ChildProcessWithoutNullStreams } from 'child_process'; import { spawn } from 'child_process'; import { randomUUID } from 'crypto'; @@ -43,12 +44,18 @@ export type LaunchNodeOptions = { loggingEnabled?: boolean; debugEnabled?: boolean; basePath?: string; + /** + * The snapshot configuration to use. + * Passing in a snapshot configuration path via the `--snapshot` flag in `args` will override this. + * */ + snapshotConfig?: SnapshotConfigs; }; export type LaunchNodeResult = Promise<{ cleanup: () => void; ip: string; port: string; + url: string; snapshotDir: string; }>; @@ -80,6 +87,50 @@ export const killNode = (params: KillNodeParams) => { } }; +function getFinalStateConfigJSON({ stateConfig, chainConfig }: SnapshotConfigs) { + const defaultCoins = defaultSnapshotConfigs.stateConfig.coins.map((coin) => ({ + ...coin, + amount: '18446744073709551615', + })); + const defaultMessages = defaultSnapshotConfigs.stateConfig.messages.map((message) => ({ + ...message, + amount: '18446744073709551615', + })); + + const coins = defaultCoins + .concat(stateConfig.coins.map((coin) => ({ ...coin, amount: coin.amount.toString() }))) + .filter((coin, index, self) => self.findIndex((c) => c.tx_id === coin.tx_id) === index); + const messages = defaultMessages + .concat(stateConfig.messages.map((msg) => ({ ...msg, amount: msg.amount.toString() }))) + .filter((msg, index, self) => self.findIndex((m) => m.nonce === msg.nonce) === index); + + // If there's no genesis key, generate one and some coins to the genesis block. + if (!process.env.GENESIS_SECRET) { + const pk = Signer.generatePrivateKey(); + const signer = new Signer(pk); + process.env.GENESIS_SECRET = hexlify(pk); + + coins.push({ + tx_id: hexlify(randomBytes(BYTES_32)), + owner: signer.address.toHexString(), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + amount: '18446744073709551615' as any, + asset_id: chainConfig.consensus_parameters.V1.base_asset_id, + output_index: 0, + tx_pointer_block_height: 0, + tx_pointer_tx_idx: 0, + }); + } + const json = JSON.stringify({ + ...stateConfig, + coins, + messages, + }); + + const regexMakeNumber = /("amount":)"(\d+)"/gm; + return json.replace(regexMakeNumber, '$1$2'); +} + // #region launchNode-launchNodeOptions /** * Launches a fuel-core node. @@ -100,6 +151,7 @@ export const launchNode = async ({ loggingEnabled = true, debugEnabled = false, basePath, + snapshotConfig = defaultSnapshotConfigs, }: LaunchNodeOptions): LaunchNodeResult => // eslint-disable-next-line no-async-promise-executor new Promise(async (resolve, reject) => { @@ -142,69 +194,27 @@ export const launchNode = async ({ const prefix = basePath || os.tmpdir(); const suffix = basePath ? '' : randomUUID(); - const tempDirPath = path.join(prefix, '.fuels', suffix, 'snapshotDir'); + const tempDir = path.join(prefix, '.fuels', suffix, 'snapshotDir'); if (snapshotDir) { snapshotDirToUse = snapshotDir; } else { - if (!existsSync(tempDirPath)) { - mkdirSync(tempDirPath, { recursive: true }); - } - - let { stateConfigJson } = defaultSnapshotConfigs; - const { chainConfigJson, metadataJson } = defaultSnapshotConfigs; - - stateConfigJson = { - ...stateConfigJson, - coins: [ - ...stateConfigJson.coins.map((coin) => ({ - ...coin, - amount: '18446744073709551615', - })), - ], - messages: stateConfigJson.messages.map((message) => ({ - ...message, - amount: '18446744073709551615', - })), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - } as any; - - // If there's no genesis key, generate one and some coins to the genesis block. - if (!process.env.GENESIS_SECRET) { - const pk = Signer.generatePrivateKey(); - const signer = new Signer(pk); - process.env.GENESIS_SECRET = hexlify(pk); - - stateConfigJson.coins.push({ - tx_id: hexlify(randomBytes(BYTES_32)), - owner: signer.address.toHexString(), - // eslint-disable-next-line @typescript-eslint/no-explicit-any - amount: '18446744073709551615' as any, - asset_id: chainConfigJson.consensus_parameters.V1.base_asset_id, - output_index: 0, - tx_pointer_block_height: 0, - tx_pointer_tx_idx: 0, - }); + if (!existsSync(tempDir)) { + mkdirSync(tempDir, { recursive: true }); } + const { metadata } = snapshotConfig; - let fixedStateConfigJSON = JSON.stringify(stateConfigJson); - - const regexMakeNumber = /("amount":)"(\d+)"/gm; + const metadataPath = path.join(tempDir, 'metadata.json'); + const chainConfigPath = path.join(tempDir, metadata.chain_config); + const stateConfigPath = path.join(tempDir, metadata.table_encoding.Json.filepath); + const stateTransitionPath = path.join(tempDir, 'state_transition_bytecode.wasm'); - fixedStateConfigJSON = fixedStateConfigJSON.replace(regexMakeNumber, '$1$2'); + writeFileSync(chainConfigPath, JSON.stringify(snapshotConfig.chainConfig), 'utf8'); + writeFileSync(stateConfigPath, getFinalStateConfigJSON(snapshotConfig), 'utf8'); + writeFileSync(metadataPath, JSON.stringify(metadata), 'utf8'); + writeFileSync(stateTransitionPath, JSON.stringify('')); - // Write a temporary chain configuration files. - const chainConfigWritePath = path.join(tempDirPath, 'chainConfig.json'); - const stateConfigWritePath = path.join(tempDirPath, 'stateConfig.json'); - const metadataWritePath = path.join(tempDirPath, 'metadata.json'); - const stateTransitionWritePath = path.join(tempDirPath, 'state_transition_bytecode.wasm'); - - writeFileSync(chainConfigWritePath, JSON.stringify(chainConfigJson), 'utf8'); - writeFileSync(stateConfigWritePath, fixedStateConfigJSON, 'utf8'); - writeFileSync(metadataWritePath, JSON.stringify(metadataJson), 'utf8'); - writeFileSync(stateTransitionWritePath, JSON.stringify('')); - - snapshotDirToUse = tempDirPath; + snapshotDirToUse = tempDir; } const child = spawn( @@ -213,7 +223,7 @@ export const launchNode = async ({ 'run', ['--ip', ipToUse], ['--port', portToUse], - useInMemoryDb ? ['--db-type', 'in-memory'] : ['--db-path', tempDirPath], + useInMemoryDb ? ['--db-type', 'in-memory'] : ['--db-path', tempDir], ['--min-gas-price', '1'], poaInstant ? ['--poa-instant', 'true'] : [], ['--native-executor-version', nativeExecutorVersion], @@ -239,7 +249,7 @@ export const launchNode = async ({ const cleanupConfig: KillNodeParams = { child, - configPath: tempDirPath, + configPath: tempDir, killFn: treeKill, state: { isDead: false, @@ -247,19 +257,27 @@ export const launchNode = async ({ }; // Look for a specific graphql start point in the output. - child.stderr.on('data', (chunk: string) => { + child.stderr.on('data', (chunk: string | Buffer) => { + const text = typeof chunk === 'string' ? chunk : chunk.toString(); // chunk is sometimes Buffer and sometimes string... // Look for the graphql service start. - if (chunk.indexOf(graphQLStartSubstring) !== -1) { + if (text.indexOf(graphQLStartSubstring) !== -1) { + const rows = text.split('\n'); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const rowWithUrl = rows.find((row) => row.indexOf(graphQLStartSubstring) !== -1)!; + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const [realIp, realPort] = rowWithUrl.split(' ').at(-1)!.trim().split(':'); // e.g. "2024-02-13T12:31:44.445844Z INFO new{name=fuel-core}: fuel_core::graphql_api::service: 216: Binding GraphQL provider to 127.0.0.1:35039" + // Resolve with the cleanup method. resolve({ cleanup: () => killNode(cleanupConfig), - ip: ipToUse, - port: portToUse, + ip: realIp, + port: realPort, + url: `http://${realIp}:${realPort}/v1/graphql`, snapshotDir: snapshotDirToUse as string, }); } - if (/error/i.test(chunk)) { - reject(chunk.toString()); + if (/error/i.test(text)) { + reject(text.toString()); } }); diff --git a/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts b/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts index 18f56899684..39b36c91e8c 100644 --- a/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts +++ b/packages/account/src/test-utils/launchNodeAndGetWallets.test.ts @@ -25,7 +25,6 @@ describe('launchNode', () => { }, 10000); test('launchNodeAndGetWallets - custom config', async () => { - // #region launchNode-custom-config const snapshotDir = path.join(cwd(), '.fuel-core/configs'); const { stop, provider } = await launchNodeAndGetWallets({ @@ -46,7 +45,6 @@ describe('launchNode', () => { expect(gasPerByte.toNumber()).toEqual(expectedGasPerByte); stop(); - // #endregion launchNode-custom-config }); test('launchNodeAndGetWallets - custom walletCount', async () => { diff --git a/packages/account/src/test-utils/setup-test-provider-and-wallets.test.ts b/packages/account/src/test-utils/setup-test-provider-and-wallets.test.ts new file mode 100644 index 00000000000..b54a543ffcd --- /dev/null +++ b/packages/account/src/test-utils/setup-test-provider-and-wallets.test.ts @@ -0,0 +1,263 @@ +import { randomBytes } from '@fuel-ts/crypto'; +import { ErrorCode } from '@fuel-ts/errors'; +import { expectToThrowFuelError, safeExec } from '@fuel-ts/errors/test-utils'; +import type { AbstractAddress } from '@fuel-ts/interfaces'; +import { bn, toNumber } from '@fuel-ts/math'; +import { defaultSnapshotConfigs, hexlify } from '@fuel-ts/utils'; +import { waitUntilUnreachable } from '@fuel-ts/utils/test-utils'; + +import { Provider } from '../providers'; +import { Signer } from '../signer'; +import { WalletUnlocked } from '../wallet'; + +import { AssetId } from './asset-id'; +import * as launchNodeMod from './launchNode'; +import { setupTestProviderAndWallets } from './setup-test-provider-and-wallets'; +import { TestMessage } from './test-message'; + +const BaseAssetId = defaultSnapshotConfigs.chainConfig.consensus_parameters.V1.base_asset_id; +/** + * @group node + */ +describe('setupTestProviderAndWallets', () => { + it('kills the node after going out of scope', async () => { + let url = ''; + // eslint-disable-next-line no-lone-blocks + { + using result = await setupTestProviderAndWallets(); + url = result.provider.url; + await result.provider.getBlockNumber(); + } + + await waitUntilUnreachable(url); + + const { error } = await safeExec(async () => { + const p = await Provider.create(url); + return p.getBlockNumber(); + }); + + expect(error).toMatchObject({ + message: 'fetch failed', + }); + }); + + test('kills the node if provider cant connect post-launch', async () => { + const launchNodeSpy = vi.spyOn(launchNodeMod, 'launchNode'); + + await expectToThrowFuelError( + async () => { + await setupTestProviderAndWallets({ providerOptions: { cacheUtxo: -500 } }); + }, + { code: ErrorCode.INVALID_TTL } + ); + expect(launchNodeSpy).toHaveBeenCalled(); + const { url } = launchNodeSpy.mock.results[0].value as Awaited; + + // test will timeout if the node isn't killed + await waitUntilUnreachable(url); + }); + + it('can partially extend the default node configs', async () => { + const coin = { + owner: hexlify(randomBytes(32)), + amount: 1234, + asset_id: hexlify(randomBytes(32)), + tx_id: hexlify(randomBytes(32)), + output_index: 0, + tx_pointer_block_height: 0, + tx_pointer_tx_idx: 0, + }; + using launched = await setupTestProviderAndWallets({ + nodeOptions: { + snapshotConfig: { + stateConfig: { + coins: [coin], + }, + }, + }, + }); + + const { provider } = launched; + + const [sutCoin] = await provider.getCoins( + { toB256: () => coin.owner } as AbstractAddress, + coin.asset_id + ); + + expect(sutCoin.amount.toNumber()).toEqual(coin.amount); + expect(sutCoin.owner.toB256()).toEqual(coin.owner); + expect(sutCoin.assetId).toEqual(coin.asset_id); + expect(sutCoin.txCreatedIdx.toNumber()).toEqual(coin.tx_pointer_tx_idx); + expect(sutCoin.blockCreated.toNumber()).toEqual(coin.tx_pointer_block_height); + }); + + it('default: two wallets, three assets (BaseAssetId, AssetId.A, AssetId.B), one coin, 10_000_000_000_ amount', async () => { + using providerAndWallets = await setupTestProviderAndWallets(); + const { wallets, provider } = providerAndWallets; + + expect(wallets.length).toBe(2); + wallets.forEach((w) => expect(w.provider).toBe(provider)); + const [wallet1, wallet2] = wallets; + const coins1 = await wallet1.getCoins(); + const coins2 = await wallet2.getCoins(); + + expect(coins1.length).toBe(3); + expect( + coins1 + .sort((a, b) => (bn(a.assetId).gt(bn(b.assetId)) ? 1 : -1)) + .map((x) => [x.amount, x.assetId]) + ).toEqual( + coins2 + .sort((a, b) => (bn(a.assetId).gt(bn(b.assetId)) ? 1 : -1)) + .map((x) => [x.amount, x.assetId]) + ); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const baseAssetIdCoin = coins1.find((x) => x.assetId === BaseAssetId)!; + + expect(baseAssetIdCoin.assetId).toBe(BaseAssetId); + expect(baseAssetIdCoin.amount.toNumber()).toBe(10_000_000_000); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const assetACoin = coins1.find((x) => x.assetId === AssetId.A.value)!; + expect(assetACoin.amount.toNumber()).toBe(10_000_000_000); + + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const assetBCoin = coins1.find((x) => x.assetId === AssetId.B.value)!; + expect(assetBCoin.amount.toNumber()).toBe(10_000_000_000); + }); + + it('can be given custom asset id and message', async () => { + const [assetId] = AssetId.random(); + const testMessage = new TestMessage(); + using providerAndWallets = await setupTestProviderAndWallets({ + walletsConfig: { + count: 1, + assets: [assetId], + messages: [testMessage], + }, + }); + + const { + wallets: [wallet], + } = providerAndWallets; + + const coins = await wallet.getCoins(); + expect(coins.length).toBe(2); + coins.sort((a) => (a.assetId === BaseAssetId ? -1 : 1)); + + const coin1 = coins[0]; + + expect(coin1.assetId).toBe(BaseAssetId); + expect(coin1.amount.toNumber()).toBe(10_000_000_000); + + const coin2 = coins[1]; + + expect(coin2.assetId).toBe(assetId.value); + expect(coin2.amount.toNumber()).toBe(10_000_000_000); + + const messages = await wallet.getMessages(); + expect(messages.length).toBe(1); + + const [message] = messages; + const chainMessage = testMessage.toChainMessage(wallet.address); + + expect(message.amount.toNumber()).toEqual(chainMessage.amount); + expect(message.recipient.toB256()).toEqual(chainMessage.recipient); + expect(message.sender.toB256()).toEqual(chainMessage.sender); + expect(toNumber(message.daHeight)).toEqual(toNumber(chainMessage.da_height)); + expect(message.data.toString()).toEqual(toNumber(chainMessage.data).toString()); + expect(message.nonce).toEqual(chainMessage.nonce); + }); + + it('can return multiple wallets with multiple assets, coins and amounts', async () => { + const numWallets = 3; + const numOfAssets = 5; + const coinsPerAsset = 10; + const amountPerCoin = 15; + + using providerAndWallets = await setupTestProviderAndWallets({ + walletsConfig: { + count: numWallets, + assets: numOfAssets, + coinsPerAsset, + amountPerCoin, + }, + }); + const { wallets } = providerAndWallets; + + expect(wallets.length).toBe(numWallets); + + const promises = wallets.map(async (wallet) => { + const coins = await wallet.getCoins(); + expect(coins.length).toBe(numOfAssets * coinsPerAsset); + + coins + .sort((coin) => (coin.assetId === BaseAssetId ? -1 : 1)) + .forEach((coin, index) => { + if (index < coinsPerAsset) { + expect(coin.assetId).toBe(BaseAssetId); + } else { + expect(coin.assetId).not.toBe(BaseAssetId); + expect(coin.assetId).not.toBeFalsy(); + } + expect(coin.amount.toNumber()).toBe(amountPerCoin); + }); + }); + + await Promise.all(promises); + }); + + test("gives control to add additional custom coins/messages to the genesis block without overriding walletsConfig's settings", async () => { + const pk = Signer.generatePrivateKey(); + const signer = new Signer(pk); + const address = signer.address; + + const coin = { + owner: address.toB256(), + amount: 100, + asset_id: BaseAssetId, + tx_id: hexlify(randomBytes(32)), + output_index: 0, + tx_pointer_block_height: 0, + tx_pointer_tx_idx: 0, + }; + const message = new TestMessage({ recipient: signer.address }).toChainMessage(); + + using providerAndWallets = await setupTestProviderAndWallets({ + nodeOptions: { + snapshotConfig: { + stateConfig: { + coins: [coin], + messages: [message], + }, + }, + }, + }); + const { wallets, provider } = providerAndWallets; + + expect(wallets.length).toBe(2); + const [wallet] = wallets; + const coins = await wallet.getCoins(); + expect(coins.length).toBe(3); + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + const walletCoin = coins.find((c) => c.assetId === BaseAssetId)!; + expect(walletCoin.assetId).toBe(BaseAssetId); + expect(walletCoin.amount.toNumber()).toBe(10_000_000_000); + + const customWallet = new WalletUnlocked(pk, provider); + const [customWalletCoin] = await customWallet.getCoins(); + const [customWalletMessage] = await customWallet.getMessages(); + + expect(customWalletCoin.amount.toNumber()).toEqual(coin.amount); + expect(customWalletCoin.owner.toB256()).toEqual(coin.owner); + expect(customWalletCoin.assetId).toEqual(coin.asset_id); + + expect(customWalletMessage.amount.toNumber()).toEqual(message.amount); + expect(customWalletMessage.recipient.toB256()).toEqual(message.recipient); + expect(customWalletMessage.sender.toB256()).toEqual(message.sender); + expect(toNumber(customWalletMessage.daHeight)).toEqual(toNumber(message.da_height)); + expect(customWalletMessage.data.toString()).toEqual(toNumber(message.data).toString()); + expect(customWalletMessage.nonce).toEqual(message.nonce); + }); +}); diff --git a/packages/account/src/test-utils/setup-test-provider-and-wallets.ts b/packages/account/src/test-utils/setup-test-provider-and-wallets.ts new file mode 100644 index 00000000000..2e03d5fa8d0 --- /dev/null +++ b/packages/account/src/test-utils/setup-test-provider-and-wallets.ts @@ -0,0 +1,97 @@ +import { defaultSnapshotConfigs, type SnapshotConfigs } from '@fuel-ts/utils'; +import { mergeDeepRight } from 'ramda'; +import type { PartialDeep } from 'type-fest'; + +import type { ProviderOptions } from '../providers'; +import { Provider } from '../providers'; +import type { WalletUnlocked } from '../wallet'; + +import { AssetId } from './asset-id'; +import type { LaunchNodeOptions } from './launchNode'; +import { launchNode } from './launchNode'; +import type { WalletsConfigOptions } from './wallet-config'; +import { WalletsConfig } from './wallet-config'; + +export interface LaunchCustomProviderAndGetWalletsOptions { + /** Configures the wallets that should exist in the genesis block of the `fuel-core` node. */ + walletsConfig?: Partial; + /** Options for configuring the provider. */ + providerOptions?: Partial; + /** Options for configuring the test node. */ + nodeOptions?: Partial< + Omit & { + snapshotConfig: PartialDeep; + } + >; +} + +const defaultWalletConfigOptions: WalletsConfigOptions = { + count: 2, + assets: [AssetId.A, AssetId.B], + coinsPerAsset: 1, + amountPerCoin: 10_000_000_000, + messages: [], +}; + +export interface SetupTestProviderAndWalletsReturn extends Disposable { + wallets: WalletUnlocked[]; + provider: Provider; + cleanup: () => void; +} + +/** + * Launches a test node and creates wallets for testing. + * If initialized with the `using` keyword, the node will be killed when it goes out of scope. + * If initialized with `const`, manual disposal of the node must be done via the `cleanup` function. + * + * @param options - Options for configuring the wallets, provider, and test node. + * @returns The wallets, provider and cleanup function that kills the node. + * + */ +export async function setupTestProviderAndWallets({ + walletsConfig: walletsConfigOptions = {}, + providerOptions, + nodeOptions = {}, +}: Partial = {}): Promise { + // @ts-expect-error this is a polyfill (see https://devblogs.microsoft.com/typescript/announcing-typescript-5-2/#using-declarations-and-explicit-resource-management) + Symbol.dispose ??= Symbol('Symbol.dispose'); + const walletsConfig = new WalletsConfig( + nodeOptions.snapshotConfig?.chainConfig?.consensus_parameters?.V1?.base_asset_id ?? + defaultSnapshotConfigs.chainConfig.consensus_parameters.V1.base_asset_id, + { + ...defaultWalletConfigOptions, + ...walletsConfigOptions, + } + ); + + const { cleanup, url } = await launchNode({ + loggingEnabled: false, + ...nodeOptions, + snapshotConfig: mergeDeepRight( + defaultSnapshotConfigs, + walletsConfig.apply(nodeOptions?.snapshotConfig) + ), + port: '0', + }); + + let provider: Provider; + + try { + provider = await Provider.create(url, providerOptions); + } catch (err) { + cleanup(); + throw err; + } + + const wallets = walletsConfig.wallets; + wallets.forEach((wallet) => { + wallet.connect(provider); + }); + + return { + provider, + wallets, + cleanup, + [Symbol.dispose]: cleanup, + }; +} diff --git a/packages/account/src/test-utils/test-message.test.ts b/packages/account/src/test-utils/test-message.test.ts new file mode 100644 index 00000000000..ec68d403dd1 --- /dev/null +++ b/packages/account/src/test-utils/test-message.test.ts @@ -0,0 +1,47 @@ +import { Address } from '@fuel-ts/address'; +import { randomBytes } from '@fuel-ts/crypto'; +import { hexlify } from '@fuel-ts/utils'; + +import { TestMessage } from './test-message'; + +/** + * @group node + */ +describe('test-message', () => { + test('has default values', () => { + const message = new TestMessage().toChainMessage(); + + expect(message.amount).toBeDefined(); + expect(message.da_height).toBeDefined(); + expect(message.data).toBeDefined(); + expect(message.nonce).toBeDefined(); + expect(message.recipient).toBeDefined(); + expect(message.sender).toBeDefined(); + }); + + test('accepts custom values', () => { + const recipient = Address.fromRandom(); + const sender = Address.fromRandom(); + const amount = 1; + const daHeight = 1; + const data = '1234'; + const nonce = hexlify(randomBytes(32)); + + const testMessage = new TestMessage({ + amount, + recipient, + sender, + nonce, + da_height: daHeight, + data, + }); + + const message = testMessage.toChainMessage(); + expect(message.amount).toEqual(amount); + expect(message.da_height).toEqual(daHeight); + expect(message.data).toEqual(data); + expect(message.nonce).toEqual(nonce); + expect(message.recipient).toEqual(recipient.toB256()); + expect(message.sender).toEqual(sender.toB256()); + }); +}); diff --git a/packages/account/src/test-utils/test-message.ts b/packages/account/src/test-utils/test-message.ts new file mode 100644 index 00000000000..7c8685cc64a --- /dev/null +++ b/packages/account/src/test-utils/test-message.ts @@ -0,0 +1,57 @@ +import { Address } from '@fuel-ts/address'; +import { randomBytes } from '@fuel-ts/crypto'; +import type { AbstractAddress } from '@fuel-ts/interfaces'; +import { bn, type BN } from '@fuel-ts/math'; +import type { SnapshotConfigs } from '@fuel-ts/utils'; +import { hexlify } from '@fuel-ts/utils'; + +interface TestMessageSpecs { + sender: AbstractAddress; + recipient: AbstractAddress; + nonce: string; + amount: number; + data: string; + da_height: number; +} + +export class TestMessage { + public readonly sender: AbstractAddress; + public readonly recipient: AbstractAddress; + public readonly nonce: string; + public readonly amount: number | BN; + public readonly data: string; + public readonly da_height: number; + + /** + * A helper class to create messages for testing purposes. + * + * Used in tandem with `WalletsConfig`. + * It can also be used standalone and passed into the initial state of a chain via the `.toChainMessage` method. + */ + constructor({ + sender = Address.fromRandom(), + recipient = Address.fromRandom(), + nonce = hexlify(randomBytes(32)), + amount = 1_000_000, + data = '02', + da_height = 0, + }: Partial = {}) { + this.sender = sender; + this.recipient = recipient; + this.nonce = nonce; + this.amount = amount; + this.data = data; + this.da_height = da_height; + } + + toChainMessage(recipient?: AbstractAddress): SnapshotConfigs['stateConfig']['messages'][0] { + return { + sender: this.sender.toB256(), + recipient: recipient?.toB256() ?? this.recipient.toB256(), + nonce: this.nonce, + amount: bn(this.amount).toNumber(), + data: this.data, + da_height: this.da_height, + }; + } +} diff --git a/packages/account/src/test-utils/wallet-config.test.ts b/packages/account/src/test-utils/wallet-config.test.ts new file mode 100644 index 00000000000..86598f10562 --- /dev/null +++ b/packages/account/src/test-utils/wallet-config.test.ts @@ -0,0 +1,113 @@ +import { randomBytes } from '@fuel-ts/crypto'; +import { FuelError } from '@fuel-ts/errors'; +import { expectToThrowFuelError } from '@fuel-ts/errors/test-utils'; +import { hexlify } from '@fuel-ts/utils'; + +import { AssetId } from './asset-id'; +import type { WalletsConfigOptions } from './wallet-config'; +import { WalletsConfig } from './wallet-config'; + +/** + * @group node + */ +describe('WalletsConfig', () => { + const configOptions: WalletsConfigOptions = { + count: 2, + assets: [AssetId.A, AssetId.B], + coinsPerAsset: 1, + amountPerCoin: 10_000_000_000, + messages: [], + }; + it('throws on invalid number of wallets', async () => { + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, count: -1 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of wallets must be greater than zero.' + ) + ); + + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, count: 0 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of wallets must be greater than zero.' + ) + ); + }); + + it('throws on invalid number of assets', async () => { + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, assets: -1 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of assets per wallet must be greater than zero.' + ) + ); + + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, assets: 0 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of assets per wallet must be greater than zero.' + ) + ); + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, assets: [] }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of assets per wallet must be greater than zero.' + ) + ); + }); + + it('throws on invalid number of coins per asset', async () => { + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, coinsPerAsset: -1 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of coins per asset must be greater than zero.' + ) + ); + + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, coinsPerAsset: 0 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of coins per asset must be greater than zero.' + ) + ); + }); + + it('throws on invalid amount per coin', async () => { + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, amountPerCoin: -1 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Amount per coin must be greater than zero.' + ) + ); + + await expectToThrowFuelError( + () => new WalletsConfig(hexlify(randomBytes(32)), { ...configOptions, amountPerCoin: 0 }), + new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Amount per coin must be greater than zero.' + ) + ); + }); + + it('allows custom assets to be provided', () => { + const [assetId] = AssetId.random(); + const baseAssetId = hexlify(randomBytes(32)); + const { + stateConfig: { coins: allCoins }, + } = new WalletsConfig(baseAssetId, { ...configOptions, assets: [assetId] }).apply({}); + + const coins = allCoins.filter((coin, _index, arr) => coin.owner === arr[0].owner); + + expect(coins[0].asset_id).toEqual(baseAssetId); + expect(coins[1].asset_id).toEqual(assetId.value); + expect(coins.length).toBe(2); + }); +}); diff --git a/packages/account/src/test-utils/wallet-config.ts b/packages/account/src/test-utils/wallet-config.ts new file mode 100644 index 00000000000..334226be0e6 --- /dev/null +++ b/packages/account/src/test-utils/wallet-config.ts @@ -0,0 +1,177 @@ +import { randomBytes } from '@fuel-ts/crypto'; +import { FuelError } from '@fuel-ts/errors'; +import { defaultSnapshotConfigs, hexlify, type SnapshotConfigs } from '@fuel-ts/utils'; +import type { PartialDeep } from 'type-fest'; + +import { WalletUnlocked } from '../wallet'; + +import { AssetId } from './asset-id'; +import type { TestMessage } from './test-message'; + +export interface WalletsConfigOptions { + /** + * Number of wallets to generate. + */ + count: number; + + /** + * If `number`, the number of unique asset ids each wallet will own with the base asset included. + * + * If `AssetId[]`, the asset ids the each wallet will own besides the base asset. + */ + assets: number | AssetId[]; + + /** + * Number of coins (UTXOs) per asset id. + */ + coinsPerAsset: number; + + /** + * For each coin, the amount it'll contain. + */ + amountPerCoin: number; + + /** + * Messages that are supposed to be on the wallet. + * The `recipient` field of the message is overriden to be the wallet's address. + */ + messages: TestMessage[]; +} + +/** + * Used for configuring the wallets that should exist in the genesis block of a test node. + */ +export class WalletsConfig { + private initialState: SnapshotConfigs['stateConfig']; + private options: WalletsConfigOptions; + public wallets: WalletUnlocked[]; + + private generateWallets: () => WalletUnlocked[] = () => { + const generatedWallets: WalletUnlocked[] = []; + for (let index = 1; index <= this.options.count; index++) { + generatedWallets.push(new WalletUnlocked(randomBytes(32))); + } + return generatedWallets; + }; + + constructor(baseAssetId: string, config: WalletsConfigOptions) { + WalletsConfig.validate(config); + + this.options = config; + + const { assets, coinsPerAsset, amountPerCoin, messages } = this.options; + + this.wallets = this.generateWallets(); + + this.initialState = { + messages: WalletsConfig.createMessages(this.wallets, messages), + coins: WalletsConfig.createCoins( + this.wallets, + baseAssetId, + assets, + coinsPerAsset, + amountPerCoin + ), + }; + } + + apply(snapshotConfig: PartialDeep | undefined): PartialDeep & { + stateConfig: { coins: SnapshotConfigs['stateConfig']['coins'] }; + } { + return { + ...snapshotConfig, + stateConfig: { + ...(snapshotConfig?.stateConfig ?? defaultSnapshotConfigs.stateConfig), + coins: this.initialState.coins.concat(snapshotConfig?.stateConfig?.coins || []), + messages: this.initialState.messages.concat(snapshotConfig?.stateConfig?.messages ?? []), + }, + }; + } + + /** + * Create messages for the wallets in the format that the chain expects. + */ + private static createMessages(wallets: WalletUnlocked[], messages: TestMessage[]) { + return messages + .map((msg) => wallets.map((wallet) => msg.toChainMessage(wallet.address))) + .flatMap((x) => x); + } + + /** + * Create coins for the wallets in the format that the chain expects. + */ + private static createCoins( + wallets: WalletUnlocked[], + baseAssetId: string, + assets: number | AssetId[], + coinsPerAsset: number, + amountPerCoin: number + ) { + const coins: SnapshotConfigs['stateConfig']['coins'] = []; + + let assetIds: string[] = [baseAssetId]; + if (Array.isArray(assets)) { + assetIds = assetIds.concat(assets.map((a) => a.value)); + } else { + assetIds = assetIds.concat(AssetId.random(assets - 1).map((a) => a.value)); + } + + wallets + .map((wallet) => wallet.address.toHexString()) + .forEach((walletAddress) => { + assetIds.forEach((assetId) => { + for (let index = 0; index < coinsPerAsset; index++) { + coins.push({ + amount: amountPerCoin, + asset_id: assetId, + owner: walletAddress, + tx_pointer_block_height: 0, + tx_pointer_tx_idx: 0, + output_index: 0, + tx_id: hexlify(randomBytes(32)), + }); + } + }); + }); + + return coins; + } + + private static validate({ + count: wallets, + assets, + coinsPerAsset, + amountPerCoin, + }: WalletsConfigOptions) { + if ( + (Array.isArray(wallets) && wallets.length === 0) || + (typeof wallets === 'number' && wallets <= 0) + ) { + throw new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of wallets must be greater than zero.' + ); + } + if ( + (Array.isArray(assets) && assets.length === 0) || + (typeof assets === 'number' && assets <= 0) + ) { + throw new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of assets per wallet must be greater than zero.' + ); + } + if (coinsPerAsset <= 0) { + throw new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Number of coins per asset must be greater than zero.' + ); + } + if (amountPerCoin <= 0) { + throw new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + 'Amount per coin must be greater than zero.' + ); + } + } +} diff --git a/packages/contract/package.json b/packages/contract/package.json index 06f0cb63445..3dcf9b3142e 100644 --- a/packages/contract/package.json +++ b/packages/contract/package.json @@ -14,12 +14,26 @@ "require": "./dist/index.js", "import": "./dist/index.mjs", "types": "./dist/index.d.ts" + }, + "./test-utils": { + "require": "./dist/test-utils.js", + "import": "./dist/test-utils.mjs", + "types": "./dist/test-utils.d.ts" + } + }, + "typesVersions": { + "*": { + "test-utils": [ + "./dist/test-utils.d.ts" + ] } }, "files": [ "dist" ], "scripts": { + "pretest": "pnpm build:forc", + "build:forc": "pnpm fuels-forc build -p ./test/fixtures/forc-projects --release", "build": "tsup", "postbuild": "tsx ../../scripts/postbuild.ts" }, @@ -34,6 +48,13 @@ "@fuel-ts/merkle": "workspace:*", "@fuel-ts/program": "workspace:*", "@fuel-ts/transactions": "workspace:*", + "@fuel-ts/utils": "workspace:*", + "@fuel-ts/versions": "workspace:*", + "ramda": "^0.29.0" + }, + "devDependencies": { + "@internal/forc": "workspace:*", + "@types/ramda": "^0.29.3", "@fuel-ts/utils": "workspace:*" } } diff --git a/packages/contract/src/test-utils.ts b/packages/contract/src/test-utils.ts new file mode 100644 index 00000000000..bc52371bd2d --- /dev/null +++ b/packages/contract/src/test-utils.ts @@ -0,0 +1 @@ +export * from './test-utils/launch-test-node'; diff --git a/packages/contract/src/test-utils/launch-test-node.test.ts b/packages/contract/src/test-utils/launch-test-node.test.ts new file mode 100644 index 00000000000..41480cbc386 --- /dev/null +++ b/packages/contract/src/test-utils/launch-test-node.test.ts @@ -0,0 +1,303 @@ +import type { JsonAbi } from '@fuel-ts/abi-coder'; +import { Provider } from '@fuel-ts/account'; +import * as setupTestProviderAndWalletsMod from '@fuel-ts/account/test-utils'; +import { FuelError } from '@fuel-ts/errors'; +import { expectToThrowFuelError, safeExec } from '@fuel-ts/errors/test-utils'; +import { hexlify, type SnapshotConfigs } from '@fuel-ts/utils'; +import { getForcProject, waitUntilUnreachable } from '@fuel-ts/utils/test-utils'; +import { randomBytes, randomUUID } from 'crypto'; +import { existsSync, mkdirSync, readFileSync, rmSync } from 'fs'; +import { writeFile, copyFile } from 'fs/promises'; +import os from 'os'; +import { join } from 'path'; + +import ContractFactory from '../contract-factory'; + +import { launchTestNode } from './launch-test-node'; + +const { binHexlified, abiContents } = getForcProject({ + projectDir: join(__dirname, '../../test/fixtures/forc-projects/simple-contract'), + projectName: 'simple-contract', + build: 'release', +}); +async function generateChainConfigFile(chainName: string): Promise<[string, () => void]> { + const configsFolder = join(__dirname, '../../../../', '.fuel-core', 'configs'); + + const chainMetadata = JSON.parse( + readFileSync(join(configsFolder, 'metadata.json'), 'utf-8') + ) as SnapshotConfigs['metadata']; + + const chainConfig = JSON.parse( + readFileSync(join(configsFolder, chainMetadata.chain_config), 'utf-8') + ) as SnapshotConfigs['chainConfig']; + + chainConfig.chain_name = chainName; + + const tempSnapshotDirPath = join(os.tmpdir(), '.fuels-ts', randomUUID()); + + if (!existsSync(tempSnapshotDirPath)) { + mkdirSync(tempSnapshotDirPath, { recursive: true }); + } + + const metadataPath = join(tempSnapshotDirPath, 'metadata.json'); + + await copyFile(join(configsFolder, 'metadata.json'), metadataPath); + await copyFile( + join(configsFolder, chainMetadata.table_encoding.Json.filepath), + join(tempSnapshotDirPath, chainMetadata.table_encoding.Json.filepath) + ); + + // Write a temporary chain configuration file. + await writeFile( + join(tempSnapshotDirPath, chainMetadata.chain_config), + JSON.stringify(chainConfig), + 'utf-8' + ); + + return [tempSnapshotDirPath, () => rmSync(tempSnapshotDirPath, { recursive: true, force: true })]; +} + +/** + * @group node + */ +describe('launchTestNode', () => { + test('kills the node after going out of scope', async () => { + let url = ''; + + { + using launched = await launchTestNode(); + + const { provider } = launched; + + url = provider.url; + await provider.getBlockNumber(); + } + + await waitUntilUnreachable(url); + + const { error } = await safeExec(async () => { + const p = await Provider.create(url); + await p.getBlockNumber(); + }); + + expect(error).toMatchObject({ + message: 'fetch failed', + }); + }); + + test('kills the node if error happens post-launch on contract deployment', async () => { + const spy = vi.spyOn(setupTestProviderAndWalletsMod, 'setupTestProviderAndWallets'); + + const { error } = await safeExec(() => + launchTestNode({ + contractsConfigs: [ + { + deployer: { + deployContract: () => { + throw new Error('Test error'); + }, + }, + bytecode: binHexlified, + }, + ], + }) + ); + expect(error).toBeDefined(); + // Verify that error isn't due to + expect(spy).toHaveBeenCalled(); + + const { + provider: { url }, + } = spy.mock.results[0].value as { provider: { url: string } }; + + // test will time out if the node isn't killed + await waitUntilUnreachable(url); + }); + + test('a contract can be deployed', async () => { + using launched = await launchTestNode({ + contractsConfigs: [ + { + deployer: { + deployContract: async (bytecode, wallet, options) => { + const factory = new ContractFactory(bytecode, abiContents, wallet); + return factory.deployContract(options); + }, + }, + bytecode: binHexlified, + }, + ], + }); + + const { + contracts: [contract], + } = launched; + + const response = await contract.functions.test_function().call(); + expect(response.value).toBe(true); + }); + + test('multiple contracts can be deployed', async () => { + using launched = await launchTestNode({ + contractsConfigs: [ + { + deployer: { + deployContract: async (bytecode, wallet, options) => { + const factory = new ContractFactory(bytecode, abiContents, wallet); + return factory.deployContract(options); + }, + }, + bytecode: binHexlified, + }, + { + deployer: { + deployContract: async (bytecode, wallet, options) => { + const factory = new ContractFactory(bytecode, abiContents, wallet); + return factory.deployContract(options); + }, + }, + bytecode: binHexlified, + }, + ], + }); + + const { + contracts: [contract, contract2], + } = launched; + + const response1 = await contract.functions.test_function().call(); + const response2 = await contract2.functions.test_function().call(); + expect(response1.value).toBe(true); + expect(response2.value).toBe(true); + }); + + test('multiple contracts can be deployed with different wallets', async () => { + using launched = await launchTestNode({ + walletsConfig: { + count: 2, + }, + contractsConfigs: [ + { + deployer: { + deployContract: async (bytecode, wallet, options) => { + const factory = new ContractFactory(bytecode, abiContents, wallet); + return factory.deployContract(options); + }, + }, + bytecode: binHexlified, + }, + { + deployer: { + deployContract: async (bytecode, wallet, options) => { + const factory = new ContractFactory(bytecode, abiContents, wallet); + return factory.deployContract(options); + }, + }, + bytecode: binHexlified, + walletIndex: 1, + }, + ], + }); + + const { + contracts: [contract1, contract2], + wallets: [wallet1, wallet2], + } = launched; + + const contract1Response = (await contract1.functions.test_function().call()).value; + const contract2Response = (await contract2.functions.test_function().call()).value; + + expect(contract1Response).toBe(true); + expect(contract2Response).toBe(true); + + expect(contract1.account).toEqual(wallet1); + expect(contract2.account).toEqual(wallet2); + }); + + test('throws on invalid walletIndex', async () => { + await expectToThrowFuelError( + async () => { + await launchTestNode({ + contractsConfigs: [ + { + deployer: { + deployContract: async (bytecode, wallet, options) => { + const factory = new ContractFactory(bytecode, abiContents, wallet); + return factory.deployContract(options); + }, + }, + bytecode: binHexlified, + walletIndex: 2, + }, + ], + }); + }, + { + code: FuelError.CODES.INVALID_INPUT_PARAMETERS, + message: `Invalid walletIndex 2; wallets array contains 2 elements.`, + } + ); + }); + + test('can be given different fuel-core args via an environment variable', async () => { + process.env.DEFAULT_FUEL_CORE_ARGS = `--tx-max-depth 20`; + + using launched = await launchTestNode(); + + const { provider } = launched; + + expect(provider.getNode().maxDepth.toNumber()).toEqual(20); + process.env.DEFAULT_FUEL_CORE_ARGS = ''; + }); + + test('can be given a different base chain config via an environment variable', async () => { + const chainName = 'gimme_fuel'; + const [chainConfigPath, cleanup] = await generateChainConfigFile(chainName); + + process.env.DEFAULT_CHAIN_SNAPSHOT_DIR = chainConfigPath; + + using launched = await launchTestNode(); + cleanup(); + process.env.DEFAULT_CHAIN_SNAPSHOT_DIR = ''; + + const { provider } = launched; + + const { name } = await provider.fetchChain(); + + expect(name).toEqual(chainName); + }); + + test('chain config from environment variable can be extended manually', async () => { + const chainName = 'gimme_fuel_gimme_fire_gimme_that_which_i_desire'; + const [chainMetadataPath, cleanup] = await generateChainConfigFile(chainName); + process.env.DEFAULT_CHAIN_SNAPSHOT_DIR = chainMetadataPath; + + const baseAssetId = hexlify(randomBytes(32)); + + using launched = await launchTestNode({ + nodeOptions: { + snapshotConfig: { + chainConfig: { + consensus_parameters: { + V1: { + base_asset_id: baseAssetId, + }, + }, + }, + }, + }, + }); + + cleanup(); + process.env.DEFAULT_CHAIN_SNAPSHOT_DIR = ''; + + const { provider } = launched; + + const { + name, + consensusParameters: { baseAssetId: baseAssetIdFromChainConfig }, + } = await provider.fetchChain(); + expect(name).toEqual(chainName); + expect(baseAssetIdFromChainConfig).toEqual(baseAssetId); + }); +}); diff --git a/packages/contract/src/test-utils/launch-test-node.ts b/packages/contract/src/test-utils/launch-test-node.ts new file mode 100644 index 00000000000..0b2664f8d93 --- /dev/null +++ b/packages/contract/src/test-utils/launch-test-node.ts @@ -0,0 +1,174 @@ +import type { Account, WalletUnlocked } from '@fuel-ts/account'; +import { setupTestProviderAndWallets } from '@fuel-ts/account/test-utils'; +import type { + LaunchCustomProviderAndGetWalletsOptions, + SetupTestProviderAndWalletsReturn, +} from '@fuel-ts/account/test-utils'; +import { FuelError } from '@fuel-ts/errors'; +import type { BytesLike } from '@fuel-ts/interfaces'; +import type { Contract } from '@fuel-ts/program'; +import type { SnapshotConfigs } from '@fuel-ts/utils'; +import { readFileSync } from 'fs'; +import * as path from 'path'; +import { mergeDeepRight } from 'ramda'; + +import type { DeployContractOptions } from '../contract-factory'; + +interface ContractDeployer { + deployContract( + bytecode: BytesLike, + wallet: Account, + options?: DeployContractOptions + ): Promise; +} + +interface DeployContractConfig { + /** + * Contract deployer object compatible with factories outputted by `pnpm fuels typegen`. + */ + deployer: ContractDeployer; + /** + * Contract bytecode. It can be generated via `pnpm fuels typegen`. + */ + bytecode: BytesLike; + /** + * Options for contract deployment taken from `ContractFactory`. + */ + options?: DeployContractOptions; + /** + * Index of wallet to be used for deployment. Defaults to `0` (first wallet). + */ + walletIndex?: number; +} + +interface LaunchTestNodeOptions + extends LaunchCustomProviderAndGetWalletsOptions { + /** + * Pass in either the path to the contract's root directory to deploy the contract or use `DeployContractConfig` for more control. + */ + contractsConfigs: TContractConfigs; +} +type TContracts = { + [K in keyof T]: Awaited>; +}; +interface LaunchTestNodeReturn + extends SetupTestProviderAndWalletsReturn { + contracts: TContracts; +} + +function getChainSnapshot( + nodeOptions: LaunchTestNodeOptions['nodeOptions'] +) { + let envChainMetadata: SnapshotConfigs['metadata'] | undefined; + let chainConfig: SnapshotConfigs['chainConfig'] | undefined; + let stateConfig: SnapshotConfigs['stateConfig'] | undefined; + + if (process.env.DEFAULT_CHAIN_SNAPSHOT_DIR) { + const dirname = process.env.DEFAULT_CHAIN_SNAPSHOT_DIR; + + envChainMetadata = JSON.parse( + readFileSync(path.join(dirname, 'metadata.json'), 'utf-8') + ) as SnapshotConfigs['metadata']; + + chainConfig = JSON.parse( + readFileSync(path.join(dirname, envChainMetadata.chain_config), 'utf-8') + ); + + stateConfig = JSON.parse( + readFileSync(path.join(dirname, envChainMetadata.table_encoding.Json.filepath), 'utf-8') + ); + } + + const obj = [envChainMetadata, chainConfig, stateConfig].reduce((acc, val, idx) => { + if (val === undefined) { + return acc; + } + switch (idx) { + case 0: + acc.metadata = val as SnapshotConfigs['metadata']; + break; + case 1: + acc.chainConfig = val as SnapshotConfigs['chainConfig']; + break; + case 2: + acc.stateConfig = val as SnapshotConfigs['stateConfig']; + break; + default: + return acc; + } + return acc; + }, {} as SnapshotConfigs); + + return mergeDeepRight(obj, nodeOptions?.snapshotConfig ?? {}); +} + +function getFuelCoreArgs( + nodeOptions: LaunchTestNodeOptions['nodeOptions'] +) { + const envArgs = process.env.DEFAULT_FUEL_CORE_ARGS + ? process.env.DEFAULT_FUEL_CORE_ARGS.split(' ') + : undefined; + + return nodeOptions?.args ?? envArgs; +} + +function getWalletForDeployment(config: DeployContractConfig, wallets: WalletUnlocked[]) { + if (!config.walletIndex) { + return wallets[0]; + } + + const validWalletIndex = config.walletIndex >= 0 && config.walletIndex < wallets.length; + + if (!validWalletIndex) { + throw new FuelError( + FuelError.CODES.INVALID_INPUT_PARAMETERS, + `Invalid walletIndex ${config.walletIndex}; wallets array contains ${wallets.length} elements.` + ); + } + + return wallets[config.walletIndex]; +} + +export async function launchTestNode({ + providerOptions = {}, + walletsConfig = {}, + nodeOptions = {}, + contractsConfigs, +}: Partial> = {}): Promise> { + const snapshotConfig = getChainSnapshot(nodeOptions); + const args = getFuelCoreArgs(nodeOptions); + const { provider, wallets, cleanup } = await setupTestProviderAndWallets({ + walletsConfig, + providerOptions, + nodeOptions: { + ...nodeOptions, + snapshotConfig, + args, + }, + }); + + const contracts: TContracts = [] as TContracts; + const configs = contractsConfigs ?? []; + try { + for (let i = 0; i < configs.length; i++) { + const config = configs[i]; + contracts.push( + await config.deployer.deployContract( + config.bytecode, + getWalletForDeployment(config, wallets), + config.options ?? {} + ) + ); + } + } catch (err) { + cleanup(); + throw err; + } + return { + provider, + wallets, + contracts, + cleanup, + [Symbol.dispose]: cleanup, + }; +} diff --git a/packages/contract/test/fixtures/forc-projects/Forc.toml b/packages/contract/test/fixtures/forc-projects/Forc.toml new file mode 100644 index 00000000000..e7a5b7d4e3c --- /dev/null +++ b/packages/contract/test/fixtures/forc-projects/Forc.toml @@ -0,0 +1,2 @@ +[workspace] +members = ["simple-contract"] diff --git a/packages/contract/test/fixtures/forc-projects/simple-contract/.gitignore b/packages/contract/test/fixtures/forc-projects/simple-contract/.gitignore new file mode 100644 index 00000000000..77d3844f58c --- /dev/null +++ b/packages/contract/test/fixtures/forc-projects/simple-contract/.gitignore @@ -0,0 +1,2 @@ +out +target diff --git a/packages/contract/test/fixtures/forc-projects/simple-contract/Forc.toml b/packages/contract/test/fixtures/forc-projects/simple-contract/Forc.toml new file mode 100644 index 00000000000..205538fcce0 --- /dev/null +++ b/packages/contract/test/fixtures/forc-projects/simple-contract/Forc.toml @@ -0,0 +1,7 @@ +[project] +authors = ["Fuel Labs "] +entry = "main.sw" +license = "Apache-2.0" +name = "simple-contract" + +[dependencies] diff --git a/packages/contract/test/fixtures/forc-projects/simple-contract/src/main.sw b/packages/contract/test/fixtures/forc-projects/simple-contract/src/main.sw new file mode 100644 index 00000000000..7d4a75493c6 --- /dev/null +++ b/packages/contract/test/fixtures/forc-projects/simple-contract/src/main.sw @@ -0,0 +1,11 @@ +contract; + +abi MyContract { + fn test_function() -> bool; +} + +impl MyContract for Contract { + fn test_function() -> bool { + true + } +} diff --git a/packages/contract/tsup.config.ts b/packages/contract/tsup.config.ts index 4c7f2f0354f..4972257d818 100644 --- a/packages/contract/tsup.config.ts +++ b/packages/contract/tsup.config.ts @@ -1,3 +1,12 @@ -import { index } from '@internal/tsup'; +import { tsupDefaults } from '@internal/tsup'; +import type { Options } from 'tsup'; -export default index; +const configs: Options = { + ...tsupDefaults, + entry: { + index: 'src/index.ts', + 'test-utils': 'src/test-utils.ts', + }, +}; + +export default configs; diff --git a/packages/fuel-gauge/src/multi-token-contract.test.ts b/packages/fuel-gauge/src/multi-token-contract.test.ts index 3432d8c829e..89cbef5c696 100644 --- a/packages/fuel-gauge/src/multi-token-contract.test.ts +++ b/packages/fuel-gauge/src/multi-token-contract.test.ts @@ -1,23 +1,9 @@ -import { generateTestWallet } from '@fuel-ts/account/test-utils'; import type { BN } from 'fuels'; -import { Provider, Wallet, ContractFactory, bn, FUEL_NETWORK_URL } from 'fuels'; +import { Wallet, bn } from 'fuels'; +import { launchTestNode } from 'fuels/test-utils'; -import { FuelGaugeProjectsEnum, getFuelGaugeForcProject } from '../test/fixtures'; - -const setup = async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); - const baseAssetId = provider.getBaseAssetId(); - // Create wallet - const wallet = await generateTestWallet(provider, [[500_000, baseAssetId]]); - - const { binHexlified: bytecode, abiContents: abi } = getFuelGaugeForcProject( - FuelGaugeProjectsEnum.MULTI_TOKEN_CONTRACT - ); - - const factory = new ContractFactory(bytecode, abi, wallet); - const contract = await factory.deployContract(); - return contract; -}; +import { MultiTokenContractAbi__factory } from '../test/typegen/contracts'; +import binHexlified from '../test/typegen/contracts/MultiTokenContractAbi.hex'; // hardcoded subIds on MultiTokenContract const subIds = [ @@ -33,10 +19,21 @@ describe('MultiTokenContract', () => { it( 'can mint and transfer coins', async () => { - const provider = await Provider.create(FUEL_NETWORK_URL); + using launched = await launchTestNode({ + contractsConfigs: [ + { + deployer: MultiTokenContractAbi__factory, + bytecode: binHexlified, + }, + ], + }); + const { + provider, + contracts: [multiTokenContract], + } = launched; // New wallet to transfer coins and check balance const userWallet = Wallet.generate({ provider }); - const multiTokenContract = await setup(); + const contractId = { bits: multiTokenContract.id.toB256() }; const helperDict: { [key: string]: { assetId: string; amount: number } } = { @@ -116,7 +113,17 @@ describe('MultiTokenContract', () => { ); it('can burn coins', async () => { - const multiTokenContract = await setup(); + using launched = await launchTestNode({ + contractsConfigs: [ + { + deployer: MultiTokenContractAbi__factory, + bytecode: binHexlified, + }, + ], + }); + const { + contracts: [multiTokenContract], + } = launched; const contractId = { bits: multiTokenContract.id.toB256() }; const helperDict: { diff --git a/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts b/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts index 0f3a21a574e..b42419d71eb 100644 --- a/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts +++ b/packages/fuel-gauge/src/predicate/predicate-evaluations.test.ts @@ -1,47 +1,43 @@ -import type { InputValue, Provider, WalletLocked, WalletUnlocked } from 'fuels'; -import { Predicate } from 'fuels'; +import { Address, Wallet } from 'fuels'; +import { launchTestNode } from 'fuels/test-utils'; -import { FuelGaugeProjectsEnum, getFuelGaugeForcProject } from '../../test/fixtures'; +import { + PredicateTrueAbi__factory, + PredicateFalseAbi__factory, +} from '../../test/typegen/predicates'; -import { setupWallets, assertBalances, fundPredicate } from './utils/predicate'; +import { assertBalances, fundPredicate } from './utils/predicate'; /** * @group node */ describe('Predicate', () => { - const { binHexlified: predicateBytesTrue } = getFuelGaugeForcProject( - FuelGaugeProjectsEnum.PREDICATE_TRUE - ); - - const { binHexlified: predicateBytesFalse } = getFuelGaugeForcProject( - FuelGaugeProjectsEnum.PREDICATE_FALSE - ); - describe('Evaluations', () => { - let predicate: Predicate; - let wallet: WalletUnlocked; - let receiver: WalletLocked; - let provider: Provider; - let baseAssetId: string; - - beforeEach(async () => { - [wallet, receiver] = await setupWallets(); - provider = wallet.provider; - baseAssetId = provider.getBaseAssetId(); - }); - it('calls a no argument predicate and returns true', async () => { - const amountToReceiver = 50; - const initialReceiverBalance = await receiver.getBalance(); + using launched = await launchTestNode(); - predicate = new Predicate({ - bytecode: predicateBytesTrue, + const { + wallets: [wallet], provider, - }); + } = launched; + + const receiver = Wallet.fromAddress(Address.fromRandom(), provider); + const initialReceiverBalance = await receiver.getBalance(); - const tx = await predicate.transfer(receiver.address, amountToReceiver, baseAssetId, { - gasLimit: 1000, - }); + const predicate = PredicateTrueAbi__factory.createInstance(provider); + + await fundPredicate(wallet, predicate, 200_000); + + const amountToReceiver = 50; + + const tx = await predicate.transfer( + receiver.address, + amountToReceiver, + provider.getBaseAssetId(), + { + gasLimit: 1000, + } + ); const { isStatusSuccess } = await tx.waitForResult(); await assertBalances(receiver, initialReceiverBalance, amountToReceiver); @@ -50,18 +46,21 @@ describe('Predicate', () => { }); it('calls a no argument predicate and returns false', async () => { - const amountToPredicate = 200_000; - const amountToReceiver = 50; + using launched = await launchTestNode(); - predicate = new Predicate({ - bytecode: predicateBytesFalse, + const { + wallets: [wallet], provider, - }); + } = launched; + + const receiver = Wallet.fromAddress(Address.fromRandom(), provider); + + const predicate = PredicateFalseAbi__factory.createInstance(provider); - await fundPredicate(wallet, predicate, amountToPredicate); + await fundPredicate(wallet, predicate, 200_000); await expect( - predicate.transfer(receiver.address, amountToReceiver, baseAssetId, { + predicate.transfer(receiver.address, 50, provider.getBaseAssetId(), { gasLimit: 1000, }) ).rejects.toThrow('PredicateVerificationFailed'); diff --git a/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts b/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts index dbd373bcb35..32965c971dd 100644 --- a/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts +++ b/packages/fuel-gauge/src/predicate/predicate-with-contract.test.ts @@ -1,102 +1,66 @@ -import { generateTestWallet, seedTestWallet } from '@fuel-ts/account/test-utils'; -import type { Account, BN, CoinQuantity, InputCoin, WalletUnlocked } from 'fuels'; +import { Contract, Wallet } from 'fuels'; +import { launchTestNode } from 'fuels/test-utils'; + +import { + CallTestContractAbi__factory, + TokenContractAbi__factory, +} from '../../test/typegen/contracts'; +import contractBytes from '../../test/typegen/contracts/CallTestContractAbi.hex'; +import tokenPoolBytes from '../../test/typegen/contracts/TokenContractAbi.hex'; import { - ContractFactory, - toNumber, - Contract, - Provider, - Predicate, - FUEL_NETWORK_URL, - bn, - InputType, - Wallet, -} from 'fuels'; - -import { FuelGaugeProjectsEnum, getFuelGaugeForcProject } from '../../test/fixtures'; -import type { Validation } from '../types/predicate'; - -import { fundPredicate, setupContractWithConfig } from './utils/predicate'; + PredicateMainArgsStructAbi__factory, + PredicateTrueAbi__factory, +} from '../../test/typegen/predicates'; + +import { fundPredicate } from './utils/predicate'; /** * @group node */ describe('Predicate', () => { - const { binHexlified: contractBytes, abiContents: contractAbi } = getFuelGaugeForcProject( - FuelGaugeProjectsEnum.CALL_TEST_CONTRACT - ); - const { binHexlified: tokenPoolBytes, abiContents: tokenPoolAbi } = getFuelGaugeForcProject( - FuelGaugeProjectsEnum.TOKEN_CONTRACT - ); - - const { binHexlified: predicateSum, abiContents: predicateAbiSum } = getFuelGaugeForcProject( - FuelGaugeProjectsEnum.PREDICATE_SUM - ); - - const { binHexlified: predicateBytesTrue, abiContents: predicateAbiTrue } = - getFuelGaugeForcProject(FuelGaugeProjectsEnum.PREDICATE_TRUE); - - const { binHexlified: complexPredicateBytes, abiContents: complexPredicateAbi } = - getFuelGaugeForcProject(FuelGaugeProjectsEnum.COMPLEX_PREDICATE); - describe('With Contract', () => { - let wallet: WalletUnlocked; - let receiver: WalletUnlocked; - let provider: Provider; - let baseAssetId: string; - beforeAll(async () => { - provider = await Provider.create(FUEL_NETWORK_URL); - baseAssetId = provider.getBaseAssetId(); - }); - - beforeEach(async () => { - wallet = await generateTestWallet(provider, [[2_000_000, baseAssetId]]); - receiver = await generateTestWallet(provider); - }); - it('calls a predicate from a contract function', async () => { - const testWallet = Wallet.generate({ provider }); + using launched = await launchTestNode({ + contractsConfigs: [{ deployer: CallTestContractAbi__factory, bytecode: contractBytes }], + }); - await seedTestWallet(testWallet, [[1_000_000, baseAssetId]]); + const { + contracts: [contract], + provider, + wallets: [wallet], + } = launched; - const factory = new ContractFactory(contractBytes, contractAbi, testWallet); - const contract = await factory.deployContract(); + const amountToPredicate = 300_000; + const predicate = PredicateTrueAbi__factory.createInstance(provider); - const amountToPredicate = 500_000; - const predicate = new Predicate<[BN, BN]>({ - bytecode: predicateSum, - abi: predicateAbiSum, - provider, - inputData: [bn(1337), bn(0)], - }); // Create a instance of the contract with the predicate as the caller Account const contractPredicate = new Contract(contract.id, contract.interface, predicate); - await fundPredicate(testWallet, predicate, amountToPredicate); + await fundPredicate(wallet, predicate, amountToPredicate); - const { - value, - transactionResult: { - transaction: { witnesses }, - isStatusSuccess, - }, - } = await contractPredicate.functions + const { value, transactionResult } = await contractPredicate.functions .return_context_amount() .callParams({ - forward: [500, baseAssetId], + forward: [500, provider.getBaseAssetId()], }) .call(); - // not witnesses entry were added to Transaction witnesses - expect(witnesses?.length).toBe(0); expect(value.toString()).toEqual('500'); - expect(isStatusSuccess).toBeTruthy(); + expect(transactionResult.isStatusSuccess).toBeTruthy(); }); it('calls a predicate and uses proceeds for a contract call', async () => { - const contract = await new ContractFactory( - tokenPoolBytes, - tokenPoolAbi, - wallet - ).deployContract(); + using launched = await launchTestNode({ + contractsConfigs: [{ deployer: TokenContractAbi__factory, bytecode: tokenPoolBytes }], + }); + + const { + contracts: [contract], + provider, + wallets: [wallet], + } = launched; + + const receiver = Wallet.generate({ provider }); + const receiverInitialBalance = await receiver.getBalance(); // calling the contract with the receiver account (no resources) contract.account = receiver; @@ -105,100 +69,34 @@ describe('Predicate', () => { ); // setup predicate - const amountToPredicate = 700_000; + const amountToPredicate = 1_000_000; const amountToReceiver = 200_000; - const predicate = new Predicate<[Validation]>({ - bytecode: predicateBytesTrue, - provider, - abi: predicateAbiTrue, - }); - const initialPredicateBalance = toNumber(await predicate.getBalance()); + const predicate = PredicateMainArgsStructAbi__factory.createInstance(provider, [ + { + has_account: true, + total_complete: 100, + }, + ]); await fundPredicate(wallet, predicate, amountToPredicate); - expect(toNumber(await predicate.getBalance())).toEqual( - initialPredicateBalance + amountToPredicate - ); - // executing predicate to transfer resources to receiver - const tx = await predicate.transfer(receiver.address, amountToReceiver, baseAssetId); - const { isStatusSuccess } = await tx.waitForResult(); + const tx = await predicate.transfer( + receiver.address, + amountToReceiver, + provider.getBaseAssetId() + ); + let { isStatusSuccess } = await tx.waitForResult(); expect(isStatusSuccess).toBeTruthy(); - // calling the contract with the receiver account (with resources) - const { transactionResult: contractCallResult } = await contract.functions - .mint_coins(200) - .call(); - - expect(contractCallResult.isStatusSuccess).toBeTruthy(); - }); - - it('executes a contract call with a predicate to ensure not extra witnesses are added', async () => { - const setupContract = setupContractWithConfig({ - contractBytecode: contractBytes, - abi: contractAbi, - cache: true, - }); + const receiverFinalBalance = await receiver.getBalance(); + expect(receiverFinalBalance.gt(receiverInitialBalance)).toBeTruthy(); - const predicate = new Predicate<[BN]>({ - bytecode: complexPredicateBytes, - abi: complexPredicateAbi, - provider, - inputData: [bn(10)], - }); - - await fundPredicate(wallet, predicate, 5000); - - const contract = await setupContract(); - const forward: CoinQuantity = { amount: bn(500), assetId: baseAssetId }; - - const request = await contract.functions - .return_context_amount() - .callParams({ - forward, - }) - .getTransactionRequest(); + ({ + transactionResult: { isStatusSuccess }, + } = await contract.functions.mint_coins(200).call()); - const sender = contract.account as Account; - - // Adding any amount of resources from the sender to ensure its witness index will be 0 - const senderResources = await sender.getResourcesToSpend([[1, baseAssetId]]); - request.addResources(senderResources); - - // Any amount of the predicate will do as it is not going to pay for the fee - const predicateResources = await predicate.getResourcesToSpend([[1, baseAssetId]]); - request.addResources(predicateResources); - - const txCost = await provider.getTransactionCost(request, { - quantitiesToContract: [forward], - }); - - request.gasLimit = txCost.gasUsed; - request.maxFee = txCost.maxFee; - - // Properly funding the TX to ensure the fee was covered - await sender?.fund(request, txCost); - - const tx = await sender?.sendTransaction(request); - - const { - transaction: { witnesses, inputs }, - } = await tx.waitForResult(); - - const predicateAddress = predicate.address.toB256(); - const predicateInputs = inputs?.filter( - (input) => input.type === InputType.Coin && input.owner === predicateAddress - ); - - // It ensures a predicate witness index is set to 0 - expect(predicateInputs?.length).toBe(1); - expect((predicateInputs?.[0]).witnessIndex).toBe(0); - - /** - * TX should have only one witness entry which is from the sender as a predicate should - * not add witnesses entries - */ - expect(witnesses?.length).toBe(1); + expect(isStatusSuccess).toBeTruthy(); }); }); }); diff --git a/packages/fuel-gauge/src/utils.ts b/packages/fuel-gauge/src/utils.ts index ecf09da7127..2b3dd9bdae7 100644 --- a/packages/fuel-gauge/src/utils.ts +++ b/packages/fuel-gauge/src/utils.ts @@ -87,3 +87,6 @@ export const getScript = ( wallet ) ); + +export const getProgramDir = (name: string) => + join(__dirname, `../test/fixtures/forc-projects/${name}`); diff --git a/packages/fuels/package.json b/packages/fuels/package.json index bb8b9a2be32..5beeacd1f5e 100644 --- a/packages/fuels/package.json +++ b/packages/fuels/package.json @@ -22,12 +22,20 @@ "require": "./dist/cli.js", "import": "./dist/cli.mjs", "types": "./dist/cli.d.ts" + }, + "./test-utils": { + "require": "./dist/test-utils.js", + "import": "./dist/test-utils.mjs", + "types": "./dist/test-utils.d.ts" } }, "typesVersions": { "*": { "cli": [ "./dist/cli.d.ts" + ], + "test-utils": [ + "./dist/test-utils.d.ts" ] } }, diff --git a/packages/fuels/src/test-utils.ts b/packages/fuels/src/test-utils.ts new file mode 100644 index 00000000000..4d63de50ed5 --- /dev/null +++ b/packages/fuels/src/test-utils.ts @@ -0,0 +1,2 @@ +export * from '@fuel-ts/contract/test-utils'; +export * from '@fuel-ts/account/test-utils'; diff --git a/packages/fuels/tsup.config.ts b/packages/fuels/tsup.config.ts index 572b1ff00d6..7d2b3788867 100644 --- a/packages/fuels/tsup.config.ts +++ b/packages/fuels/tsup.config.ts @@ -3,6 +3,10 @@ import type { Options } from 'tsup'; const options: Options = { ...indexBinAndCliConfig, + entry: { + ...indexBinAndCliConfig.entry, + 'test-utils': 'src/test-utils.ts', + }, loader: { '.hbs': 'text', }, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index 2bbc8d4ad25..ba483c283e7 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -5,6 +5,8 @@ export * from './utils/arrayify'; export * from './utils/hexlify'; export * from './utils/normalizeString'; export * from './utils/date-time'; +export * from './utils/types'; +export * from './utils/sleep'; export * from './utils/defaultSnapshotConfigs'; export * from './utils/isDefined'; export * from './utils/base58'; diff --git a/packages/utils/src/test-utils.ts b/packages/utils/src/test-utils.ts index fdf17afa727..bfad80a17ed 100644 --- a/packages/utils/src/test-utils.ts +++ b/packages/utils/src/test-utils.ts @@ -1,3 +1,4 @@ export * from './test-utils/getForcProject'; export * from './test-utils/expectToBeInRange'; export * from './test-utils/constants'; +export * from './test-utils/wait-until-unreachable'; diff --git a/packages/utils/src/test-utils/wait-until-unreachable.test.ts b/packages/utils/src/test-utils/wait-until-unreachable.test.ts new file mode 100644 index 00000000000..37e30985041 --- /dev/null +++ b/packages/utils/src/test-utils/wait-until-unreachable.test.ts @@ -0,0 +1,41 @@ +import { waitUntilUnreachable } from './wait-until-unreachable'; + +/** + * @group node + */ +describe('waitUntilUnreachable', () => { + afterEach(() => { + vi.restoreAllMocks(); + }); + + test('waits until the url is unreachable', async () => { + const fetchSpy = vi + .spyOn(global, 'fetch') + .mockImplementationOnce(() => Promise.resolve(new Response())) + .mockImplementationOnce(() => Promise.resolve(new Response())) + + .mockImplementationOnce(() => { + throw new Error('mocked'); + }); + const url = 'mocked'; + + await waitUntilUnreachable(url); + + expect(fetchSpy).toHaveBeenCalledTimes(3); + expect(fetchSpy).toHaveBeenNthCalledWith(1, url); + expect(fetchSpy).toHaveBeenNthCalledWith(2, url); + expect(fetchSpy).toHaveBeenNthCalledWith(3, url); + }); + + test.fails( + 'times out test if url is live', + async () => { + vi.spyOn(global, 'fetch').mockImplementation(() => Promise.resolve(new Response())); + + const url = 'mocked'; + + await waitUntilUnreachable(url); + }, + { timeout: 2000 } + ); +}); diff --git a/packages/utils/src/test-utils/wait-until-unreachable.ts b/packages/utils/src/test-utils/wait-until-unreachable.ts new file mode 100644 index 00000000000..c7b80b9a35c --- /dev/null +++ b/packages/utils/src/test-utils/wait-until-unreachable.ts @@ -0,0 +1,22 @@ +import { sleep } from '../utils/sleep'; + +/** + * @hidden + * + * Waits until the given URL is unreachable. Used in tests to verify that fuel-core is no longer running. + */ +export async function waitUntilUnreachable(url: string) { + let isLive; + try { + await fetch(url); + isLive = true; + } catch (e) { + isLive = false; + } + if (!isLive) { + return; + } + + await sleep(250); + await waitUntilUnreachable(url); +} diff --git a/packages/utils/src/utils/defaultSnapshotConfigs.ts b/packages/utils/src/utils/defaultSnapshotConfigs.ts index bdfc47c1e58..5d362e1b46d 100644 --- a/packages/utils/src/utils/defaultSnapshotConfigs.ts +++ b/packages/utils/src/utils/defaultSnapshotConfigs.ts @@ -1,11 +1,12 @@ import chainConfigJson from './defaultSnapshots/chainConfig.json'; import metadataJson from './defaultSnapshots/metadata.json'; import stateConfigJson from './defaultSnapshots/stateConfig.json'; +import type { SnapshotConfigs } from './types'; -export const defaultSnapshotConfigs = { - chainConfigJson, - metadataJson, - stateConfigJson, +export const defaultSnapshotConfigs: SnapshotConfigs = { + chainConfig: chainConfigJson, + metadata: metadataJson, + stateConfig: stateConfigJson, }; export const defaultConsensusKey = diff --git a/packages/account/src/providers/utils/sleep.ts b/packages/utils/src/utils/sleep.ts similarity index 90% rename from packages/account/src/providers/utils/sleep.ts rename to packages/utils/src/utils/sleep.ts index 2448422a0f6..e15fd57eca4 100644 --- a/packages/account/src/providers/utils/sleep.ts +++ b/packages/utils/src/utils/sleep.ts @@ -1,4 +1,3 @@ -/** @hidden */ export function sleep(time: number) { return new Promise((resolve) => { setTimeout(() => { diff --git a/packages/utils/src/utils/types.ts b/packages/utils/src/utils/types.ts new file mode 100644 index 00000000000..c0cef584131 --- /dev/null +++ b/packages/utils/src/utils/types.ts @@ -0,0 +1,222 @@ +interface Coin { + tx_id: string; + output_index: number; + tx_pointer_block_height: number; + tx_pointer_tx_idx: number; + owner: string; + amount: number; + asset_id: string; +} + +interface Message { + sender: string; + recipient: string; + nonce: string; + amount: number; + data: string; + da_height: number; +} + +interface StateConfig { + coins: Coin[]; + messages: Message[]; +} + +type Operation = + | { + LightOperation: { + base: number; + units_per_gas: number; + }; + } + | { + HeavyOperation: { + base: number; + gas_per_unit: number; + }; + }; + +interface GasCosts { + mod_op: number; + move_op: number; + ret: number; + rvrt: number; + retd: Operation; + add: number; + addi: number; + aloc: number; + and: number; + andi: number; + bal: number; + bhei: number; + bhsh: number; + burn: number; + cb: number; + cfei: number; + cfsi: number; + croo: Operation; + div: number; + divi: number; + ecr1: number; + eck1: number; + poph: number; + popl: number; + pshh: number; + pshl: number; + ed19: number; + eq: number; + exp: number; + expi: number; + flag: number; + gm: number; + gt: number; + gtf: number; + ji: number; + jmp: number; + jne: number; + jnei: number; + jnzi: number; + jmpf: number; + jmpb: number; + jnzf: number; + jnzb: number; + jnef: number; + jneb: number; + k256: Operation; + lb: number; + log: number; + lt: number; + lw: number; + mint: number; + mlog: number; + modi: number; + movi: number; + mroo: number; + mul: number; + muli: number; + mldv: number; + noop: number; + not: number; + or: number; + ori: number; + s256: Operation; + sb: number; + scwq: Operation; + sll: number; + slli: number; + srl: number; + srli: number; + srw: number; + sub: number; + subi: number; + sw: number; + sww: number; + swwq: Operation; + time: number; + tr: number; + tro: number; + wdcm: number; + wqcm: number; + wdop: number; + wqop: number; + wdml: number; + wqml: number; + wddv: number; + wqdv: number; + wdmd: number; + wqmd: number; + wdam: number; + wqam: number; + wdmm: number; + wqmm: number; + xor: number; + xori: number; + new_storage_per_byte: number; + contract_root: Operation; + state_root: Operation; + vm_initialization: Operation; + call: Operation; + mcpi: Operation; + ccp: Operation; + csiz: Operation; + ldc: Operation; + logd: Operation; + mcl: Operation; + mcli: Operation; + mcp: Operation; + meq: Operation; + smo: Operation; + srwq: Operation; +} + +interface Consensus { + PoA: { + signing_key: string; + }; +} + +interface ConsensusParameters { + chain_id: number; + base_asset_id: string; + privileged_address: string; + tx_params: { + V1: { + max_inputs: number; + max_outputs: number; + max_witnesses: number; + max_gas_per_tx: number; + max_size: number; + }; + }; + predicate_params: { + V1: { + max_predicate_length: number; + max_predicate_data_length: number; + max_gas_per_predicate: number; + max_message_data_length: number; + }; + }; + script_params: { + V1: { + max_script_length: number; + max_script_data_length: number; + }; + }; + contract_params: { + V1: { + contract_max_size: number; + max_storage_slots: number; + }; + }; + fee_params: { + V1: { + gas_price_factor: number; + gas_per_byte: number; + }; + }; + block_gas_limit: number; + gas_costs: { V1: GasCosts }; +} + +interface ChainConfig { + chain_name: string; + consensus_parameters: { + V1: ConsensusParameters; + }; + consensus: Consensus; +} + +interface MetadataConfig { + chain_config: string; + table_encoding: { + Json: { + filepath: string; + }; + }; +} + +export interface SnapshotConfigs { + stateConfig: StateConfig; + chainConfig: ChainConfig; + metadata: MetadataConfig; +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 545f7e7854a..4a263e1dab3 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -750,6 +750,9 @@ importers: get-graphql-schema: specifier: ^2.1.2 version: 2.1.2 + type-fest: + specifier: ^4.6.0 + version: 4.19.0 packages/address: dependencies: @@ -804,6 +807,19 @@ importers: '@fuel-ts/utils': specifier: workspace:* version: link:../utils + '@fuel-ts/versions': + specifier: workspace:* + version: link:../versions + ramda: + specifier: ^0.29.0 + version: 0.29.0 + devDependencies: + '@internal/forc': + specifier: workspace:* + version: link:../../internal/forc + '@types/ramda': + specifier: ^0.29.3 + version: 0.29.3 packages/create-fuels: dependencies: @@ -13223,6 +13239,10 @@ packages: resolution: {integrity: sha512-StmrZmK3eD9mDF9Vt7UhqthrDSk66O9iYl5t5a0TSoVkHjl0XZx/xuc/BRz4urAXXGHOY5OLsE0RdJFIApSFmw==} engines: {node: '>=14.16'} + type-fest@4.19.0: + resolution: {integrity: sha512-CN2l+hWACRiejlnr68vY0/7734Kzu+9+TOslUXbSCQ1ruY9XIHDBSceVXCcHm/oXrdzhtLMMdJEKfemf1yXiZQ==} + engines: {node: '>=16'} + type-is@1.6.18: resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} engines: {node: '>= 0.6'} @@ -30580,6 +30600,8 @@ snapshots: type-fest@3.1.0: {} + type-fest@4.19.0: {} + type-is@1.6.18: dependencies: media-typer: 0.3.0 diff --git a/tsconfig.base.json b/tsconfig.base.json index 09debdaee95..a0124c00208 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -4,7 +4,7 @@ "declaration": true, "declarationMap": true, "esModuleInterop": true, - "lib": ["ES2022"], + "lib": ["ES2022", "ESNext.disposable"], "module": "CommonJS", "moduleResolution": "node", "resolveJsonModule": true, diff --git a/vitest.shared.config.mts b/vitest.shared.config.mts index 08ba4406074..6635fa35d93 100644 --- a/vitest.shared.config.mts +++ b/vitest.shared.config.mts @@ -10,6 +10,7 @@ export default defineConfig({ namedExport: false, }), ], + esbuild: { target: "es2022" }, test: { coverage: { enabled: true,