diff --git a/.github/workflows/e2e.yml b/.github/workflows/e2e.yml new file mode 100644 index 000000000..df11bef61 --- /dev/null +++ b/.github/workflows/e2e.yml @@ -0,0 +1,75 @@ +name: E2E + +on: + pull_request: + +concurrency: + group: ${{ github.workflow }}-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +env: + CARGO_TERM_COLOR: always + +jobs: + e2e: + name: End-to-end Test Journey + runs-on: ubuntu-latest + timeout-minutes: 90 + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Cache Cargo registry + uses: actions/cache@v4 + with: + path: | + ~/.cargo/registry + ~/.cargo/git + key: ${{ runner.os }}-cargo-registry-${{ hashFiles('**/Cargo.lock') }} + restore-keys: | + ${{ runner.os }}-cargo-registry- + + - name: Install Rust toolchain + uses: actions-rs/toolchain@v1 + with: + toolchain: stable + override: true + + - name: Install system dependencies + run: | + sudo apt-get update + sudo apt-get install -y build-essential pkg-config clang cmake libsqlite3-dev ca-certificates + + - name: Install protoc + run: | + curl -OL https://github.com/protocolbuffers/protobuf/releases/download/v25.2/protoc-25.2-linux-x86_64.zip + sudo unzip -o protoc-25.2-linux-x86_64.zip -d /usr/local bin/protoc + sudo unzip -o protoc-25.2-linux-x86_64.zip -d /usr/local 'include/*' + rm -f protoc-25.2-linux-x86_64.zip + + - name: Prepare app configuration + run: | + mkdir -p "$HOME/.config/dash-evo-tool" + cp .env.example "$HOME/.config/dash-evo-tool/.env" + + - name: Run E2E test journey + env: + E2E_WALLET_MNEMONIC: ${{ secrets.E2E_WALLET_MNEMONIC }} + E2E_SPV_TIMEOUT_SECS: 900 + RUST_BACKTRACE: 1 + run: | + if [ -z "${E2E_WALLET_MNEMONIC}" ]; then + echo "::warning::E2E_WALLET_MNEMONIC is not set; skipping E2E test job." + exit 0 + fi + + cargo test --features="e2e" --test e2e -- --ignored --nocapture | tee /tmp/e2e.log + + - name: Upload E2E log + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-test-log + path: /tmp/e2e.log + if-no-files-found: warn diff --git a/Cargo.toml b/Cargo.toml index 1ed4b5f8e..06dff62f2 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -87,6 +87,7 @@ raw-cpuid = "11.5.0" [features] testing = [] +e2e = [] [dev-dependencies] egui_kittest = { version = "0.33.3", features = ["eframe"] } @@ -94,6 +95,22 @@ egui_kittest = { version = "0.33.3", features = ["eframe"] } [build-dependencies] winres = "0.1" +[patch."https://github.com/dashpay/rust-dashcore"] +dash-network = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +dash-spv = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +dashcore = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +dashcore-private = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +dashcore-rpc = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +dashcore-rpc-json = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +dashcore_hashes = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +key-wallet = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } +key-wallet-manager = { git = "https://www.github.com/dashpay/rust-dashcore", rev = "6affdaa5db30c04f533cfac4a81b9939d1cf2545" } + +[[test]] +name = "e2e" +path = "tests/e2e/main.rs" +required-features = ["e2e"] + [lints.rust.unexpected_cfgs] level = "warn" check-cfg = ["cfg(tokio_unstable)", "cfg(feature, values(\"testing\"))"] diff --git a/src/context/mod.rs b/src/context/mod.rs index 868f8b71e..42b3e0503 100644 --- a/src/context/mod.rs +++ b/src/context/mod.rs @@ -103,6 +103,19 @@ pub struct AppContext { egui_ctx: egui::Context, } +#[cfg(feature = "e2e")] +impl AppContext { + /// Public database accessor for E2E tests. + pub fn db(&self) -> &Arc { + &self.db + } + + /// Public wallets accessor for E2E tests. + pub fn wallets(&self) -> &RwLock>>> { + &self.wallets + } +} + impl AppContext { pub fn new( network: Network, diff --git a/src/ui/contracts_documents/add_contracts_screen.rs b/src/ui/contracts_documents/add_contracts_screen.rs index 449d184b6..820f298a7 100644 --- a/src/ui/contracts_documents/add_contracts_screen.rs +++ b/src/ui/contracts_documents/add_contracts_screen.rs @@ -103,7 +103,7 @@ impl AddContractsScreen { for (i, contract_id) in self.contract_ids_input.iter_mut().enumerate() { ui.horizontal(|ui| { ui.label(format!("Contract {}:", i + 1)); - ui.text_edit_singleline(contract_id); + ui.add(egui::TextEdit::singleline(contract_id).hint_text("Contract ID")); }); ui.add_space(5.0); } @@ -353,7 +353,7 @@ impl ScreenLike for AddContractsScreen { ui.add_space(10.0); // Add Contracts Button let button = - egui::Button::new(RichText::new("Add Contracts").color(Color32::WHITE)) + egui::Button::new(RichText::new("Fetch Contracts").color(Color32::WHITE)) .fill(DashColors::ACTION_BUTTON_BLUE) .frame(true) .corner_radius(3.0); diff --git a/src/ui/identities/add_existing_identity_screen.rs b/src/ui/identities/add_existing_identity_screen.rs index 88b651007..8e85db84b 100644 --- a/src/ui/identities/add_existing_identity_screen.rs +++ b/src/ui/identities/add_existing_identity_screen.rs @@ -329,7 +329,7 @@ impl AddExistingIdentityScreen { } } }); - ui.text_edit_singleline(&mut self.identity_id_input); + ui.add(egui::TextEdit::singleline(&mut self.identity_id_input).hint_text("Identity ID")); ui.end_row(); // Advanced: Identity Type selector @@ -793,7 +793,10 @@ impl AddExistingIdentityScreen { .show(ui, |ui| { ui.label("Username:"); ui.horizontal(|ui| { - ui.text_edit_singleline(&mut self.dpns_name_input); + ui.add( + egui::TextEdit::singleline(&mut self.dpns_name_input) + .hint_text("DPNS name"), + ); ui.label(".dash"); }); ui.end_row(); diff --git a/src/ui/identities/add_new_identity_screen/mod.rs b/src/ui/identities/add_new_identity_screen/mod.rs index 1efc1c4b2..ce640354d 100644 --- a/src/ui/identities/add_new_identity_screen/mod.rs +++ b/src/ui/identities/add_new_identity_screen/mod.rs @@ -92,7 +92,7 @@ pub struct AddNewIdentityScreen { show_pop_up_info: Option, in_key_selection_advanced_mode: bool, pub app_context: Arc, - successful_qualified_identity_id: Option, + pub(crate) successful_qualified_identity_id: Option, /// Selected Platform address for funding with the amount in credits selected_platform_address_for_funding: Option<( dash_sdk::dpp::address_funds::PlatformAddress, @@ -107,6 +107,25 @@ pub struct AddNewIdentityScreen { completed_fee_result: Option, } +#[cfg(feature = "e2e")] +impl AddNewIdentityScreen { + pub fn step(&self) -> &Arc> { + &self.step + } + + pub fn funding_method(&self) -> &Arc> { + &self.funding_method + } + + pub fn set_funding_amount(&mut self, amount: Option) { + self.funding_amount = amount; + } + + pub fn set_alias_input(&mut self, alias: String) { + self.alias_input = alias; + } +} + impl AddNewIdentityScreen { pub fn new(app_context: &Arc) -> Self { Self::new_with_wallet(app_context, None) diff --git a/src/ui/identities/register_dpns_name_screen.rs b/src/ui/identities/register_dpns_name_screen.rs index 7ceb12809..9c7d13645 100644 --- a/src/ui/identities/register_dpns_name_screen.rs +++ b/src/ui/identities/register_dpns_name_screen.rs @@ -63,6 +63,13 @@ pub struct RegisterDpnsNameScreen { pub source: RegisterDpnsNameSource, } +#[cfg(feature = "e2e")] +impl RegisterDpnsNameScreen { + pub fn set_name_input(&mut self, name: String) { + self.name_input = name; + } +} + impl RegisterDpnsNameScreen { pub fn new(app_context: &Arc, source: RegisterDpnsNameSource) -> Self { let qualified_identities: Vec<_> = diff --git a/src/ui/mod.rs b/src/ui/mod.rs index 84b8ac96b..cda1772b5 100644 --- a/src/ui/mod.rs +++ b/src/ui/mod.rs @@ -82,7 +82,7 @@ pub mod contracts_documents; pub mod dashpay; pub mod dpns; pub mod helpers; -pub(crate) mod identities; +pub mod identities; pub mod network_chooser_screen; pub mod theme; pub mod tokens; diff --git a/src/ui/tokens/tokens_screen/keyword_search.rs b/src/ui/tokens/tokens_screen/keyword_search.rs index 1879675f1..dd1585478 100644 --- a/src/ui/tokens/tokens_screen/keyword_search.rs +++ b/src/ui/tokens/tokens_screen/keyword_search.rs @@ -42,7 +42,8 @@ impl TokensScreen { let query_ref = self .token_search_query .get_or_insert_with(|| "".to_string()); - let text_edit_response = ui.text_edit_singleline(query_ref); + let text_edit_response = + ui.add(egui::TextEdit::singleline(query_ref).hint_text("Search tokens")); // Clone the current query for use in the closure below let current_query = query_ref.clone(); diff --git a/src/ui/wallets/import_mnemonic_screen.rs b/src/ui/wallets/import_mnemonic_screen.rs index 85247628a..ce3975dea 100644 --- a/src/ui/wallets/import_mnemonic_screen.rs +++ b/src/ui/wallets/import_mnemonic_screen.rs @@ -399,6 +399,7 @@ impl ImportMnemonicScreen { let response = ui.add_sized( Vec2::new(input_width, 20.0), egui::TextEdit::singleline(&mut word) + .hint_text(format!("Word {}", i + 1)) .text_color(DashColors::text_primary(dark_mode)) .background_color(DashColors::input_background(dark_mode)), ); @@ -494,6 +495,35 @@ impl ImportMnemonicScreen { } } +#[cfg(feature = "e2e")] +impl ImportMnemonicScreen { + /// Set all seed phrase words and parse the mnemonic (for testing). + pub fn set_seed_phrase_words(&mut self, words: &[&str]) { + self.selected_seed_phrase_length = words.len(); + self.seed_phrase_words = words.iter().map(|w| w.to_string()).collect(); + match Mnemonic::parse_normalized(words.join(" ").as_str()) { + Ok(mnemonic) => { + self.seed_phrase = Some(mnemonic); + self.error = None; + } + Err(e) => { + self.seed_phrase = None; + self.error = Some(e.to_string()); + } + } + } + + /// Set the wallet alias (for testing). + pub fn set_alias(&mut self, alias: &str) { + self.alias_input = alias.to_string(); + } + + /// Trigger wallet import — stores to DB and adds to AppContext (for testing). + pub fn trigger_save(&mut self) -> Result { + self.save_wallet() + } +} + impl ScreenLike for ImportMnemonicScreen { fn ui(&mut self, ctx: &Context) -> AppAction { let mut action = add_top_panel( @@ -630,7 +660,7 @@ impl ScreenLike for ImportMnemonicScreen { ui.horizontal(|ui| { ui.label("Name:"); - ui.text_edit_singleline(&mut self.alias_input); + ui.add(egui::TextEdit::singleline(&mut self.alias_input).hint_text("Wallet name")); }); step += 1; diff --git a/tests/e2e/helpers.rs b/tests/e2e/helpers.rs deleted file mode 100644 index 1deba5264..000000000 --- a/tests/e2e/helpers.rs +++ /dev/null @@ -1,36 +0,0 @@ -//! E2E Test Helpers -//! -//! This module provides shared utilities for E2E testing, including: -//! - Test harness setup -//! - Common test fixtures - -/// Create a minimal test harness for E2E tests -#[allow(dead_code)] -pub struct TestHarness { - pub runtime: tokio::runtime::Runtime, -} - -impl TestHarness { - pub fn new() -> Self { - let runtime = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - Self { runtime } - } -} - -impl Default for TestHarness { - fn default() -> Self { - Self::new() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_harness_creation() { - let harness = TestHarness::new(); - // Just verify we can create the harness without panicking - drop(harness); - } -} diff --git a/tests/e2e/helpers/context.rs b/tests/e2e/helpers/context.rs new file mode 100644 index 000000000..12321d8bd --- /dev/null +++ b/tests/e2e/helpers/context.rs @@ -0,0 +1,49 @@ +use dash_evo_tool::model::wallet::WalletSeedHash; +use dash_sdk::platform::Identifier; + +/// Shared test state persisted across phases within a single test run. +/// Replaces the react-native test-context.ts JSON file approach. +pub struct TestContext { + pub wallet_seed_hash: Option, + pub receive_address: Option, + pub balance_duffs: u64, + pub spv_synced: bool, + pub network: String, + pub wallet_reused: bool, + /// Identity ID created in Phase 5 + pub identity_id: Option, + /// DPNS name registered in Phase 6 + pub dpns_name: Option, + /// SPV header height at setup completion (needed for tx lock time) + pub header_height: u32, +} + +impl TestContext { + /// Returns the wallet seed hash, panicking if not yet set. + pub fn seed_hash(&self) -> &WalletSeedHash { + self.wallet_seed_hash + .as_ref() + .expect("wallet_seed_hash must be set (did Phase 0 complete?)") + } +} + +impl Default for TestContext { + fn default() -> Self { + Self { + wallet_seed_hash: None, + receive_address: None, + balance_duffs: 0, + spv_synced: false, + network: "testnet".to_string(), + wallet_reused: false, + identity_id: None, + dpns_name: None, + header_height: 0, + } + } +} + +/// Format first 4 bytes of a seed hash as a hex prefix string. +pub fn seed_hash_prefix(hash: &WalletSeedHash) -> String { + hash[..4].iter().map(|b| format!("{:02x}", b)).collect() +} diff --git a/tests/e2e/helpers/harness.rs b/tests/e2e/helpers/harness.rs new file mode 100644 index 000000000..5b3bed75f --- /dev/null +++ b/tests/e2e/helpers/harness.rs @@ -0,0 +1,416 @@ +use crate::helpers::context::TestContext; +use dash_evo_tool::app::AppState; +use dash_evo_tool::spv::SpvStatus; +use dash_evo_tool::ui::{RootScreenType, ScreenLike, ScreenType}; +use dash_sdk::dash_spv::sync::ProgressPercentage; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use std::time::{Duration, Instant}; + +// ─── Centralized constants ─────────────────────────────────────────────────── + +/// Default SPV sync timeout (seconds); overridden by E2E_SPV_TIMEOUT_SECS env var in phase_00. +pub const SPV_SYNC_TIMEOUT_SECS: u64 = 600; +/// Minimum wallet balance (duffs) for SPV sync to be considered successful. +pub const MIN_BALANCE_DUFFS: u64 = 100_000; +/// Platform read operations (DPNS lookup, contract fetch). +pub const PLATFORM_READ_TIMEOUT: Duration = Duration::from_secs(120); +/// Contract fetch (simpler than full platform reads). +pub const CONTRACT_FETCH_TIMEOUT: Duration = Duration::from_secs(90); +/// Token search. +pub const TOKEN_SEARCH_TIMEOUT: Duration = Duration::from_secs(60); +/// DPNS name registration. +pub const DPNS_REGISTRATION_TIMEOUT: Duration = Duration::from_secs(180); +/// SPV stop timeout during teardown. +pub const SPV_STOP_TIMEOUT: Duration = Duration::from_secs(30); +/// Default retry count for transient platform operations. +pub const PLATFORM_MAX_RETRIES: u32 = 3; +/// Frames per poll cycle in wait loops (~0.5s at 60fps). +pub const POLL_STEPS: usize = 30; +/// Frames to run after navigation/screen push for UI settle. +pub const SETTLE_STEPS: usize = 10; +// ─── Error classification ──────────────────────────────────────────────────── + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ErrorCategory { + Network, + Validation, + TransientPlatform, + Fatal, +} + +impl ErrorCategory { + pub fn is_retryable(self) -> bool { + matches!(self, Self::Network | Self::TransientPlatform) + } + + pub fn label(self) -> &'static str { + match self { + Self::Network => "NETWORK", + Self::Validation => "VALIDATION", + Self::TransientPlatform => "TRANSIENT", + Self::Fatal => "FATAL", + } + } +} + +/// Error classification patterns, checked in priority order (first match wins). +/// Network patterns are checked before Validation/TransientPlatform so that +/// ambiguous tokens like "invalid connection" classify as Network (retryable) +/// rather than Validation (fatal). This biases toward retry for E2E resilience. +const ERROR_PATTERNS: &[(ErrorCategory, &[&str])] = &[ + ( + ErrorCategory::Network, + &[ + "timeout", + "connection", + "network", + "unavailable", + "timed out", + "refused", + "unreachable", + ], + ), + ( + ErrorCategory::Validation, + &[ + "invalid", + "insufficient", + "already exists", + "not found", + "duplicate", + "too low", + "too high", + ], + ), + ( + ErrorCategory::TransientPlatform, + &[ + "consensus", + "retry", + "temporarily", + "try again", + "rate limit", + "internal error", + "transport error", + ], + ), +]; + +pub fn classify_error(error_text: &str) -> ErrorCategory { + let lower = error_text.to_lowercase(); + for (category, patterns) in ERROR_PATTERNS { + if patterns.iter().any(|p| lower.contains(p)) { + return *category; + } + } + ErrorCategory::Fatal +} + +// ─── Harness creation ──────────────────────────────────────────────────────── + +/// Create a test harness configured for E2E testing. +pub fn create_e2e_harness(rt: &tokio::runtime::Runtime) -> Harness<'static, AppState> { + let _guard = rt.enter(); + let mut harness = Harness::builder() + .with_max_steps(10000) + .build_eframe(|ctx| AppState::new(ctx.egui_ctx.clone()).with_animations(false)); + harness.set_size(egui::vec2(1280.0, 800.0)); + harness +} + +// ─── Wait helpers ──────────────────────────────────────────────────────────── + +/// Poll harness until predicate returns true, or timeout. +/// Replacement for WebdriverIO's browser.waitUntil(). +/// Runs `steps_per_check` frames between each predicate evaluation. +pub fn wait_until( + harness: &mut Harness<'_, AppState>, + predicate: F, + timeout: Duration, + steps_per_check: usize, +) -> bool +where + F: Fn(&Harness<'_, AppState>) -> bool, +{ + let start = Instant::now(); + while start.elapsed() < timeout { + harness.run_steps(steps_per_check); + if predicate(harness) { + return true; + } + } + false +} + +/// Wait until a label containing `text` appears in the UI. +/// Safe with ambiguous matches — returns true if at least one node matches. +pub fn wait_for_label(harness: &mut Harness<'_, AppState>, text: &str, timeout: Duration) -> bool { + wait_until( + harness, + |h| h.query_all_by_label_contains(text).next().is_some(), + timeout, + 5, + ) +} + +// ─── Navigation helpers ────────────────────────────────────────────────────── + +/// Dismiss the welcome screen so tests start from the main app. +pub fn dismiss_welcome_screen(harness: &mut Harness<'_, AppState>) { + harness.state_mut().show_welcome_screen = false; + harness.state_mut().welcome_screen = None; +} + +/// Navigate to a root screen by setting the selected screen directly. +/// Calls `refresh_on_arrival()` on the target screen so it picks up new +/// wallets, identities, etc. that were added after initial screen creation. +pub fn navigate_to_screen(harness: &mut Harness<'_, AppState>, screen: RootScreenType) { + harness.state_mut().selected_main_screen = screen; + harness.state_mut().screen_stack.clear(); + harness + .state_mut() + .active_root_screen_mut() + .refresh_on_arrival(); + harness.run_steps(15); +} + +/// Verify the Receive button is visible (proves wallet is selected on the +/// wallets screen). In kittest, opening modal dialogs and verifying their +/// content is unreliable because AccessKit interactions don't always propagate, +/// so we limit this to checking the button exists. +pub fn verify_receive_button_visible(harness: &mut Harness<'_, AppState>) { + // Use exact match to avoid "Total Received (DASH)" + let found = harness.query_by_label("Receive").is_some(); + assert!( + found, + "Receive button must be visible on wallets screen (wallet selected)" + ); + println!(" Receive button visible (wallet is selected)"); +} + +/// Verify the sidebar renders a label for the given screen, then navigate +/// directly. AccessKit cannot click sidebar labels (they're non-interactive +/// text beneath icon buttons), so we verify presence and navigate directly. +pub fn verify_sidebar_label_and_navigate( + harness: &mut Harness<'_, AppState>, + label: &str, + target: RootScreenType, +) { + harness.state_mut().screen_stack.clear(); + harness.run_steps(5); + + // Verify the sidebar label is rendered (proves the left panel works). + // Use query_all because both a sidebar Label and a breadcrumb Button + // can contain the same text (e.g. "Wallets"). + assert!( + harness.query_all_by_label_contains(label).next().is_some(), + "Sidebar label '{}' must be visible (left panel rendering broken?)", + label + ); + println!(" Sidebar label '{}' verified", label); + + navigate_to_screen(harness, target); +} + +/// Push a screen onto the screen stack by type. +/// Creates the screen from the current AppContext and runs a few frames +/// to let the UI settle. +pub fn push_screen(harness: &mut Harness<'_, AppState>, screen_type: ScreenType) { + let app_ctx = harness.state().current_app_context(); + let screen = screen_type.create_screen(app_ctx); + harness.state_mut().screen_stack.push(screen); + harness.run_steps(SETTLE_STEPS); +} + +/// Pop the top screen off the stack and run settle frames. +pub fn pop_screen(harness: &mut Harness<'_, AppState>) { + harness.state_mut().screen_stack.pop(); + harness.run_steps(SETTLE_STEPS); +} + +// ─── Input helpers ─────────────────────────────────────────────────────────── + +/// Click the Nth TextInput (by AccessKit role), type text into it, and +/// run a few frames for the UI to process the input. +/// +/// `nth` is zero-indexed: 0 = first TextInput, 1 = second, etc. +pub fn type_into_text_input(harness: &mut Harness<'_, AppState>, nth: usize, text: &str) { + harness + .query_all_by_role(egui::accesskit::Role::TextInput) + .nth(nth) + .unwrap_or_else(|| panic!("TextInput #{} must exist on screen", nth)) + .click(); + harness.run_steps(5); + harness + .query_all_by_role(egui::accesskit::Role::TextInput) + .nth(nth) + .unwrap_or_else(|| { + panic!( + "TextInput #{} vanished between click and type in type_into_text_input", + nth + ) + }) + .type_text(text); + harness.run_steps(SETTLE_STEPS); +} + +// ─── AccessKit text extraction ─────────────────────────────────────────────── + +/// Extract the display text from an AccessKit node, matching kittest's own +/// label-resolution logic: `Role::Label` nodes store text in `value()`, +/// all other roles use `label()`. +pub fn node_text(node: &egui_kittest::kittest::AccessKitNode<'_>) -> Option { + if node.role() == egui::accesskit::Role::Label { + node.value() + } else { + node.label() + } +} + +// ─── Error / dismiss helpers ───────────────────────────────────────────────── + +/// Dismiss an error/info dialog if the "Dismiss" button is present. +pub fn dismiss_if_present(harness: &mut Harness<'_, AppState>) { + if let Some(dismiss) = harness.query_by_label_contains("Dismiss") { + dismiss.click(); + harness.run_steps(5); + } +} + +/// Capture the text of a visible error label. +/// Searches multiple common error patterns and extracts the node text. +/// Returns None if no error label is visible. +pub fn capture_error_text(harness: &Harness<'_, AppState>) -> Option { + const PATTERNS: &[&str] = &["Error:", "Error registering", "Error "]; + + for pattern in PATTERNS { + if let Some(node) = harness.query_all_by_label_contains(pattern).next() { + if let Some(text) = node_text(&node.accesskit_node()) { + return Some(text); + } + // Fallback: truncated Debug output if text unavailable + return Some(format!("{:?}", node).chars().take(200).collect()); + } + } + None +} + +/// Handle a retryable error during a platform operation. +/// +/// Captures the error text, classifies it, logs it, and either panics (for +/// non-retryable errors or final attempt) or dismisses the dialog and prepares +/// for the next attempt. If `pop_screen` is true, pops the top screen off the +/// stack before backoff. +/// +/// Panics if the error is non-retryable or this was the last attempt. +/// Returns normally when the caller should `continue` the retry loop. +pub fn handle_retry_error( + harness: &mut Harness<'_, AppState>, + operation: &str, + attempt: u32, + pop_screen: bool, +) { + let error_text = capture_error_text(harness).unwrap_or_else(|| "unknown error".to_string()); + let category = classify_error(&error_text); + println!( + " {} error (attempt {}/{}): [{}] {}", + operation, + attempt, + PLATFORM_MAX_RETRIES, + category.label(), + error_text + ); + + if !category.is_retryable() { + panic!( + "{} failed with non-retryable error: {}", + operation, error_text + ); + } + + dismiss_if_present(harness); + if pop_screen { + harness.state_mut().screen_stack.pop(); + } + harness.run_steps(SETTLE_STEPS * attempt as usize); + + if attempt == PLATFORM_MAX_RETRIES { + panic!( + "{} failed after {} retries. Last error: {}", + operation, PLATFORM_MAX_RETRIES, error_text + ); + } +} + +// ─── SPV readiness gate ────────────────────────────────────────────────────── + +/// Re-verify SPV state before phases that build core transactions. +/// Asserts that SPV is syncing/running, headers are available, and +/// the wallet still has the minimum balance from Phase 0. +pub fn ensure_spv_tx_ready(harness: &mut Harness<'_, AppState>, ctx: &TestContext) { + let app_ctx = harness.state().current_app_context(); + let status = app_ctx.spv_manager().status(); + let header_height = status + .sync_progress + .as_ref() + .and_then(|p| p.headers().ok()) + .map(|h| h.current_height()) + .unwrap_or(0); + + assert!( + matches!(status.status, SpvStatus::Syncing | SpvStatus::Running), + "SPV must be Syncing or Running for transactions, got {:?}. Last error: {}", + status.status, + status.last_error.as_deref().unwrap_or("none") + ); + assert!( + header_height > 0, + "SPV header height must be > 0 for transaction building (got 0)" + ); + + let wallets = app_ctx.wallets().read().unwrap(); + let wallet = wallets + .get(ctx.seed_hash()) + .expect("Test wallet must exist in AppContext during tx phases"); + let w = wallet.read().unwrap(); + assert!( + w.max_balance() >= MIN_BALANCE_DUFFS, + "Wallet balance ({} duffs) below minimum since Phase 0", + w.max_balance() + ); + + println!( + " SPV tx-ready: {:?}, header_height={}, peers={}", + status.status, header_height, status.connected_peers + ); +} + +// ─── Emergency cleanup ─────────────────────────────────────────────────────── + +/// Emergency cleanup after a panic — stop SPV, remove test identity and wallet. +/// All operations are synchronous (CancellationToken + rusqlite/RwLock), +/// so they are safe to call in a panic handler. +/// Identity is removed before wallet (identity references may depend on wallet state). +pub fn emergency_cleanup(harness: &Harness<'_, AppState>, ctx: &TestContext) { + let app_ctx = harness.state().current_app_context(); + app_ctx.spv_manager().stop(); + eprintln!(" Emergency: SPV stop requested"); + + if let Some(identity_id) = &ctx.identity_id { + match app_ctx + .db() + .delete_local_qualified_identity(identity_id, app_ctx) + { + Ok(()) => eprintln!(" Emergency: identity removed"), + Err(e) => eprintln!(" Emergency: identity removal failed: {}", e), + } + } + + if let Some(seed_hash) = &ctx.wallet_seed_hash { + match app_ctx.remove_wallet(seed_hash) { + Ok(()) => eprintln!(" Emergency: wallet removed"), + Err(e) => eprintln!(" Emergency: wallet removal failed: {}", e), + } + } +} diff --git a/tests/e2e/helpers/mod.rs b/tests/e2e/helpers/mod.rs new file mode 100644 index 000000000..ca5f42114 --- /dev/null +++ b/tests/e2e/helpers/mod.rs @@ -0,0 +1,2 @@ +pub mod context; +pub mod harness; diff --git a/tests/e2e/main.rs b/tests/e2e/main.rs index d75b5912e..49f38a992 100644 --- a/tests/e2e/main.rs +++ b/tests/e2e/main.rs @@ -1,8 +1,60 @@ -//! E2E Test Suite Entry Point -//! -//! This file serves as the entry point for the E2E test suite. -//! Run with: cargo test --test e2e - mod helpers; -mod navigation; -mod wallet_flows; +mod phases; + +/// Full E2E test against real testnet. +/// +/// Required: E2E_WALLET_MNEMONIC env var (BIP39 testnet mnemonic) +/// The wallet must be pre-funded with at least 0.1 DASH. +/// +/// Run: E2E_WALLET_MNEMONIC="word1 word2 ..." cargo test --test e2e --all-features -- --ignored --nocapture +#[test] +#[ignore] +fn e2e_testnet_journey() { + // Validate presence early so we fail fast before booting the app + std::env::var("E2E_WALLET_MNEMONIC") + .expect("E2E_WALLET_MNEMONIC env var required (BIP39 testnet mnemonic, pre-funded)"); + + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + let mut harness = helpers::harness::create_e2e_harness(&rt); + let mut ctx = helpers::context::TestContext::default(); + + let test_result = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + println!("\n=== Smoke: App Initialization ==="); + phases::phase_smoke::run(&mut harness); + + println!("\n=== Phase 0: Setup (Wallet Import + SPV Sync) ==="); + phases::phase_00_setup::run(&mut harness, &mut ctx); + + println!("\n=== Phase 1: Wallet UI + Balance Display ==="); + phases::phase_01_wallet_ui::run(&mut harness, &mut ctx); + + println!("\n=== Phase 2: Wallet UI Operations ==="); + phases::phase_02_wallet::run(&mut harness, &mut ctx); + + println!("\n=== Phase 3: Platform Reads ==="); + phases::phase_03_platform::run(&mut harness, &mut ctx); + + println!("\n=== Phase 4: Token Search ==="); + phases::phase_04_tokens::run(&mut harness, &mut ctx); + + println!("\n=== Phase 5: Identity Validation ==="); + phases::phase_05_identity::run(&mut harness, &mut ctx); + + // Phase 6 (DPNS) skipped — Phase 5 runs validation tests but actual + // identity creation is disabled until SPV mempool support lands, + // so no identity_id is produced for DPNS registration. + println!("\n=== Phase 6: DPNS Name Registration — SKIPPED ==="); + + println!("\n=== Phase 7: Teardown ==="); + phases::phase_07_teardown::run(&mut harness, &ctx); + })); + + if let Err(payload) = test_result { + eprintln!("\n=== PANIC: Running emergency cleanup ==="); + let _ = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + helpers::harness::emergency_cleanup(&harness, &ctx); + })); + std::panic::resume_unwind(payload); + } +} diff --git a/tests/e2e/mod.rs b/tests/e2e/mod.rs deleted file mode 100644 index acacf49c1..000000000 --- a/tests/e2e/mod.rs +++ /dev/null @@ -1,9 +0,0 @@ -//! E2E Test Suite for Dash Evo Tool -//! -//! This module contains end-to-end tests that verify complete user journeys. -//! The tests use egui_kittest to simulate the UI and verify that screens -//! render and behave correctly. - -mod helpers; -mod navigation; -mod wallet_flows; diff --git a/tests/e2e/navigation.rs b/tests/e2e/navigation.rs deleted file mode 100644 index 8681c1fb7..000000000 --- a/tests/e2e/navigation.rs +++ /dev/null @@ -1,111 +0,0 @@ -//! E2E Tests for Navigation -//! -//! These tests verify that navigation between screens works correctly -//! and that state is preserved appropriately. - -use egui_kittest::Harness; - -/// Test that app navigation completes without errors -#[test] -fn test_basic_navigation() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - - // Run initial frames - harness.run_steps(20); -} - -/// Test navigation with different window sizes -#[test] -fn test_navigation_responsive_layout() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let sizes = [ - egui::vec2(640.0, 480.0), - egui::vec2(1024.0, 768.0), - egui::vec2(1440.0, 900.0), - ]; - - for size in sizes { - let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - harness.set_size(size); - harness.run_steps(15); - } -} - -/// Test that rapid navigation doesn't cause issues -#[test] -fn test_rapid_frame_navigation() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(300).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - - // Run many single-step frames - for _ in 0..50 { - harness.run_steps(1); - } -} - -/// Test that the app maintains stability over extended use -#[test] -fn test_extended_navigation_stability() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(500).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - harness.set_size(egui::vec2(1280.0, 720.0)); - - // Run 100 frames in batches - for batch in 0..10 { - harness.run_steps(10); - // Verify each batch completes - let _ = batch; - } -} - -/// Test app behavior with minimum window size -#[test] -fn test_minimum_size_navigation() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(50).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - // Very small window - harness.set_size(egui::vec2(320.0, 240.0)); - harness.run_steps(10); - - // Resize to normal - harness.set_size(egui::vec2(1024.0, 768.0)); - harness.run_steps(10); -} diff --git a/tests/e2e/phases/mod.rs b/tests/e2e/phases/mod.rs new file mode 100644 index 000000000..20be7763c --- /dev/null +++ b/tests/e2e/phases/mod.rs @@ -0,0 +1,10 @@ +pub mod phase_00_setup; +pub mod phase_01_wallet_ui; +pub mod phase_02_wallet; +pub mod phase_03_platform; +pub mod phase_04_tokens; +pub mod phase_05_identity; +#[allow(dead_code)] // Phase 6 is skipped until SPV mempool support lands +pub mod phase_06_dpns; +pub mod phase_07_teardown; +pub mod phase_smoke; diff --git a/tests/e2e/phases/phase_00_setup.rs b/tests/e2e/phases/phase_00_setup.rs new file mode 100644 index 000000000..a6b223ec0 --- /dev/null +++ b/tests/e2e/phases/phase_00_setup.rs @@ -0,0 +1,348 @@ +use crate::helpers::context::{TestContext, seed_hash_prefix}; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::model::wallet::WalletSeedHash; +use dash_evo_tool::spv::CoreBackendMode; +use dash_evo_tool::spv::SpvStatus; +use dash_evo_tool::ui::{Screen, ScreenType}; +use dash_sdk::dash_spv::sync::ProgressPercentage; +use dash_sdk::dpp::dashcore::Network; +use egui_kittest::Harness; +use std::collections::BTreeSet; +use std::time::{Duration, Instant}; + +const E2E_WALLET_ALIAS: &str = "E2E Test Wallet"; + +/// Check if a wallet with the E2E alias already exists that we can reuse. +/// Only matches by exact alias -- never grabs unrelated wallets. +fn find_existing_e2e_wallet(harness: &Harness<'_, AppState>) -> Option { + let app_ctx = harness.state().current_app_context(); + let wallets = app_ctx.wallets().read().unwrap(); + wallets + .iter() + .find(|(_, wallet)| wallet.read().unwrap().alias.as_deref() == Some(E2E_WALLET_ALIAS)) + .map(|(seed_hash, _)| *seed_hash) +} + +/// Import a wallet by pushing ImportMnemonicScreen and setting values directly. +/// AccessKit interactions (button clicks, text input via hint_text) are unreliable +/// in egui_kittest, so we manipulate the screen state programmatically. +fn import_wallet_via_ui( + harness: &mut Harness<'_, AppState>, + ctx: &mut TestContext, + words: &[&str], +) { + // Capture wallet keys before import so we can diff to find the new one + let initial_wallet_keys: BTreeSet = { + let app_ctx = harness.state().current_app_context(); + app_ctx.wallets().read().unwrap().keys().copied().collect() + }; + + // Push ImportMnemonicScreen and configure it directly + push_screen(harness, ScreenType::ImportMnemonic); + + if let Some(Screen::ImportMnemonicScreen(screen)) = harness.state_mut().screen_stack.last_mut() + { + screen.set_seed_phrase_words(words); + screen.set_alias(E2E_WALLET_ALIAS); + println!( + " Set {} mnemonic words + alias '{}'", + words.len(), + E2E_WALLET_ALIAS + ); + + screen + .trigger_save() + .unwrap_or_else(|e| panic!("Wallet import failed: {}", e)); + println!(" Wallet saved to DB"); + } else { + panic!("Expected ImportMnemonicScreen on screen stack"); + } + + // Let the UI process the save + harness.run_steps(SETTLE_STEPS); + + // Verify wallet appeared in AppContext + { + let app_ctx = harness.state().current_app_context(); + let wallets = app_ctx.wallets().read().unwrap(); + assert!( + wallets.len() > initial_wallet_keys.len(), + "Wallet count didn't increase after save (still {})", + initial_wallet_keys.len() + ); + let current_keys: BTreeSet = wallets.keys().copied().collect(); + let new_keys: Vec<_> = current_keys.difference(&initial_wallet_keys).collect(); + assert_eq!( + new_keys.len(), + 1, + "Expected exactly 1 new wallet after import, found {}", + new_keys.len() + ); + ctx.wallet_seed_hash = Some(*new_keys[0]); + } + println!( + " Wallet imported. Seed hash prefix: {}", + seed_hash_prefix(ctx.seed_hash()) + ); + + // Pop the import screen + harness.state_mut().screen_stack.pop(); + harness.run_steps(5); +} + +pub fn run(harness: &mut Harness<'_, AppState>, ctx: &mut TestContext) { + // 1. Read & validate mnemonic + let mnemonic = + std::env::var("E2E_WALLET_MNEMONIC").expect("E2E_WALLET_MNEMONIC env var required"); + let words: Vec<&str> = mnemonic.split_whitespace().collect(); + assert!( + [12, 15, 18, 21, 24].contains(&words.len()), + "Mnemonic has {} words, expected 12/15/18/21/24", + words.len() + ); + println!(" Mnemonic: {} words", words.len()); + + // 2. Dismiss welcome screen + dismiss_welcome_screen(harness); + harness.run_steps(SETTLE_STEPS); + + // 3. Switch to testnet and enable SPV mode + harness.state_mut().change_network(Network::Testnet); + harness.run_steps(SETTLE_STEPS); + let app_ctx = harness.state().current_app_context().clone(); + app_ctx.set_core_backend_mode(CoreBackendMode::Spv); + println!(" Switched to testnet (SPV mode)"); + + // 4. Check if wallet already exists (idempotent re-run support) + if let Some(seed_hash) = find_existing_e2e_wallet(harness) { + ctx.wallet_seed_hash = Some(seed_hash); + ctx.wallet_reused = true; + println!( + " Reusing existing wallet. Seed hash prefix: {}", + seed_hash_prefix(&seed_hash) + ); + + // Unlock the wallet so SPV can register its addresses. + // Wallets loaded from DB are in a closed state — SPV needs the + // seed bytes to build the bloom filter and discover transactions. + let app_ctx = harness.state().current_app_context().clone(); + let wallet_arc = { + let wallets = app_ctx.wallets().read().unwrap(); + wallets.get(&seed_hash).cloned() + }; + // Drop wallets lock before calling bootstrap/unlock methods + if let Some(wallet_arc) = wallet_arc { + { + let mut w = wallet_arc.write().unwrap(); + if !w.is_open() { + w.wallet_seed + .open_no_password() + .unwrap_or_else(|e| panic!("Failed to unlock reused wallet: {}", e)); + println!(" Wallet unlocked (no password)"); + } else { + println!(" Wallet already open"); + } + } + app_ctx.bootstrap_wallet_addresses(&wallet_arc); + app_ctx.handle_wallet_unlocked(&wallet_arc); + println!(" Wallet bootstrapped for SPV"); + } + } else { + // 5–10. Import wallet via UI + import_wallet_via_ui(harness, ctx, &words); + } + + // 11. Clear stale cached wallet state so the balance/UTXO check below only + // passes after a FRESH SPV reconciliation (not from DB-cached values + // left over from a previous run). + { + let app_ctx = harness.state().current_app_context(); + let wallets = app_ctx.wallets().read().unwrap(); + if let Some(seed_hash) = &ctx.wallet_seed_hash + && let Some(wallet) = wallets.get(seed_hash) + { + let mut w = wallet.write().unwrap(); + w.utxos.clear(); + w.update_spv_balances(0, 0, 0); + } + } + + // 12. Start SPV via AppContext::start_spv() which registers the reconcile + // and finality listeners BEFORE launching the sync loop. Without the + // reconcile listener, balance updates from SPV never propagate to wallets. + { + let app_ctx = harness.state().current_app_context().clone(); + app_ctx.start_spv().expect("SPV start failed"); + } + println!(" SPV sync started (with reconcile listener), waiting for completion..."); + + // 13. Poll for balance availability (primary) or SPV Running status. + // + // Balance is populated via the reconcile listener as soon as filters + blocks + // are scanned — this completes MUCH earlier than full SPV sync because + // masternode list validation (Syncing 0/N) can stall for extended periods + // on testnet with limited peers. We treat the balance being ready as + // sufficient to proceed; SpvStatus::Running is a nice-to-have. + let timeout_secs: u64 = std::env::var("E2E_SPV_TIMEOUT_SECS") + .ok() + .and_then(|s| s.parse().ok()) + .unwrap_or(SPV_SYNC_TIMEOUT_SECS); + + let start = Instant::now(); + let timeout = Duration::from_secs(timeout_secs); + let mut retry_count: u32 = 0; + let mut spv_synced = false; + let mut balance_ready = false; + let mut last_log = Instant::now(); + let mut last_spv_status = SpvStatus::Idle; + + while start.elapsed() < timeout { + harness.run_steps(60); // ~1s at 60fps + + let status = { + let app_ctx = harness.state().current_app_context(); + app_ctx.spv_manager().status() + }; + + // Check balance using max_balance() (sum of UTXOs in memory) rather than + // total_balance_duffs() which can return a cached DB value from a previous run. + // UTXOs are populated by reconcile_spv_wallets() which also syncs the SPV + // WalletManager state — so non-zero max_balance proves the WalletManager + // has UTXOs available for building transactions. + if !balance_ready { + let app_ctx = harness.state().current_app_context(); + let wallets = app_ctx.wallets().read().unwrap(); + if let Some(seed_hash) = &ctx.wallet_seed_hash + && let Some(wallet) = wallets.get(seed_hash) + { + let w = wallet.read().unwrap(); + let utxo_balance = w.max_balance(); + if utxo_balance >= MIN_BALANCE_DUFFS { + balance_ready = true; + println!( + " UTXOs ready: {} duffs ({:.8} DASH) at {:.0}s", + utxo_balance, + utxo_balance as f64 / 1e8, + start.elapsed().as_secs_f64(), + ); + } + } + } + + // Check whether SPV has synced headers (required for building transactions — + // the chain height is used for lock time calculation). + let header_height = status + .sync_progress + .as_ref() + .and_then(|p| p.headers().ok()) + .map(|h| h.current_height()) + .unwrap_or(0); + + // Check SPV status + last_spv_status = status.status; + match status.status { + SpvStatus::Running => { + spv_synced = true; + } + SpvStatus::Error => { + let err_msg = status.last_error.as_deref().unwrap_or("unknown"); + println!(" SPV error detected: {}", err_msg); + + if retry_count < PLATFORM_MAX_RETRIES { + retry_count += 1; + println!( + " Retrying SPV sync ({}/{})...", + retry_count, PLATFORM_MAX_RETRIES + ); + let app_ctx = harness.state().current_app_context().clone(); + app_ctx.spv_manager().stop(); + harness.run_steps(120); // ~2s cooldown + app_ctx + .start_spv() + .unwrap_or_else(|e| panic!("SPV restart failed: {}", e)); + } else { + panic!( + "SPV sync failed after {} retries. Last error: {}", + PLATFORM_MAX_RETRIES, err_msg + ); + } + } + _ => {} + } + + // We can proceed once: + // 1. Balance is ready (wallet has funds) + // 2. SPV is at least Syncing (network manager live, can broadcast) + // 3. Header height is known (required for transaction lock time) + // We don't require Running (full masternode validation can stall on testnet). + let spv_network_ready = matches!(status.status, SpvStatus::Syncing | SpvStatus::Running); + if balance_ready && spv_network_ready && header_height > 0 { + ctx.header_height = header_height; + println!( + " SPV {:?}, header_height={} — proceeding{}", + status.status, + header_height, + if spv_synced { "" } else { " without full sync" }, + ); + break; + } + + // Log progress every 30s so we can diagnose hangs + if last_log.elapsed() > Duration::from_secs(30) { + let stage_info = status + .sync_progress + .as_ref() + .map(|p| format!("state={:?}, synced={}", p.state(), p.is_synced())) + .unwrap_or_else(|| "no progress info".to_string()); + println!( + " SPV status: {:?} ({:.0}s elapsed, peers={}, {})", + status.status, + start.elapsed().as_secs_f64(), + status.connected_peers, + stage_info, + ); + last_log = Instant::now(); + } + } + + ctx.spv_synced = spv_synced; + + // Read final balance and print wallet diagnostics + { + let app_ctx = harness.state().current_app_context(); + let wallets = app_ctx.wallets().read().unwrap(); + let wallet = wallets + .get(ctx.seed_hash()) + .expect("Wallet not found by seed hash after SPV sync"); + let w = wallet.read().unwrap(); + ctx.balance_duffs = w.total_balance_duffs(); + println!( + " Wallet diagnostics: total_balance={}, confirmed={}, max_balance(utxos)={}, utxo_addrs={}, tx_count={}, is_open={}", + w.total_balance_duffs(), + w.confirmed_balance_duffs(), + w.max_balance(), + w.utxos.len(), + w.transactions.len(), + w.is_open(), + ); + } + + assert!( + balance_ready, + "Wallet balance ({} duffs / {:.8} DASH) did not reach minimum ({} duffs) \ + within {}s. SPV status: {:?}. Please fund the wallet.", + ctx.balance_duffs, + ctx.balance_duffs as f64 / 1e8, + MIN_BALANCE_DUFFS, + timeout_secs, + last_spv_status, + ); + + println!( + " Setup complete. Balance: {} duffs ({:.8} DASH){}", + ctx.balance_duffs, + ctx.balance_duffs as f64 / 1e8, + if ctx.wallet_reused { " (reused)" } else { "" } + ); +} diff --git a/tests/e2e/phases/phase_01_wallet_ui.rs b/tests/e2e/phases/phase_01_wallet_ui.rs new file mode 100644 index 000000000..230c5e595 --- /dev/null +++ b/tests/e2e/phases/phase_01_wallet_ui.rs @@ -0,0 +1,112 @@ +use crate::helpers::context::TestContext; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::ui::RootScreenType; +use dash_sdk::dpp::dashcore::Network; +use egui_kittest::Harness; +use egui_kittest::kittest::{NodeT, Queryable}; +use std::time::Duration; + +pub fn run(harness: &mut Harness<'_, AppState>, ctx: &mut TestContext) { + // 1. Navigate to wallets screen and verify sidebar label + verify_sidebar_label_and_navigate( + harness, + "Wallets", + RootScreenType::RootScreenWalletsBalances, + ); + + let has_wallet_label = wait_for_label(harness, "E2E Test Wallet", Duration::from_secs(10)); + assert!( + has_wallet_label, + "Wallet card should show 'E2E Test Wallet' alias" + ); + println!(" UI shows wallet card with alias"); + + // 2. Verify the wallet screen renders a balance label containing "Balance:" and "DASH". + // SPV runs continuously in the background and can update the wallet balance at any + // time via reconciliation callbacks. To avoid a race between the rendered UI and our + // wallet read, we: run a few frames to get a fresh render, immediately parse the UI + // balance, then immediately read the wallet balance — minimizing the time window. + harness.run_steps(5); // fresh render + let balance_label = harness + .query_all_by_label_contains("Balance:") + .find_map(|node| { + let text = node_text(&node.accesskit_node())?; + if text.contains("DASH") { Some(text) } else { None } + }) + .expect( + "Wallet screen must render a 'Balance:' label containing 'DASH'. \ + This means the UI rendering pipeline is broken or the wallet has no balance to display.", + ); + + // Parse numeric value from the label text. + // The label format is: " Balance: X.XXXXXXXX DASH" + // We extract the substring between "Balance:" and "DASH". + let ui_balance: f64 = { + let start = balance_label + .find("Balance:") + .expect("Balance: prefix not found in label"); + let after_prefix = &balance_label[start + "Balance:".len()..]; + let end = after_prefix + .find("DASH") + .expect("DASH suffix not found in label"); + after_prefix[..end] + .trim() + .parse::() + .expect("Could not parse balance value as a number") + }; + + // Read the wallet's live balance immediately after parsing the UI — + // both should reflect the same SPV state since no frames ran between them. + let live_balance_duffs = { + let app_ctx = harness.state().current_app_context(); + let wallets = app_ctx.wallets().read().unwrap(); + let wallet = wallets + .get(ctx.seed_hash()) + .expect("wallet not found by seed hash (phase 01 - did phase 00 import succeed?)"); + wallet.read().unwrap().total_balance_duffs() + }; + ctx.balance_duffs = live_balance_duffs; + let expected_balance = live_balance_duffs as f64 / 1e8; + + // Tolerance: SPV can still update between UI render and our read (background thread), + // so allow up to 1000 duffs (0.00001 DASH) of drift beyond floating-point rounding. + assert!( + (ui_balance - expected_balance).abs() < 0.00001, + "UI balance ({} DASH) doesn't match wallet balance ({} DASH / {} duffs)", + ui_balance, + expected_balance, + live_balance_duffs + ); + println!( + " Balance value verified: {:.8} DASH matches wallet state ({:.8} DASH)", + ui_balance, expected_balance + ); + + // 3. Get receive address and verify it's a valid testnet P2PKH address (starts with 'y') + { + let app_ctx = harness.state().current_app_context(); + let wallets = app_ctx.wallets().read().unwrap(); + let wallet = wallets + .get(ctx.seed_hash()) + .expect("wallet not found by seed hash (phase 01 - receive address)"); + let addr = wallet + .write() + .unwrap() + .receive_address(Network::Testnet, false, None) + .expect("Failed to get receive address from imported wallet"); + ctx.receive_address = Some(addr.to_string()); + } + let addr_str = ctx.receive_address.as_deref().unwrap(); + assert!( + addr_str.starts_with('y'), + "Testnet P2PKH receive address must start with 'y', got: {}", + addr_str + ); + println!(" Receive address: {} (valid testnet prefix)", addr_str); + + // 4. Verify Receive button is visible (proves wallet is selected) + verify_receive_button_visible(harness); + + println!(" Phase 01 complete: wallet UI renders balance, receive address valid"); +} diff --git a/tests/e2e/phases/phase_02_wallet.rs b/tests/e2e/phases/phase_02_wallet.rs new file mode 100644 index 000000000..7228305f4 --- /dev/null +++ b/tests/e2e/phases/phase_02_wallet.rs @@ -0,0 +1,41 @@ +use crate::helpers::context::TestContext; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::ui::RootScreenType; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::time::Duration; + +pub fn run(harness: &mut Harness<'_, AppState>, ctx: &mut TestContext) { + // SPV readiness gate — re-verify before building core transactions + ensure_spv_tx_ready(harness, ctx); + + // 1. Navigate to wallets screen and verify wallet card + navigate_to_screen(harness, RootScreenType::RootScreenWalletsBalances); + + let has_wallet = wait_for_label(harness, "E2E Test Wallet", Duration::from_secs(10)); + assert!( + has_wallet, + "Wallet card should show 'E2E Test Wallet' alias" + ); + println!(" Wallet card with alias visible"); + + // 2. Verify Send/Receive buttons visible (wallet is already selected from Phase 0/1) + assert!( + harness.query_by_label("Send").is_some(), + "Send button must be visible after selecting wallet" + ); + verify_receive_button_visible(harness); + println!(" Send/Receive buttons visible"); + + // ─── Send-to-self disabled ────────────────────────────────────────── + // The send-to-self test and post-send SPV reconciliation are disabled + // until dash-spv has mempool support. Without it, reconciliation + // requires a block confirmation which is too slow/unreliable for CI. + // TODO: Re-enable when SPV mempool support lands. + println!(" Send-to-self: SKIPPED (needs SPV mempool support)"); + + // Defensive reset: ensure clean screen state for next phase + navigate_to_screen(harness, RootScreenType::RootScreenWalletsBalances); + println!(" Phase 02 complete: wallet UI buttons verified"); +} diff --git a/tests/e2e/phases/phase_03_platform.rs b/tests/e2e/phases/phase_03_platform.rs new file mode 100644 index 000000000..b19063fab --- /dev/null +++ b/tests/e2e/phases/phase_03_platform.rs @@ -0,0 +1,169 @@ +use crate::helpers::context::TestContext; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::ui::{RootScreenType, ScreenType}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// Well-known DPNS contract ID (base58) present on all Dash Platform networks. +const DPNS_CONTRACT_ID: &str = "GWRSAVFMjXx8HpQFaNJMqBV7MBgMK4br5UESsB4S31Ec"; + +pub fn run(harness: &mut Harness<'_, AppState>, _ctx: &mut TestContext) { + run_dpns_lookup(harness); + run_contract_fetch(harness); + + println!( + " Platform info: network={:?}", + harness.state().chosen_network + ); + println!(" Phase 03 complete: platform reads verified"); +} + +fn run_dpns_lookup(harness: &mut Harness<'_, AppState>) { + for attempt in 1..=PLATFORM_MAX_RETRIES { + // Navigate to Load Identity screen each attempt (clean slate) + navigate_to_screen(harness, RootScreenType::RootScreenIdentities); + push_screen(harness, ScreenType::AddExistingIdentity); + + // Switch to "By DPNS Name" tab + harness + .query_by_label_contains("By DPNS Name") + .expect("'By DPNS Name' tab must be visible on Load Identity screen") + .click(); + harness.run_steps(15); + + // Type a DPNS name to search for + type_into_text_input(harness, 0, "quantum"); + + // Click "Search by Username" button + harness + .query_by_label("Search by Username") + .expect("'Search by Username' button must be visible on DPNS lookup screen") + .click(); + harness.run_steps(SETTLE_STEPS); + + // Wait for result + let completed = wait_until( + harness, + |h| { + h.query_by_label_contains("Successfully loaded").is_some() + || h.query_by_label_contains("Finished loading").is_some() + || h.query_by_label_contains("not found").is_some() + || h.query_by_label_contains("No identity").is_some() + || h.query_by_label_contains("Error").is_some() + || h.query_by_label_contains("Dismiss").is_some() + }, + PLATFORM_READ_TIMEOUT, + POLL_STEPS, + ); + if !completed { + println!( + " DPNS lookup timed out after {}s (attempt {}/{})", + PLATFORM_READ_TIMEOUT.as_secs(), + attempt, + PLATFORM_MAX_RETRIES + ); + pop_screen(harness); + if attempt == PLATFORM_MAX_RETRIES { + panic!( + "DPNS lookup timed out after {} attempts ({}s each)", + PLATFORM_MAX_RETRIES, + PLATFORM_READ_TIMEOUT.as_secs() + ); + } + continue; + } + + let is_success = harness + .query_by_label_contains("Successfully loaded") + .is_some() + || harness + .query_by_label_contains("Finished loading") + .is_some(); + let is_not_found = harness.query_by_label_contains("not found").is_some() + || harness + .query_by_label_contains("No identity found") + .is_some(); + let has_error = harness.query_by_label_contains("Error").is_some() + || harness.query_by_label_contains("Dismiss").is_some(); + + if is_success { + println!(" DPNS lookup succeeded: name \"quantum\" found"); + } else if is_not_found { + println!(" DPNS lookup completed: name not found (acceptable)"); + } else if has_error { + handle_retry_error(harness, "DPNS lookup", attempt, true); + continue; + } else { + panic!("DPNS lookup reached unexpected state"); + } + + // Common cleanup for success/not-found paths + dismiss_if_present(harness); + harness.state_mut().screen_stack.pop(); + harness.run_steps(5); + return; + } +} + +fn run_contract_fetch(harness: &mut Harness<'_, AppState>) { + for attempt in 1..=PLATFORM_MAX_RETRIES { + // Push AddContracts screen fresh each attempt so the text input is empty + navigate_to_screen(harness, RootScreenType::RootScreenDocumentQuery); + push_screen(harness, ScreenType::AddContracts); + + // Enter the DPNS contract ID + type_into_text_input(harness, 0, DPNS_CONTRACT_ID); + + // Click "Fetch Contracts" submit button + harness + .query_by_label("Fetch Contracts") + .or_else(|| harness.query_by_label_contains("Fetch Contracts")) + .expect("'Fetch Contracts' button must be visible on Add Contracts screen") + .click(); + harness.run_steps(SETTLE_STEPS); + + // Wait for fetch result + let completed = wait_until( + harness, + |h| { + h.query_by_label_contains("Successfully queried").is_some() + || h.query_by_label_contains("Error").is_some() + || h.query_by_label_contains("Dismiss").is_some() + || h.query_by_label_contains("not found").is_some() + }, + CONTRACT_FETCH_TIMEOUT, + POLL_STEPS, + ); + if !completed { + println!( + " Contract fetch timed out after {}s (attempt {}/{})", + CONTRACT_FETCH_TIMEOUT.as_secs(), + attempt, + PLATFORM_MAX_RETRIES + ); + pop_screen(harness); + if attempt == PLATFORM_MAX_RETRIES { + panic!( + "Contract fetch timed out after {} attempts ({}s each)", + PLATFORM_MAX_RETRIES, + CONTRACT_FETCH_TIMEOUT.as_secs() + ); + } + continue; + } + + if harness + .query_by_label_contains("Successfully queried") + .is_some() + { + println!(" Contract fetch succeeded: DPNS contract found"); + harness.state_mut().screen_stack.pop(); + harness.run_steps(5); + return; + } + + // Error path — classify and decide whether to retry + handle_retry_error(harness, "Contract fetch", attempt, true); + } +} diff --git a/tests/e2e/phases/phase_04_tokens.rs b/tests/e2e/phases/phase_04_tokens.rs new file mode 100644 index 000000000..2c8bc0c74 --- /dev/null +++ b/tests/e2e/phases/phase_04_tokens.rs @@ -0,0 +1,98 @@ +use crate::helpers::context::TestContext; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::ui::RootScreenType; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +pub fn run(harness: &mut Harness<'_, AppState>, _ctx: &mut TestContext) { + for attempt in 1..=PLATFORM_MAX_RETRIES { + // ─── 1. Navigate to token search screen (fresh each attempt) ─── + navigate_to_screen(harness, RootScreenType::RootScreenTokenSearch); + + let on_screen = + wait_for_label(harness, "Enter Keyword", std::time::Duration::from_secs(10)); + assert!( + on_screen, + "'Enter Keyword' label must be visible on token search screen" + ); + + if attempt == 1 { + println!(" Navigated to token search screen"); + } + + type_into_text_input(harness, 0, "dash"); + println!( + " Typed 'dash' in search input (attempt {}/{})", + attempt, PLATFORM_MAX_RETRIES + ); + + // ─── 2. Click search button and wait for results ─────────────── + harness + .query_by_label("Search") + .expect("Search button must be visible on token search screen") + .click(); + harness.run_steps(SETTLE_STEPS); + + let completed = wait_until( + harness, + |h| { + h.query_by_label_contains("Contract ID").is_some() + || h.query_by_label_contains("No tokens match").is_some() + || h.query_by_label_contains("Error").is_some() + }, + TOKEN_SEARCH_TIMEOUT, + POLL_STEPS, + ); + + if !completed { + println!( + " Token search timed out after {}s (attempt {}/{})", + TOKEN_SEARCH_TIMEOUT.as_secs(), + attempt, + PLATFORM_MAX_RETRIES + ); + if attempt == PLATFORM_MAX_RETRIES { + panic!( + "Token search timed out after {} attempts ({}s each)", + PLATFORM_MAX_RETRIES, + TOKEN_SEARCH_TIMEOUT.as_secs() + ); + } + continue; + } + + let has_results = harness.query_by_label_contains("Contract ID").is_some(); + let no_results = harness.query_by_label_contains("No tokens match").is_some(); + let has_error = harness.query_by_label_contains("Error").is_some(); + + if has_results { + println!(" Token search returned results for 'dash'"); + + // Clear search before leaving + if let Some(clear_btn) = harness.query_by_label_contains("Clear") { + clear_btn.click(); + harness.run_steps(5); + println!(" Search cleared"); + } + + println!(" Phase 04 complete: token search verified"); + return; + } + + if no_results { + panic!( + "Token search for 'dash' returned no results. \ + The keyword 'dash' consistently returns results on testnet — \ + empty results means the query mechanism or token contract is broken." + ); + } + + if has_error { + handle_retry_error(harness, "Token search", attempt, false); + continue; + } + + panic!("Token search reached unexpected state"); + } +} diff --git a/tests/e2e/phases/phase_05_identity.rs b/tests/e2e/phases/phase_05_identity.rs new file mode 100644 index 000000000..1f7717776 --- /dev/null +++ b/tests/e2e/phases/phase_05_identity.rs @@ -0,0 +1,144 @@ +use crate::helpers::context::TestContext; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::model::amount::Amount; +use dash_evo_tool::ui::identities::add_new_identity_screen::{AddNewIdentityScreen, FundingMethod}; +use dash_evo_tool::ui::identities::funding_common::WalletFundedScreenStep; +use dash_evo_tool::ui::{MessageType, Screen, ScreenLike, ScreenType}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +pub fn run(harness: &mut Harness<'_, AppState>, ctx: &mut TestContext) { + // Run validation sub-tests first — these are pure client-side (no network + // calls) and should run regardless of SPV sync state. + run_validation_tests(harness); + + // SPV readiness gate — identity creation builds an asset lock transaction + ensure_spv_tx_ready(harness, ctx); + + // ─── Actual identity creation disabled ────────────────────────────── + // Identity creation requires an asset lock transaction to be confirmed + // in a block for the proof. Without SPV mempool support, this depends + // on testnet block timing which is too slow/unreliable for CI. + // TODO: Re-enable when SPV mempool support lands. + println!(" Identity creation: SKIPPED (needs SPV mempool support for asset lock proof)"); + println!(" Phase 05 complete: validation tests passed"); +} + +/// Client-side validation tests for identity creation. +/// Each sub-test pushes a fresh AddNewIdentityScreen, configures an invalid +/// state, verifies the app handles it correctly, then pops the screen. +/// No network calls — these run in < 1 second total. +fn run_validation_tests(harness: &mut Harness<'_, AppState>) { + // ─── Sub-test A: Zero funding amount → action button not rendered ─── + // Note: The top panel breadcrumb always shows a "Create Identity" button, + // so we count matches: 1 = breadcrumb only, 2+ = breadcrumb + action button. + push_screen(harness, ScreenType::AddNewIdentity); + with_identity_screen_mut(harness, |screen| { + set_wallet_funded_ready(screen, "Zero Amount Test"); + screen.set_funding_amount(None); + }); + harness.run_steps(POLL_STEPS); + let count = harness.query_all_by_label("Create Identity").count(); + assert_eq!( + count, 1, + "Only breadcrumb should show 'Create Identity' when funding_amount is None (found {})", + count + ); + println!(" Validation: zero-amount action button correctly hidden"); + pop_screen(harness); + + // ─── Sub-test B: No master key → click silently rejected ──────────── + push_screen(harness, ScreenType::AddNewIdentity); + with_identity_screen_mut(harness, |screen| { + set_wallet_funded_ready(screen, "No Keys Test"); + screen.set_funding_amount(Some(Amount::new_dash(0.01))); + // Deliberately skip ensure_correct_identity_keys() + }); + harness.run_steps(POLL_STEPS); + // Skip the breadcrumb (nth 0) and click the action button (nth 1). + // With funding_amount set, the action button should be rendered. + let has_action_button = harness.query_all_by_label("Create Identity").count() >= 2; + assert!( + has_action_button, + "Action button must be present when funding_amount is set (found only breadcrumb)" + ); + harness + .query_all_by_label("Create Identity") + .nth(1) + .unwrap() + .click(); + harness.run_steps(POLL_STEPS); + assert_identity_step(harness, WalletFundedScreenStep::ReadyToCreate); + println!(" Validation: no-key click correctly rejected (stayed on ReadyToCreate)"); + pop_screen(harness); + + // ─── Sub-test C: Error message display and dismiss ────────────────── + push_screen(harness, ScreenType::AddNewIdentity); + with_identity_screen_mut(harness, |screen| { + screen.display_message("Simulated identity error", MessageType::Error); + }); + harness.run_steps(POLL_STEPS); + assert!( + harness + .query_by_label_contains("Error registering identity") + .is_some(), + "Error message must be visible after display_message(Error)" + ); + dismiss_if_present(harness); + harness.run_steps(SETTLE_STEPS); + assert!( + harness + .query_by_label_contains("Error registering identity") + .is_none(), + "Error message must be gone after dismiss" + ); + println!(" Validation: error message displayed and dismissed"); + pop_screen(harness); + + // ─── Sub-test D: Step resets to ReadyToCreate on error ────────────── + push_screen(harness, ScreenType::AddNewIdentity); + with_identity_screen_mut(harness, |screen| { + *screen.funding_method().write().unwrap() = FundingMethod::UseWalletBalance; + *screen.step().write().unwrap() = WalletFundedScreenStep::WaitingForAssetLock; + screen.display_message("Asset lock failed", MessageType::Error); + }); + harness.run_steps(POLL_STEPS); + assert_identity_step(harness, WalletFundedScreenStep::ReadyToCreate); + println!(" Validation: step reset to ReadyToCreate on error"); + pop_screen(harness); +} + +// ─── Validation test helpers ───────────────────────────────────────────────── + +/// Access the AddNewIdentityScreen at the top of the screen stack mutably. +/// Panics if the top screen is not an AddNewIdentityScreen. +fn with_identity_screen_mut( + harness: &mut Harness<'_, AppState>, + f: impl FnOnce(&mut AddNewIdentityScreen), +) { + let stack = &mut harness.state_mut().screen_stack; + match stack.last_mut() { + Some(Screen::AddNewIdentityScreen(screen)) => f(screen), + _ => panic!("Expected AddNewIdentityScreen on screen stack"), + } +} + +/// Assert the identity screen's current step matches the expected value. +fn assert_identity_step(harness: &mut Harness<'_, AppState>, expected: WalletFundedScreenStep) { + let stack = &harness.state().screen_stack; + match stack.last() { + Some(Screen::AddNewIdentityScreen(screen)) => { + let step = screen.step().read().unwrap(); + assert_eq!(*step, expected, "Identity screen step mismatch"); + } + _ => panic!("Expected AddNewIdentityScreen on screen stack"), + } +} + +/// Set common fields for a wallet-funded identity screen in ReadyToCreate state. +fn set_wallet_funded_ready(screen: &mut AddNewIdentityScreen, alias: &str) { + *screen.funding_method().write().unwrap() = FundingMethod::UseWalletBalance; + *screen.step().write().unwrap() = WalletFundedScreenStep::ReadyToCreate; + screen.set_alias_input(alias.to_string()); +} diff --git a/tests/e2e/phases/phase_06_dpns.rs b/tests/e2e/phases/phase_06_dpns.rs new file mode 100644 index 000000000..70404b0d2 --- /dev/null +++ b/tests/e2e/phases/phase_06_dpns.rs @@ -0,0 +1,140 @@ +use crate::helpers::context::TestContext; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::ui::identities::register_dpns_name_screen::RegisterDpnsNameSource; +use dash_evo_tool::ui::{Screen, ScreenType}; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn run(harness: &mut Harness<'_, AppState>, ctx: &mut TestContext) { + let identity_id = ctx + .identity_id + .expect("identity_id must be set (did Phase 5 complete?)"); + + // Generate a unique, non-contested DPNS name. + // Contains digits > 1 to avoid contested name fees. + let unix_secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap() + .as_secs(); + let dpns_name = format!("e2etest{}", unix_secs); + println!(" Target DPNS name: {}", dpns_name); + + for attempt in 1..=PLATFORM_MAX_RETRIES { + println!( + " DPNS registration attempt {}/{}", + attempt, PLATFORM_MAX_RETRIES + ); + + // ─── 1. Push RegisterDpnsName screen ───────────────────────────── + push_screen( + harness, + ScreenType::RegisterDpnsName(RegisterDpnsNameSource::Identities), + ); + + // ─── 2. Reload identities (just created in Phase 5) and configure ─ + { + let stack = &mut harness.state_mut().screen_stack; + if let Some(Screen::RegisterDpnsNameScreen(screen)) = stack.last_mut() { + // Refresh identity list from database -- the identity was just + // created in Phase 5 and may not have been loaded by the + // screen's constructor. + screen.qualified_identities = screen + .app_context + .load_local_user_identities() + .expect("Failed to load local user identities from database"); + + // Hard assert: identity list must not be empty + assert!( + !screen.qualified_identities.is_empty(), + "No identities in database. Did Phase 5 complete?" + ); + + screen.select_identity(identity_id); + + // Hard assert: identity must be selected + assert!( + screen.selected_qualified_identity.is_some(), + "select_identity({}) failed — identity not in list of {} identities", + identity_id, + screen.qualified_identities.len() + ); + + screen.show_identity_selector = false; + screen.set_name_input(dpns_name.clone()); + } else { + panic!("Expected RegisterDpnsNameScreen on screen stack"); + } + } + + // ─── 3. Let UI render with configured state ────────────────────── + harness.run_steps(POLL_STEPS); + + // Verify UI rendered the expected state. + // The top panel breadcrumb also has "Register Name", so count >= 2 + // means the action button is rendered. + let reg_count = harness.query_all_by_label("Register Name").count(); + assert!( + reg_count >= 2, + "'Register Name' action button must be visible (found {} matches, \ + need >= 2: breadcrumb + button)", + reg_count + ); + println!(" Screen configured: Register Name button visible"); + + // ─── 4. Click "Register Name" action button (skip breadcrumb) ─── + harness + .query_all_by_label("Register Name") + .nth(1) + .expect("Register Name action button not found") + .click(); + harness.run_steps(SETTLE_STEPS); + + // ─── 5. Wait for success or error ──────────────────────────────── + let completed = wait_until( + harness, + |h| { + h.query_by_label_contains("DPNS Name Registered!").is_some() + || h.query_by_label_contains("Error").is_some() + }, + DPNS_REGISTRATION_TIMEOUT, + POLL_STEPS, + ); + + if !completed { + println!( + " DPNS registration timed out after {}s", + DPNS_REGISTRATION_TIMEOUT.as_secs() + ); + harness.state_mut().screen_stack.pop(); + harness.run_steps(5); + if attempt == PLATFORM_MAX_RETRIES { + panic!( + "DPNS registration timed out after {} attempts ({}s each)", + PLATFORM_MAX_RETRIES, + DPNS_REGISTRATION_TIMEOUT.as_secs() + ); + } + continue; + } + + // ─── 6. Check for success ──────────────────────────────────────── + if harness + .query_by_label_contains("DPNS Name Registered!") + .is_some() + { + ctx.dpns_name = Some(format!("{}.dash", dpns_name)); + println!(" DPNS name registered: {}.dash", dpns_name); + + harness.state_mut().screen_stack.pop(); + harness.run_steps(SETTLE_STEPS); + + println!(" Phase 06 complete: DPNS registration verified"); + return; + } + + // ─── Error path: classify, dismiss, retry ───────────────────────── + handle_retry_error(harness, "DPNS registration", attempt, true); + } +} diff --git a/tests/e2e/phases/phase_07_teardown.rs b/tests/e2e/phases/phase_07_teardown.rs new file mode 100644 index 000000000..d3893cea0 --- /dev/null +++ b/tests/e2e/phases/phase_07_teardown.rs @@ -0,0 +1,93 @@ +use crate::helpers::context::{TestContext, seed_hash_prefix}; +use crate::helpers::harness::*; +use dash_evo_tool::app::AppState; +use dash_evo_tool::spv::SpvStatus; +use dash_sdk::dpp::platform_value::string_encoding::Encoding; +use egui_kittest::Harness; + +pub fn run(harness: &mut Harness<'_, AppState>, ctx: &TestContext) { + // ─── 1. Stop SPV sync ────────────────────────────────────────────── + harness.state().current_app_context().spv_manager().stop(); + println!(" SPV sync stopped"); + + // ─── 2. Wait for SPV to reach a terminal state (Stopped/Idle/Error) ─ + let spv_stopped = wait_until( + harness, + |h| { + let app_ctx = h.state().current_app_context(); + matches!( + app_ctx.spv_manager().status().status, + SpvStatus::Stopped | SpvStatus::Idle | SpvStatus::Error + ) + }, + SPV_STOP_TIMEOUT, + 60, + ); + assert!( + spv_stopped, + "SPV must reach a terminal state after stop (still active after {}s)", + SPV_STOP_TIMEOUT.as_secs() + ); + let final_status = harness.state().current_app_context().spv_manager().status(); + println!(" SPV status after stop: {:?}", final_status.status); + + // ─── 3. Remove test identity and wallet from database ────────────── + let app_ctx = harness.state().current_app_context(); + + if let Some(identity_id) = &ctx.identity_id { + match app_ctx + .db() + .delete_local_qualified_identity(identity_id, app_ctx) + { + Ok(()) => println!(" Removed E2E identity from database"), + Err(e) => println!(" Warning: could not remove E2E identity: {}", e), + } + } + + if let Some(seed_hash) = &ctx.wallet_seed_hash { + match app_ctx.remove_wallet(seed_hash) { + Ok(()) => println!(" Removed test wallet from database"), + Err(e) => println!(" Warning: could not remove test wallet: {}", e), + } + } + + // ─── 4. Log test summary ───────────────────────────────────────── + println!(); + println!(" ======================================="); + println!(" E2E Test Suite Summary"); + println!(" ======================================="); + println!(" Network: {}", ctx.network); + println!( + " Wallet seed: {}", + ctx.wallet_seed_hash + .as_ref() + .map(seed_hash_prefix) + .unwrap_or_else(|| "N/A".to_string()) + ); + println!( + " Balance: {} duffs ({:.8} DASH)", + ctx.balance_duffs, + ctx.balance_duffs as f64 / 1e8 + ); + println!(" SPV synced: {}", ctx.spv_synced); + println!(" Header height: {}", ctx.header_height); + println!(" Wallet reused: {}", ctx.wallet_reused); + println!( + " Receive addr: {}", + ctx.receive_address.as_deref().unwrap_or("N/A") + ); + println!( + " Identity ID: {}", + ctx.identity_id + .map(|id| id.to_string(Encoding::Base58)) + .unwrap_or_else(|| "N/A".to_string()) + ); + println!( + " DPNS name: {}", + ctx.dpns_name.as_deref().unwrap_or("N/A") + ); + println!(" ======================================="); + println!(); + + println!(" Phase 07 complete: teardown finished"); +} diff --git a/tests/e2e/phases/phase_smoke.rs b/tests/e2e/phases/phase_smoke.rs new file mode 100644 index 000000000..6901266ee --- /dev/null +++ b/tests/e2e/phases/phase_smoke.rs @@ -0,0 +1,55 @@ +use dash_evo_tool::app::AppState; +use dash_evo_tool::spv::SpvStatus; +use egui_kittest::Harness; + +/// Read-only smoke tests that verify the app boots correctly. +/// Runs BEFORE phase_00_setup — no TestContext needed. +pub fn run(harness: &mut Harness<'_, AppState>) { + // 1. Harness is usable (AppState::new() succeeded) + harness.run_steps(10); + println!(" Harness usable: OK"); + + // 2. AppContext is accessible + let app_ctx = harness.state().current_app_context(); + println!(" AppContext valid: OK"); + + // 3. SPV is idle or stopped at boot (not actively syncing) + let spv_status = app_ctx.spv_manager().status().status; + assert!( + matches!(spv_status, SpvStatus::Idle | SpvStatus::Stopped), + "SPV should be Idle or Stopped at boot, got: {:?}", + spv_status + ); + println!(" SPV idle at boot: {:?}", spv_status); + + // 4. Wallets lock is accessible (no deadlock) + let wallet_count = app_ctx.wallets().read().unwrap().len(); + println!(" Wallets lock accessible: {} wallet(s)", wallet_count); + + // 5. Network is readable + let network = harness.state().chosen_network; + println!(" Network readable: {:?}", network); + + // 6. Welcome screen consistency: if show_welcome_screen, then welcome_screen.is_some() + let show = harness.state().show_welcome_screen; + let has_screen = harness.state().welcome_screen.is_some(); + if show { + assert!( + has_screen, + "show_welcome_screen is true but welcome_screen is None" + ); + } + println!( + " Welcome screen consistent: show={}, present={}", + show, has_screen + ); + + // 7. Testnet context exists (required for E2E) + assert!( + harness.state().testnet_app_context.is_some(), + "Testnet AppContext must exist for E2E tests" + ); + println!(" Testnet context exists: OK"); + + println!(" Smoke tests passed!"); +} diff --git a/tests/e2e/wallet_flows.rs b/tests/e2e/wallet_flows.rs deleted file mode 100644 index d375740d4..000000000 --- a/tests/e2e/wallet_flows.rs +++ /dev/null @@ -1,88 +0,0 @@ -//! E2E Tests for Wallet Flows -//! -//! These tests verify complete user journeys related to wallets, -//! including balance display and state management. - -use egui_kittest::Harness; - -/// Test that the app starts with proper wallet state initialization -#[test] -fn test_wallet_state_initialization() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - harness.run_steps(20); -} - -/// Test that wallet balance display renders correctly -#[test] -fn test_wallet_balance_rendering() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(100).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - - // Run enough frames to fully initialize - harness.run_steps(30); -} - -/// Test wallet operations don't cause UI freezes -#[test] -fn test_wallet_ui_responsiveness() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - harness.set_size(egui::vec2(1024.0, 768.0)); - - // Run many frames to test UI responsiveness - for batch in 0..10 { - harness.run_steps(15); - // Each batch should complete without hanging - let _ = batch; - } -} - -/// Test that the app handles rapid resizing during wallet views -#[test] -fn test_wallet_resize_stability() { - let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); - let _guard = rt.enter(); - - let mut harness = Harness::builder().with_max_steps(150).build_eframe(|ctx| { - dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()) - .expect("Failed to create AppState") - .with_animations(false) - }); - - // Test various resize scenarios - let sizes = [ - egui::vec2(800.0, 600.0), - egui::vec2(1200.0, 900.0), - egui::vec2(640.0, 480.0), - egui::vec2(1920.0, 1080.0), - ]; - - for size in sizes { - harness.set_size(size); - harness.run_steps(10); - } -} diff --git a/tests/kittest/interactions.rs b/tests/kittest/interactions.rs new file mode 100644 index 000000000..cc8b29d39 --- /dev/null +++ b/tests/kittest/interactions.rs @@ -0,0 +1,523 @@ +use dash_evo_tool::ui::RootScreenType; +use dash_evo_tool::ui::welcome_screen::WelcomeScreen; +use egui_kittest::Harness; +use egui_kittest::kittest::Queryable; + +/// Create a test harness with the standard configuration. +/// Returns the runtime (must be kept alive) and the harness. +/// Call `rt.enter()` on the returned runtime if the test triggers actions +/// that spawn tokio tasks (e.g., button clicks that dispatch AppActions). +fn create_test_harness() -> ( + tokio::runtime::Runtime, + Harness<'static, dash_evo_tool::app::AppState>, +) { + let rt = tokio::runtime::Runtime::new().expect("Failed to create tokio runtime"); + let _guard = rt.enter(); + let harness = Harness::builder().with_max_steps(200).build_eframe(|ctx| { + dash_evo_tool::app::AppState::new(ctx.egui_ctx.clone()).with_animations(false) + }); + (rt, harness) +} + +/// Helper to dismiss the welcome screen and set up for main app testing. +fn dismiss_welcome_screen(harness: &mut Harness<'_, dash_evo_tool::app::AppState>) { + harness.state_mut().show_welcome_screen = false; + harness.state_mut().welcome_screen = None; +} + +/// Helper to force-enable the welcome screen regardless of DB state. +fn enable_welcome_screen(harness: &mut Harness<'_, dash_evo_tool::app::AppState>) { + let ctx = harness.state().mainnet_app_context.clone(); + harness.state_mut().show_welcome_screen = true; + harness.state_mut().welcome_screen = Some(WelcomeScreen::new(ctx)); +} + +// ============================================================================= +// Welcome Screen Rendering Tests +// ============================================================================= + +/// Test that when the welcome screen is enabled, it renders the welcome content +#[test] +fn test_welcome_screen_renders_when_enabled() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + enable_welcome_screen(&mut harness); + harness.run_steps(10); + + assert!( + harness.state().show_welcome_screen, + "Welcome screen should be active" + ); + + let title = harness.query_by_label_contains("Welcome to Dash Evo Tool"); + assert!( + title.is_some(), + "Welcome screen title 'Welcome to Dash Evo Tool' should be visible" + ); +} + +/// Test that the welcome screen shows its subtitle and instruction text +#[test] +fn test_welcome_screen_text_content() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + enable_welcome_screen(&mut harness); + harness.run_steps(10); + + let subtitle = harness.query_by_label_contains("Your gateway to decentralized data"); + assert!( + subtitle.is_some(), + "Welcome screen subtitle should be visible" + ); + + let instruction = harness.query_by_label_contains("Select an option to get started"); + assert!( + instruction.is_some(), + "Welcome screen instruction text should be visible" + ); +} + +/// Test that the welcome screen shows all three action cards +#[test] +fn test_welcome_screen_action_cards_present() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + enable_welcome_screen(&mut harness); + harness.run_steps(10); + + // Action card titles + assert!( + harness.query_by_label_contains("Create Wallet").is_some(), + "Create Wallet card should be visible" + ); + assert!( + harness.query_by_label_contains("Import Wallet").is_some(), + "Import Wallet card should be visible" + ); + assert!( + harness.query_by_label_contains("Just Explore").is_some(), + "Just Explore card should be visible" + ); + + // Card descriptions + assert!( + harness + .query_by_label_contains("Start fresh with a new HD wallet") + .is_some(), + "Create Wallet description should be visible" + ); + assert!( + harness + .query_by_label_contains("Load a wallet you already have") + .is_some(), + "Import Wallet description should be visible" + ); + assert!( + harness + .query_by_label_contains("Explore without setting up") + .is_some(), + "Just Explore description should be visible" + ); +} + +// ============================================================================= +// Welcome Screen Click Tests +// (These need an active tokio runtime for the OnboardingComplete action) +// ============================================================================= + +/// Test that clicking "Just Explore" on the welcome screen dismisses it +#[test] +fn test_welcome_screen_just_explore_click() { + let (rt, mut harness) = create_test_harness(); + let _guard = rt.enter(); + harness.set_size(egui::vec2(1024.0, 768.0)); + enable_welcome_screen(&mut harness); + harness.run_steps(10); + + let explore_label = harness + .query_by_label_contains("Explore without setting up") + .expect("'Explore without setting up' label must be visible on welcome screen"); + explore_label.click(); + harness.run_steps(5); + + assert!( + !harness.state().show_welcome_screen, + "Welcome screen should be dismissed after clicking Just Explore" + ); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenDashPayProfile, + "Should navigate to DashPay profile after Just Explore" + ); +} + +/// Test that clicking "Create Wallet" navigates to wallets with add screen +#[test] +fn test_welcome_screen_create_wallet_click() { + let (rt, mut harness) = create_test_harness(); + let _guard = rt.enter(); + harness.set_size(egui::vec2(1024.0, 768.0)); + enable_welcome_screen(&mut harness); + harness.run_steps(10); + + let label = harness + .query_by_label_contains("Start fresh with a new HD wallet") + .expect("'Start fresh with a new HD wallet' label must be visible on welcome screen"); + label.click(); + harness.run_steps(5); + + assert!( + !harness.state().show_welcome_screen, + "Welcome screen should be dismissed after clicking Create Wallet" + ); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenWalletsBalances, + "Should navigate to Wallets screen after Create Wallet" + ); + assert!( + !harness.state().screen_stack.is_empty(), + "Screen stack should have the AddNewWallet screen pushed" + ); +} + +/// Test that clicking "Import Wallet" navigates to wallets with import screen +#[test] +fn test_welcome_screen_import_wallet_click() { + let (rt, mut harness) = create_test_harness(); + let _guard = rt.enter(); + harness.set_size(egui::vec2(1024.0, 768.0)); + enable_welcome_screen(&mut harness); + harness.run_steps(10); + + let label = harness + .query_by_label_contains("Load a wallet you already have") + .expect("'Load a wallet you already have' label must be visible on welcome screen"); + label.click(); + harness.run_steps(5); + + assert!( + !harness.state().show_welcome_screen, + "Welcome screen should be dismissed after clicking Import Wallet" + ); + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenWalletsBalances, + "Should navigate to Wallets screen after Import Wallet" + ); + assert!( + !harness.state().screen_stack.is_empty(), + "Screen stack should have the ImportMnemonic screen pushed" + ); +} + +// ============================================================================= +// Screen Navigation Tests +// ============================================================================= + +/// Test that programmatic screen switching renders each major screen without crashing +#[test] +fn test_switch_to_all_major_screens() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + harness.run_steps(5); + + let screens = [ + RootScreenType::RootScreenWalletsBalances, + RootScreenType::RootScreenIdentities, + RootScreenType::RootScreenDocumentQuery, + RootScreenType::RootScreenMyTokenBalances, + RootScreenType::RootScreenNetworkChooser, + RootScreenType::RootScreenToolsPlatformInfoScreen, + RootScreenType::RootScreenDashPayProfile, + RootScreenType::RootScreenDPNSActiveContests, + RootScreenType::RootScreenToolsProofLogScreen, + RootScreenType::RootScreenToolsMasternodeListDiffScreen, + ]; + + for screen_type in screens { + harness.state_mut().selected_main_screen = screen_type; + harness.run_steps(10); + + assert_eq!( + harness.state().selected_main_screen, + screen_type, + "Screen should be set to {:?}", + screen_type + ); + } +} + +/// Test switching between screens preserves stability (no crash) +#[test] +fn test_screen_switching_round_trip() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenWalletsBalances; + harness.run_steps(10); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenIdentities; + harness.run_steps(10); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenNetworkChooser; + harness.run_steps(10); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenWalletsBalances; + harness.run_steps(10); + + assert_eq!( + harness.state().selected_main_screen, + RootScreenType::RootScreenWalletsBalances + ); +} + +/// Test that rapid screen switching doesn't cause issues +#[test] +fn test_rapid_screen_switching() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + harness.run_steps(5); + + let screens = [ + RootScreenType::RootScreenWalletsBalances, + RootScreenType::RootScreenIdentities, + RootScreenType::RootScreenMyTokenBalances, + RootScreenType::RootScreenDocumentQuery, + RootScreenType::RootScreenNetworkChooser, + ]; + + for screen in screens.iter().cycle().take(20) { + harness.state_mut().selected_main_screen = *screen; + harness.run_steps(1); + } + + harness.run_steps(5); +} + +/// Test that the screen stack starts empty and remains empty on main screens +#[test] +fn test_screen_stack_empty_on_main_screens() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenWalletsBalances; + harness.run_steps(10); + assert!( + harness.state().screen_stack.is_empty(), + "Screen stack should be empty on main wallets screen" + ); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenIdentities; + harness.run_steps(10); + assert!( + harness.state().screen_stack.is_empty(), + "Screen stack should be empty on main identities screen" + ); +} + +// ============================================================================= +// Left Panel / UI Element Tests +// ============================================================================= + +/// Test that the left panel navigation labels are visible using contains-based matching +#[test] +fn test_left_panel_navigation_labels_visible() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + // Start on a screen that won't have conflicting labels + harness.state_mut().selected_main_screen = RootScreenType::RootScreenDashPayProfile; + harness.run_steps(15); + + // These labels appear in the left panel navigation. + // We use query_all_by_label to allow multiple matches (e.g., label + screen content). + let nav_labels = [ + "Wallets", + "Identities", + "Contracts", + "Tokens", + "Tools", + "Settings", + ]; + + for label in nav_labels { + let mut nodes = harness.query_all_by_label(label); + assert!( + nodes.next().is_some(), + "Left panel should contain navigation label '{label}'" + ); + } +} + +/// Test that the wallets screen shows expected action buttons +#[test] +fn test_wallets_screen_has_action_buttons() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1280.0, 800.0)); + dismiss_welcome_screen(&mut harness); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenWalletsBalances; + harness.run_steps(15); + + let mut import_nodes = harness.query_all_by_label_contains("Import Wallet"); + let mut create_nodes = harness.query_all_by_label_contains("Create Wallet"); + + assert!( + import_nodes.next().is_some() && create_nodes.next().is_some(), + "Wallets screen should show both Import Wallet and Create Wallet buttons" + ); +} + +/// Test that the network chooser screen shows expected configuration labels +#[test] +fn test_network_chooser_shows_config_labels() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + + harness.state_mut().selected_main_screen = RootScreenType::RootScreenNetworkChooser; + harness.run_steps(15); + + // The network chooser always shows "Network:" and only shows + // "Connection Type:" when developer mode is enabled. + let network_label = harness.query_by_label_contains("Network:"); + assert!( + network_label.is_some(), + "Network chooser should show 'Network:' label" + ); + + let connection_label = harness.query_by_label_contains("Connection Type:"); + let is_developer_mode = harness.state().current_app_context().is_developer_mode(); + if is_developer_mode { + assert!( + connection_label.is_some(), + "Network chooser should show 'Connection Type:' label in developer mode" + ); + } else { + assert!( + connection_label.is_none(), + "Network chooser should hide 'Connection Type:' label when developer mode is disabled" + ); + } +} + +// ============================================================================= +// Resize and Stress Tests +// ============================================================================= + +/// Test rendering at extreme window sizes doesn't crash any screen +#[test] +fn test_extreme_window_sizes_per_screen() { + let (_rt, mut harness) = create_test_harness(); + dismiss_welcome_screen(&mut harness); + + let screens = [ + RootScreenType::RootScreenWalletsBalances, + RootScreenType::RootScreenIdentities, + RootScreenType::RootScreenNetworkChooser, + ]; + + let extreme_sizes = [ + egui::vec2(320.0, 240.0), // Very small + egui::vec2(3840.0, 2160.0), // 4K + ]; + + for screen in screens { + for size in extreme_sizes { + harness.state_mut().selected_main_screen = screen; + harness.set_size(size); + harness.run_steps(5); + } + } +} + +/// Test DashPay-related screens render without crashing +#[test] +fn test_dashpay_screens_render() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + + let dashpay_screens = [ + RootScreenType::RootScreenDashPayProfile, + RootScreenType::RootScreenDashPayContacts, + RootScreenType::RootScreenDashPayPayments, + ]; + + for screen in dashpay_screens { + harness.state_mut().selected_main_screen = screen; + harness.run_steps(10); + assert_eq!(harness.state().selected_main_screen, screen); + } +} + +/// Test token-related screens render without crashing +#[test] +fn test_token_screens_render() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + + let token_screens = [ + RootScreenType::RootScreenMyTokenBalances, + RootScreenType::RootScreenTokenSearch, + RootScreenType::RootScreenTokenCreator, + ]; + + for screen in token_screens { + harness.state_mut().selected_main_screen = screen; + harness.run_steps(10); + assert_eq!(harness.state().selected_main_screen, screen); + } +} + +/// Test tools-related screens render without crashing +#[test] +fn test_tools_screens_render() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + + let tools_screens = [ + RootScreenType::RootScreenToolsPlatformInfoScreen, + RootScreenType::RootScreenToolsProofLogScreen, + RootScreenType::RootScreenToolsTransitionVisualizerScreen, + RootScreenType::RootScreenToolsDocumentVisualizerScreen, + RootScreenType::RootScreenToolsProofVisualizerScreen, + RootScreenType::RootScreenToolsContractVisualizerScreen, + RootScreenType::RootScreenToolsGroveSTARKScreen, + RootScreenType::RootScreenToolsAddressBalanceScreen, + ]; + + for screen in tools_screens { + harness.state_mut().selected_main_screen = screen; + harness.run_steps(10); + assert_eq!(harness.state().selected_main_screen, screen); + } +} + +/// Test DPNS-related screens render without crashing +#[test] +fn test_dpns_screens_render() { + let (_rt, mut harness) = create_test_harness(); + harness.set_size(egui::vec2(1024.0, 768.0)); + dismiss_welcome_screen(&mut harness); + + let dpns_screens = [ + RootScreenType::RootScreenDPNSActiveContests, + RootScreenType::RootScreenDPNSPastContests, + RootScreenType::RootScreenDPNSOwnedNames, + RootScreenType::RootScreenDPNSScheduledVotes, + ]; + + for screen in dpns_screens { + harness.state_mut().selected_main_screen = screen; + harness.run_steps(10); + assert_eq!(harness.state().selected_main_screen, screen); + } +} diff --git a/tests/kittest/main.rs b/tests/kittest/main.rs index 4a11e625f..f139c5213 100644 --- a/tests/kittest/main.rs +++ b/tests/kittest/main.rs @@ -1,5 +1,6 @@ mod create_asset_lock_screen; mod identities_screen; +mod interactions; mod message_banner; mod network_chooser; mod startup;