diff --git a/aptos-move/move-examples/cli-e2e-tests/README.md b/aptos-move/move-examples/cli-e2e-tests/README.md new file mode 100644 index 0000000000000..8929f80118e3b --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/README.md @@ -0,0 +1,4 @@ +# CLI E2E tests +These packages, one per production network, are used by the CLI E2E tests to test the correctness of the `aptos move` subcommand group. As such there is no particular rhyme or reason to what goes into these, it is meant to be an expressive selection of different, new features we might want to assert. + +As it is now the 3 packages share the same source code. Down the line we might want to use these tests to confirm that the CLI works with a new feature as it lands in devnet, then testnet, then mainnet. For that we'd need to separate the source. diff --git a/aptos-move/move-examples/cli-e2e-tests/common/sources/cli_e2e_tests.move b/aptos-move/move-examples/cli-e2e-tests/common/sources/cli_e2e_tests.move new file mode 100644 index 0000000000000..613056d72f1be --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/common/sources/cli_e2e_tests.move @@ -0,0 +1,327 @@ +module addr::cli_e2e_tests { + use std::error; + use std::option::{Self, Option}; + use std::signer; + use std::string::{Self, String}; + + use aptos_framework::object::{Self, ConstructorRef, Object}; + + use aptos_token_objects::collection; + use aptos_token_objects::token; + use aptos_std::string_utils; + + const ENOT_A_HERO: u64 = 1; + const ENOT_A_WEAPON: u64 = 2; + const ENOT_A_GEM: u64 = 3; + const ENOT_CREATOR: u64 = 4; + const EINVALID_WEAPON_UNEQUIP: u64 = 5; + const EINVALID_GEM_UNEQUIP: u64 = 6; + const EINVALID_TYPE: u64 = 7; + + struct OnChainConfig has key { + collection: String, + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct Hero has key { + armor: Option>, + gender: String, + race: String, + shield: Option>, + weapon: Option>, + mutator_ref: token::MutatorRef, + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct Armor has key { + defense: u64, + gem: Option>, + weight: u64, + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct Gem has key { + attack_modifier: u64, + defense_modifier: u64, + magic_attribute: String, + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct Shield has key { + defense: u64, + gem: Option>, + weight: u64, + } + + #[resource_group_member(group = aptos_framework::object::ObjectGroup)] + struct Weapon has key { + attack: u64, + gem: Option>, + weapon_type: String, + weight: u64, + } + + fun init_module(account: &signer) { + let collection = string::utf8(b"Hero Quest!"); + collection::create_unlimited_collection( + account, + string::utf8(b"collection description"), + collection, + option::none(), + string::utf8(b"collection uri"), + ); + + let on_chain_config = OnChainConfig { + collection: string::utf8(b"Hero Quest!"), + }; + move_to(account, on_chain_config); + } + + fun create( + creator: &signer, + description: String, + name: String, + uri: String, + ): ConstructorRef acquires OnChainConfig { + let on_chain_config = borrow_global(signer::address_of(creator)); + token::create_named_token( + creator, + on_chain_config.collection, + description, + name, + option::none(), + uri, + ) + } + + // Creation methods + + public fun create_hero( + creator: &signer, + description: String, + gender: String, + name: String, + race: String, + uri: String, + ): Object acquires OnChainConfig { + let constructor_ref = create(creator, description, name, uri); + let token_signer = object::generate_signer(&constructor_ref); + + let hero = Hero { + armor: option::none(), + gender, + race, + shield: option::none(), + weapon: option::none(), + mutator_ref: token::generate_mutator_ref(&constructor_ref), + }; + move_to(&token_signer, hero); + + object::address_to_object(signer::address_of(&token_signer)) + } + + public fun create_weapon( + creator: &signer, + attack: u64, + description: String, + name: String, + uri: String, + weapon_type: String, + weight: u64, + ): Object acquires OnChainConfig { + let constructor_ref = create(creator, description, name, uri); + let token_signer = object::generate_signer(&constructor_ref); + + let weapon = Weapon { + attack, + gem: option::none(), + weapon_type, + weight, + }; + move_to(&token_signer, weapon); + + object::address_to_object(signer::address_of(&token_signer)) + } + + public fun create_gem( + creator: &signer, + attack_modifier: u64, + defense_modifier: u64, + description: String, + magic_attribute: String, + name: String, + uri: String, + ): Object acquires OnChainConfig { + let constructor_ref = create(creator, description, name, uri); + let token_signer = object::generate_signer(&constructor_ref); + + let gem = Gem { + attack_modifier, + defense_modifier, + magic_attribute, + }; + move_to(&token_signer, gem); + + object::address_to_object(signer::address_of(&token_signer)) + } + + // Transfer wrappers + + public fun hero_equip_weapon(owner: &signer, hero: Object, weapon: Object) acquires Hero { + let hero_obj = borrow_global_mut(object::object_address(&hero)); + option::fill(&mut hero_obj.weapon, weapon); + object::transfer_to_object(owner, weapon, hero); + } + + public fun hero_unequip_weapon(owner: &signer, hero: Object, weapon: Object) acquires Hero { + let hero_obj = borrow_global_mut(object::object_address(&hero)); + let stored_weapon = option::extract(&mut hero_obj.weapon); + assert!(stored_weapon == weapon, error::not_found(EINVALID_WEAPON_UNEQUIP)); + object::transfer(owner, weapon, signer::address_of(owner)); + } + + public fun weapon_equip_gem(owner: &signer, weapon: Object, gem: Object) acquires Weapon { + let weapon_obj = borrow_global_mut(object::object_address(&weapon)); + option::fill(&mut weapon_obj.gem, gem); + object::transfer_to_object(owner, gem, weapon); + } + + public fun weapon_unequip_gem(owner: &signer, weapon: Object, gem: Object) acquires Weapon { + let weapon_obj = borrow_global_mut(object::object_address(&weapon)); + let stored_gem = option::extract(&mut weapon_obj.gem); + assert!(stored_gem == gem, error::not_found(EINVALID_GEM_UNEQUIP)); + object::transfer(owner, gem, signer::address_of(owner)); + } + + // Entry functions + + entry fun mint_hero( + account: &signer, + description: String, + gender: String, + name: String, + race: String, + uri: String, + ) acquires OnChainConfig { + create_hero(account, description, gender, name, race, uri); + } + + entry fun set_hero_description( + creator: &signer, + collection: String, + name: String, + description: String, + ) acquires Hero { + let (hero_obj, hero) = get_hero( + &signer::address_of(creator), + &collection, + &name, + ); + let creator_addr = token::creator(hero_obj); + assert!(creator_addr == signer::address_of(creator), error::permission_denied(ENOT_CREATOR)); + token::set_description(&hero.mutator_ref, description); + } + + // View functions + #[view] + fun view_hero(creator: address, collection: String, name: String): Hero acquires Hero { + let token_address = token::create_token_address( + &creator, + &collection, + &name, + ); + move_from(token_address) + } + + #[view] + fun view_hero_by_object(hero_obj: Object): Hero acquires Hero { + let token_address = object::object_address(&hero_obj); + move_from(token_address) + } + + #[view] + fun view_object(obj: Object): String acquires Armor, Gem, Hero, Shield, Weapon { + let token_address = object::object_address(&obj); + if (exists(token_address)) { + string_utils::to_string(borrow_global(token_address)) + } else if (exists(token_address)) { + string_utils::to_string(borrow_global(token_address)) + } else if (exists(token_address)) { + string_utils::to_string(borrow_global(token_address)) + } else if (exists(token_address)) { + string_utils::to_string(borrow_global(token_address)) + } else if (exists(token_address)) { + string_utils::to_string(borrow_global(token_address)) + } else { + abort EINVALID_TYPE + } + } + + inline fun get_hero(creator: &address, collection: &String, name: &String): (Object, &Hero) { + let token_address = token::create_token_address( + creator, + collection, + name, + ); + (object::address_to_object(token_address), borrow_global(token_address)) + } + + #[test(account = @0x3)] + fun test_hero_with_gem_weapon(account: &signer) acquires Hero, OnChainConfig, Weapon { + init_module(account); + + let hero = create_hero( + account, + string::utf8(b"The best hero ever!"), + string::utf8(b"Male"), + string::utf8(b"Wukong"), + string::utf8(b"Monkey God"), + string::utf8(b""), + ); + + let weapon = create_weapon( + account, + 32, + string::utf8(b"A magical staff!"), + string::utf8(b"Ruyi Jingu Bang"), + string::utf8(b""), + string::utf8(b"staff"), + 15, + ); + + let gem = create_gem( + account, + 32, + 32, + string::utf8(b"Beautiful specimen!"), + string::utf8(b"earth"), + string::utf8(b"jade"), + string::utf8(b""), + ); + + let account_address = signer::address_of(account); + assert!(object::is_owner(hero, account_address), 0); + assert!(object::is_owner(weapon, account_address), 1); + assert!(object::is_owner(gem, account_address), 2); + + hero_equip_weapon(account, hero, weapon); + assert!(object::is_owner(hero, account_address), 3); + assert!(object::is_owner(weapon, object::object_address(&hero)), 4); + assert!(object::is_owner(gem, account_address), 5); + + weapon_equip_gem(account, weapon, gem); + assert!(object::is_owner(hero, account_address), 6); + assert!(object::is_owner(weapon, object::object_address(&hero)), 7); + assert!(object::is_owner(gem, object::object_address(&weapon)), 8); + + hero_unequip_weapon(account, hero, weapon); + assert!(object::is_owner(hero, account_address), 9); + assert!(object::is_owner(weapon, account_address), 10); + assert!(object::is_owner(gem, object::object_address(&weapon)), 11); + + weapon_unequip_gem(account, weapon, gem); + assert!(object::is_owner(hero, account_address), 12); + assert!(object::is_owner(weapon, account_address), 13); + assert!(object::is_owner(gem, account_address), 14); + } +} diff --git a/aptos-move/move-examples/cli-e2e-tests/devnet/Move.toml b/aptos-move/move-examples/cli-e2e-tests/devnet/Move.toml new file mode 100644 index 0000000000000..29db695268495 --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/devnet/Move.toml @@ -0,0 +1,16 @@ +[package] +name = "cli_e2e_tests" +version = "0.0.1" + +[addresses] +addr = "_" + +[dependencies.AptosFramework] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "devnet" +subdir = "aptos-move/framework/aptos-framework" + +[dependencies.AptosTokenObjects] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "devnet" +subdir = "aptos-move/framework/aptos-token-objects" diff --git a/aptos-move/move-examples/cli-e2e-tests/devnet/sources b/aptos-move/move-examples/cli-e2e-tests/devnet/sources new file mode 120000 index 0000000000000..3fcdf678f47c5 --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/devnet/sources @@ -0,0 +1 @@ +../common/sources/ \ No newline at end of file diff --git a/aptos-move/move-examples/cli-e2e-tests/mainnet/Move.toml b/aptos-move/move-examples/cli-e2e-tests/mainnet/Move.toml new file mode 100644 index 0000000000000..b74dc02f72f5a --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/mainnet/Move.toml @@ -0,0 +1,16 @@ +[package] +name = "cli_e2e_tests" +version = "0.0.1" + +[addresses] +addr = "_" + +[dependencies.AptosFramework] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "mainnet" +subdir = "aptos-move/framework/aptos-framework" + +[dependencies.AptosTokenObjects] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "mainnet" +subdir = "aptos-move/framework/aptos-token-objects" diff --git a/aptos-move/move-examples/cli-e2e-tests/mainnet/sources b/aptos-move/move-examples/cli-e2e-tests/mainnet/sources new file mode 120000 index 0000000000000..3fcdf678f47c5 --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/mainnet/sources @@ -0,0 +1 @@ +../common/sources/ \ No newline at end of file diff --git a/aptos-move/move-examples/cli-e2e-tests/testnet/Move.toml b/aptos-move/move-examples/cli-e2e-tests/testnet/Move.toml new file mode 100644 index 0000000000000..0ffe8f1734786 --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/testnet/Move.toml @@ -0,0 +1,16 @@ +[package] +name = "cli_e2e_tests" +version = "0.0.1" + +[addresses] +addr = "_" + +[dependencies.AptosFramework] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "testnet" +subdir = "aptos-move/framework/aptos-framework" + +[dependencies.AptosTokenObjects] +git = "https://github.com/aptos-labs/aptos-core.git" +rev = "testnet" +subdir = "aptos-move/framework/aptos-token-objects" diff --git a/aptos-move/move-examples/cli-e2e-tests/testnet/sources b/aptos-move/move-examples/cli-e2e-tests/testnet/sources new file mode 120000 index 0000000000000..3fcdf678f47c5 --- /dev/null +++ b/aptos-move/move-examples/cli-e2e-tests/testnet/sources @@ -0,0 +1 @@ +../common/sources/ \ No newline at end of file diff --git a/crates/aptos/e2e/README.md b/crates/aptos/e2e/README.md index e0787ee26c9a1..740d6ff7facbe 100644 --- a/crates/aptos/e2e/README.md +++ b/crates/aptos/e2e/README.md @@ -19,9 +19,14 @@ To learn how to use the CLI testing framework, run this: poetry run python main.py -h ``` -For example: +For example, using the CLI from an image: ``` -poetry run python main.py --base-network mainnet --test-cli-tag mainnet +poetry run python main.py --base-network mainnet --test-cli-tag nightly +``` + +Using the CLI from a local path: +``` +poetry run python main.py -d --base-network mainnet --test-cli-path ~/aptos-core/target/debug/aptos ``` ## Debugging diff --git a/crates/aptos/e2e/cases/move.py b/crates/aptos/e2e/cases/move.py new file mode 100644 index 0000000000000..a2b146cbb5ae1 --- /dev/null +++ b/crates/aptos/e2e/cases/move.py @@ -0,0 +1,61 @@ +# Copyright © Aptos Foundation +# SPDX-License-Identifier: Apache-2.0 + +import json + +from common import TestError +from test_helpers import RunHelper +from test_results import test_case + + +@test_case +def test_move_publish(run_helper: RunHelper, test_name=None): + # Prior to this function running the move/ directory was moved into the working + # directory in the host, which is then mounted into the container. The CLI is + # then run in this directory, meaning the move/ directory is in the same directory + # as the CLI is run from. This is why we can just refer to the package dir starting + # with move/ here. + package_dir = f"move/{run_helper.base_network}" + + # Publish the module. + run_helper.run_command( + test_name, + [ + "aptos", + "move", + "publish", + "--assume-yes", + "--package-dir", + package_dir, + "--named-addresses", + f"addr={run_helper.get_account_info().account_address}", + ], + ) + + # Get what modules exist on chain. + response = run_helper.run_command( + test_name, + [ + "aptos", + "account", + "list", + "--account", + run_helper.get_account_info().account_address, + "--query", + "modules", + ], + ) + + # Confirm that the module exists on chain. + response = json.loads(response.stdout) + for module in response["Result"]: + if ( + module["abi"]["address"] + == f"0x{run_helper.get_account_info().account_address}" + and module["abi"]["name"] == "cli_e2e_tests" + ): + return + + raise TestError( + "Module apparently published successfully but it could not be found on chain" + ) diff --git a/crates/aptos/e2e/main.py b/crates/aptos/e2e/main.py index 5403d01473382..d56a552a82af3 100644 --- a/crates/aptos/e2e/main.py +++ b/crates/aptos/e2e/main.py @@ -35,6 +35,7 @@ test_account_lookup_address, ) from cases.init import test_aptos_header_included, test_init, test_metrics_accessible +from cases.move import test_move_publish from common import Network from local_testnet import run_node, stop_node, wait_for_startup from test_helpers import RunHelper @@ -114,6 +115,9 @@ def run_tests(run_helper): # Make sure the aptos-cli header is included on the original request test_aptos_header_included(run_helper) + # Run move subcommand group tests. + test_move_publish(run_helper) + def main(): args = parse_args() diff --git a/crates/aptos/e2e/test_helpers.py b/crates/aptos/e2e/test_helpers.py index b668d2682589b..8ab427d573be3 100644 --- a/crates/aptos/e2e/test_helpers.py +++ b/crates/aptos/e2e/test_helpers.py @@ -4,6 +4,7 @@ import logging import os import pathlib +import shutil import subprocess import traceback from dataclasses import dataclass @@ -45,6 +46,7 @@ def __init__( self.host_working_directory = host_working_directory self.image_repo_with_project = image_repo_with_project self.image_tag = image_tag + self.base_network = base_network self.cli_path = os.path.abspath(cli_path) if cli_path else cli_path self.base_network = base_network self.test_count = 0 @@ -61,25 +63,41 @@ def run_command(self, test_name, command, *args, **kwargs): file_name = f"{self.test_count:03}_{test_name}" self.test_count += 1 + # If we're in a CI environment it is necessary to set the --user, otherwise it + # is not possible to interact with the files in the bindmount. For more details + # see here: https://github.com/community/community/discussions/44243. + if os.environ.get("CI"): + user_args = ["--user", f"{os.getuid()}:{os.getgid()}"] + else: + user_args = [] + # Build command. if self.image_tag: - full_command = [ - "docker", - "run", - # For why we have to set --user, see here: - # https://github.com/community/community/discussions/44243 - "--user", - f"{os.getuid()}:{os.getgid()}", - "--rm", - "--network", - "host", - "-i", - "-v", - f"{self.host_working_directory}:{WORKING_DIR_IN_CONTAINER}", - "--workdir", - WORKING_DIR_IN_CONTAINER, - self.build_image_name(), - ] + command + full_command = ( + [ + "docker", + "run", + ] + + user_args + + [ + "-e", + # This is necessary to force the CLI to place the `.move` directory + # inside the bindmount dir, which is the only writeable directory + # inside the container when in CI. It's fine to do it outside of CI + # as well. + f"HOME={WORKING_DIR_IN_CONTAINER}", + "--rm", + "--network", + "host", + "-i", + "-v", + f"{self.host_working_directory}:{WORKING_DIR_IN_CONTAINER}", + "--workdir", + WORKING_DIR_IN_CONTAINER, + self.build_image_name(), + ] + + command + ) else: full_command = [self.cli_path] + command[1:] LOG.debug(f"Running command: {full_command}") @@ -130,10 +148,23 @@ def run_command(self, test_name, command, *args, **kwargs): raise + # Top level function to run any preparation. + def prepare(self): + self.prepare_move() + self.prepare_cli() + + # Move any Move files into the working directory. + def prepare_move(self): + shutil.copytree( + "../../../aptos-move/move-examples/cli-e2e-tests", + os.path.join(self.host_working_directory, "move"), + ignore=shutil.ignore_patterns("build"), + ) + # If image_Tag is set, pull the test CLI image. We don't technically have to do # this separately but it makes the steps clearer. Otherwise, cli_path must be # set, in which case we ensure the file is there. - def prepare(self): + def prepare_cli(self): if self.image_tag: image_name = self.build_image_name() LOG.info(f"Pre-pulling image for CLI we're testing: {image_name}")