From dfa51657661f9868771030940f11b8b72f714274 Mon Sep 17 00:00:00 2001 From: David Adegoke <64401859+Blazebrain@users.noreply.github.com> Date: Wed, 23 Sep 2026 18:11:37 +0100 Subject: [PATCH] Integration tests (#3477) * fix android CI * feat: Add integration test harness and new-ui test keys This change: - Adds a BaseRobot with bounded waits to replace hard sleeps in integration tests - Adds AppLauncher, TestConfig and TestWallets as the core test harness - Adds onboarding flows composing the existing onboarding robots - Adds robots for the new dashboard, home page and lightning username page - Adds a tier0 onboarding create suite targeting the new UI - Applies the declared navbar action keys that were never attached to the InkWell - Fixes AlertWithOneAction dropping its buttonKey since the alert redesign - Adds stable ValueKeys to the home page action row, wallet name, top bar and sync bar - Makes the legacy tap and text entry helpers wait for widgets instead of sleeping * fix: Test secrets drift and integration test runner parameterization This change: - Adds missing test wallet seed and receive address entries to the secrets generator - Fixes trailing space typos in two secret key names - Adds bloc_test to pubspec_base.yaml so pubspec regeneration keeps it - Parameterizes the integration test runner with suite dir, tier, platform and device knobs - Fixes the runner aborting on first failure and misaligning durations after retries - Adds adb data reset so Android suites start from a fresh install * feat: Android emulator integration test workflows for dev PRs This change: - Adds a reusable integration test workflow building the app with prebuilt native deps and running the suites on an Android emulator - Adds the PR gate workflow triggering fund-free tier0 suites on pull requests into dev - Fixes the old workflow discarding the test runner exit code so failures now fail the job - Removes the disabled automated integration test workflow on the stale flutter image - Removes the commit-message gated Linux desktop test steps, unit tests stay - Removes the vestigial emulator config from the reusable build workflow * feat: Revive fund-free integration test suites for the new UI This change: - Adds tier0 suites for restore, seed confirmation, wallet switching, receive address, settings navigation and the show keys auth gate - Adds tier1 suites for sync status, swap quotes and transaction history running non-blocking on PRs - Adds robots for the settings, receive, swap and send sheets - Adds auth, wallet switching and restore flows - Applies widget keys to settings rows through their route names, they were declared but never attached - Adds keys to the swap amount fields, send inputs, confirm swiper and history tiles - Adds an integrationTest wrapper containing background async app errors that are not test failures - Makes taps wait out running route transitions before hitting the screen - Adds a watchdog to the test runner so a wedged driver fails bounded instead of hanging - Removes the old UI suites, robots, helpers and the stale widget_test counter template * chore: Add integration testing docs and suite template This change: - Adds docs/INTEGRATION_TESTS.md covering the architecture, local runs, tier rules and the flakiness playbook - Adds a copy-paste suite template teammates start new feature tests from * feat: Manual funds integration test workflow with funded-chain auto-discovery This change: - Adds a funds suite sending a real self transaction on every funded chain - Adds a funds suite creating a real swap and broadcasting its deposit - Discovers funded chains from the funded wallet seeds map, adding a seed to the secret adds the chain - Adds the manual workflow dispatched from the Actions tab with flow, chain and recording inputs - Gates the run behind the funds-tests environment and posts the result to slack - Keeps funded seeds out of PR builds, the checked in seed map is empty * fix: Harden integration test harness from review findings This change: - Fixes body failures being swallowed by the guarded test zone, failing suites hung until the watchdog instead of failing fast - Fixes multi chain CHAINS dispatch values being split apart by the define forwarding - Fixes the swap limits wait timing out on pairs without a provider minimum - Makes unknown chain names in CHAINS fail with a readable message - Keys favorite token rows by index, duplicate token symbols crashed on duplicate keys - Guards the recording encryption so a keyserver flake cannot eat the test result - Removes the dead security and backup robot and corrects the screenshot claim in the docs * chore: Hold two funded seeds per chain in the funded wallets map This change: - Changes the funded wallet seeds map to a list of seeds per chain - Keeps the first seed as the primary for sends and swaps, the second is reserved for cross wallet scenarios * feat: Try each funded wallet per chain until one has a balance This change: - Adds a funds flow restoring a chain's funded wallets in turn and using the first with a spendable balance - Treats a wallet that finishes syncing while still empty as drained so the next seed gets its turn quickly - Fails a chain with a top up message when every funded wallet of that chain is empty * reformat integration test related files * fix: Run the integration test emulator on the kvm capable runner This change: - Moves the test job to the linux-amd64 runner the old emulator workflow targeted, the android builders have no /dev/kvm - Exposes /dev/kvm through a docker device flag instead of a volume, a bind mount materializes a directory when the host node is missing - Fails the kvm check in seconds with a clear error instead of waiting out the emulator boot timeout * fix: Queue the integration test job on the live android runner pool This change: - Moves the job back to the android runner labels, no runner carries the old linux-amd64 label anymore so the job queued forever - Keeps the kvm device flag and check so a host without kvm fails immediately with a clear error * feat: Run the integration test gate on github hosted runners This change: - Moves the test job to ubuntu-latest which has working kvm, the self-hosted android builders do not expose /dev/kvm yet - Runs directly on the vm since the cake container image does not fit hosted disk, flutter is pinned through the flutter action - Adds rust android targets for the breez cargokit build and java 17 for gradle - Keeps the prebuilt native deps from the ghcr docker cache, they pull fine on hosted docker - Adds pub, gradle and flutter caching plus a disk cleanup step - Keeps every input, the runner script, tier1 soft run, recording and artifacts unchanged * fix: Install flutter from the same source as the ci container image This change: - Clones the flutter 3.41.9 tag directly like the container image instead of using the release archive - The archive ships a stricter dart that rejects the checked in mweb ffi bindings, the tag clone compiles them - Caches the cloned sdk between runs * fix: Regenerate the mweb ffi bindings with the workflow dart This change: - Regenerates cw_mweb/lib/generated_bindings.g.dart after the deps step, the docker deps builder generates it with an older dart whose ffigen output the current compiler rejects * fix: Pin ffigen so fresh resolutions generate compliant mweb bindings This change: - Pins ffigen to ^20.1.1 in cw_mweb, the unconstrained dependency resolved to 7.1.0 on fresh checkouts - Old ffigen emits ffi classes without the base or final modifiers the current dart compiler requires * fix: Restore the pinned cw_mweb pubspec before regenerating bindings This change: - Restores cw_mweb/pubspec.yaml after the deps step, the deps image rsync clobbers it with the stale unpinned copy so the ffigen pin never applied - Wipes the rsynced pubspec.lock and .dart_tool so pub resolves the pinned ffigen fresh - Asserts the regenerated bindings carry class modifiers so a bad generation fails in seconds - Skips the libclang install when it is already present, apt spent fourteen minutes on it * fix: Run the emulator test logic from a file, the action splits script lines This change: - Moves the whole emulator script into a file written before the emulator step, the action executes every script line as a separate sh command so multi line logic broke with a syntax error - Passes the action a single line script invoking that file with bash * fix: Give the ci emulator a real phone profile This change: - Sets the avd to the pixel 5 profile, the default 320x640 skin cannot fit the seed word grid so every wallet creation suite failed its seed display checks - Caps tier0 attempts at ten minutes, two failing suites at the thirty minute worst case blew the job timeout * fix: Wait for pages on the ci emulator instead of asserting immediately This change: - Makes the legacy page assertion poll for the page, the wallet keys page arrives after the ci emulator finishes unlocking the wallet so both auth gated suites failed on a race - Raises the tier0 attempt timeout, the first attempt for each suite compiles that target's kernel and was being killed then passing on retry - Raises the job timeout to fit the per suite compiles * fix: Recover from driver attach failures instead of burning the timeout This change: - Detects attempts where the flutter driver never attached to the app and retries them without spending the real retry, restarting adb first - Lowers the ci attempt timeout back to ten minutes, every passing attempt finishes within four so a longer cap only makes an attach failure cost more * fix: Bound the adb recovery and give up when the driver never attaches This change: - Bounds every adb call in the recovery path, adb wait-for-device blocks forever when the emulator is gone and silently ate a whole ci job - Aborts the suite when adb cannot be brought back instead of retrying against a dead device - Stops the run after a few driver attach failures, an environment that never attaches will not fix itself - Dumps logcat after every failed attempt so a failure to start the app leaves evidence behind * fix: Stop the runner failing when every suite passes This change: - Assigns the result arrays empty instead of only declaring them, set -u treats a declared but never assigned array as unbound so the summary died on the first run where nothing failed * fix: Make the ci build cacheable and keep tier results when the runner dies This change: - Splits the flutter and pub caches into restore and save steps, actions/cache only saves when the whole job succeeds so they were never written and every run paid for a cold build - Lets setup-gradle write its cache, it is read only on branches other than the default one - Caps the gradle heap before the test phase, the six gigabyte daemon plus the emulator left the runner too little to stay alive - Runs tier0 and tier1 as separate emulator steps so each uploads its logs before the next can take the runner down - Logs memory and disk around each tier - Documents how ci runs the suites and the two environment quirks that only bite there * fix: token decimals defaulting to zero and breaking amount parsing in solana (cherry picked from commit c220bbb46df9468faf9a8967cb0ef672af7cc664) * fix: Install the test error handler after the app boots and drain benign errors This change: - Installs the test error handler after main() instead of before it, the app replaces FlutterError.onError while booting so the handler installed earlier never ran - Replaces the app's async error handler during tests, it returns at its first await without recording anything so the framework asserted instead of reporting the real error - Drains background errors the app tolerates at runtime from the wait loops, anything unrecognised still fails the test where it happened - Collects the tolerated categories in one place, layout and semantics assertions from debug builds plus the transient network failures exception_handler.dart already ignores * fix: Import FlutterExceptionHandler from foundation so the test build compiles This change: - Imports package:flutter/foundation.dart in the launcher, material.dart only re-exports Brightness and UniqueKey from foundation so FlutterExceptionHandler was not in scope - Drops the material.dart import, every symbol the launcher uses comes from foundation * chore: Drop the funds-tests environment from the funds workflow and docs This change: - Removes the environment input from the funds workflow, we no longer use a funds-tests GitHub environment so passing it would only auto create an unprotected one on the next dispatch - Says plainly in the docs that nothing gates the dispatch, the doc still claimed the run waits for environment approval - Names write_funds_secrets as the one thing keeping funded seeds out of a PR build, that used to be backed by environment scoping too * chore: Trim the integration test comments down to the ones that carry weight This change: - Drops the doc comment sitting on top of every robot primitive, flow and config getter, they restated the method name and nothing else - Collapses the multi paragraph docstrings on the launcher, the error drain and the funded wallet lookup into the one or two lines that actually explain why the code is shaped that way - Removes the file and class header paragraphs, the funded wallets stub keeps a short note because an empty map needs one - Keeps the notes worth having, the chain quirks in the onboarding flow, the route transition tap, the dust thresholds, the void attempt handling in the runner * chore: Fold the components folder into core and clear out the leftover comments This change: - Moves common_test_cases.dart into core, the components folder was down to that one file after common_test_constants.dart went - Drops six methods from it that nothing calls, dragUntilVisible alone was 95 lines of scroll logic with no caller - Removes the comments narrating the line below them across the older robots, "Drag to the left" above a moveBy and "Confirmation for buttons 1-9" above the loop - Deletes the commented out body of takeScreenshots and says instead that it does nothing, all fifteen callers have been no-ops - Notes in the docs that new robots extend BaseRobot while the older ones still carry a CommonTestCases * chore: Clear out pending items * refactor: Move the onboarding robots onto BaseRobot and drop the sleeps This change: - Deletes common_test_cases.dart, the sixteen robots that held a CommonTestCases now extend BaseRobot like the newer ones do - Moves the primitives those robots needed onto BaseRobot with the same behaviour, isSpecificPage, hasType, hasValueKey, hasText, hasTextAtLeastOnce and swipePage - Renames each robot's page check to isDisplayed so every robot answers the same question the same way - Removes the twenty six defaultSleepTime calls, every one of them sat in front of an action that already waits for what it needs - Waits for the view model to move to the next word in seed verification instead of sleeping a second and hoping, and fails with a message when it does not - Settles after opening a tab on the wallet keys page, the assertions there read the tree straight after the tap - Drops the fifteen takeScreenshots calls, the method's body has been commented out the whole time so none of them did anything - Formats the three files dart format was still rewriting * fix: Wait out the navigation the removed sleeps used to cover This change: - Settles after a page mounts in isSpecificPage, the route being replaced stays in the tree while it animates out and assertions on text were matching both screens, seed_confirmation_test failed on finding two "Verify Seed" widgets - Settles after the last pin digit, the gate sends itself away and returning early landed the caller's next navigation on a locked navigator, wallet_switching_test failed on the !_debugLocked assertion Both are places where waiting for a widget to exist is not the same as waiting for the app to be idle, which is what the sleeps were quietly doing. * fix: Recognise every way a flutter drive attach fails and give tier1 a retry This change: - Matches the other wordings flutter drive uses when the driver never attaches, we had only the connect one and missed the isolate initialise, the service disappeared and the driver extension variants - Gives tier1 the same single retry tier0 has, running it with none meant a driver that never attached was reported as a failing test Two of the three tier1 failures on the last run were attaches that never happened, neither of them reached any test code. * fix: Sync status test watched the wrong status class, history scroll had no guard This change: - Accepts SyncronizingSyncStatus in the sync status suite, cw_core spells it without the h and it is a separate class from SyncingSyncStatus, so chains reporting it sat out the full three minute timeout and then failed saying the wallet never synced while printing Synchronizing - Waits for the home scroll view before handing it to scrollUntilVisible, which calls single on the finder every drag and reported a missing scroll view as a bare Bad state: No element - Skips the scroll entirely when the history tiles are already on screen * fix: Test the transaction history the way the screen actually works The suite looked for history tiles on the home page and scrolled for more when it found none, which could never pass. The home page renders the history under a tab, and for a wallet with tokens the assets tab is the one selected first, so the history section was never built. It also shows a three item preview, itemsShort caps it at shortHistoryLength, and everything else sits behind the All button in a modal. This change: - Opens the history tab before looking for tiles, wallets with no assets tab render the history with no tab bar at all and skip that step - Asserts the preview never renders more than the three the view model hands it, instead of scrolling for tiles that were never coming - Opens the All button and checks the full list in the modal, which nothing covered before - Adds keys for the tab items, the All button, the single tab history bar and the modal root * fix: Stop the runner wiping a real wallet install and document what CI does that the docs did not Following app_config.sh and then running the tests locally clears com.cakewallet.cake_wallet between every suite, which is a developer's own wallets. CI never hits this because the workflow renames the app afterwards, a step that only existed in the workflow. This change: - Refuses to clear a package that is not named for testing, with the rename command in the message - Adds that rename to the local prerequisites, next to the reason it matters - Spells out the assets/images vector pass, compile_graphics.sh does not cover that folder and those vec files are neither committed nor ignored, so a fresh checkout fails on the welcome screen - Notes that a second Gradle daemon from Android Studio deadlocks the build * test: Make the tier0 and tier1 assertions check what they claim to An audit of every robot against the screen it drives turned up assertions that pass without telling you anything, the same shape as the transaction history suite looking for tiles on a tab it never opened. This change: - Reads the receive address off the screen instead of the view model, the view model holding the right address says nothing about what the user is told to send to, and reads both the chunked and the plain render because only handling the first comes back empty - Verifies a restored wallet knows the address its seed derives, restoring the wrong wallet from a correct seed used to look the same as success, and this found two stale addresses in the secrets - Replaces the sync bar check, the key it asserted sits on a widget that renders for every status including none, so it was true whenever the home page was up - Requires the All transaction view to render more than the home preview, it could not tell the two apart before - Fails the keys page check when a wallet type matches none of its branches instead of passing having verified nothing * refactor: Remove outdated items from integration tests * test: Wait for the wallet to actually change instead of pumping a fixed half second Tapping a wallet row does nothing when that wallet is already open, and the load runs behind a progress overlay, so a tap that went nowhere looked the same as a successful switch. This change: - Waits for the app store to report the wallet before returning, and fails with a message when it never does * test: Open wallet groups before looking for a wallet by name Wallets that share a seed are listed as children of their group's tile and are not on screen while it is closed, so switching to one would time out looking for a name that is only rendered once the group is opened. Single seed wallets carry their own name as the tile title, which is why this has not come up yet. This change: - Opens the group tiles when the wallet is not already on screen * chore: Keep the receive address widget diff to the key it needed A formatter pass rewrote the whole file, quotes, body style and trailing commas, for what was a one line addition. Restored the original shape so the diff shows only the key. * chore: Drop trailing whitespace left by the comment cleanup * test: Cover the send screen refusing what it cannot send Nothing covered the send screen, which is the one place in the app where getting it wrong costs the user money. The property worth holding is that the swiper, the point of no return, is only ever offered for a transaction the wallet actually built. This change: - Adds a tier0 suite driving an empty form, an address the chain cannot parse, and a well formed address on a wallet that has never held anything, and requires that none of the three ever produce a swiper - Waits for the swiper and requires it not to arrive rather than checking once, the screen passes through syncing, building and failed states on the way and any of them can be the one you catch - Notes in the docs that a merge from dev leaves the generated chain proxies and the assets/images vec files behind, which has broken the local build three times now * test: Add send and swap dry runs and a flows mode that never spends The funds suites were all or nothing: the only way to find out whether a funded wallet still works was to spend from it. Almost everything worth checking happens before the swipe, the wallet finds its balance, prices the fee and builds a transaction, or the provider returns a live quote, and none of that costs anything. This change: - Adds a send dry run that stops at the swiper, reaching it means the transaction was really built - Adds a swap dry run that stops before the swap button, creating a trade registers an order with the provider even though no funds move, so it goes no further than the quote - Adds a dry-run value to FLOWS that runs both and neither of the spending suites, and makes it the default so a mis-click cannot send anything * test: Cover a wallet name that is already taken being refused Two wallets answering to the same name is how someone ends up sending from the wrong one, and nothing checked that the form stops it. This change: - Adds a tier0 suite that creates a wallet, starts a second one and gives it the same name, and requires the form to refuse it and stay put - Splits the walk to the naming form out of createAdditionalWalletFromWalletList so a suite can stop there instead of going through with the wallet * test: Cover a seed the wallet cannot parse restoring nothing Restoring from a seed the wallet does not understand either fails outright or, worse, derives some other wallet the user has no keys for. The restore button is disabled until the words check out and nothing checked that. This change: - Adds a tier0 suite that types twelve words outside the wordlist, presses restore, and requires the form to stay put - Follows it with the real seed in the same run, so the refusal above cannot pass just because the form restores nothing for any seed at all - Splits the walk to the restore form out of restoreFirstWalletFromSeed so a suite can stop there * refactor: Make spending its own switch instead of a value hidden in flows flows says which flow to run, so folding a dry-run value into it made the dropdown answer two questions at once and buried the one that matters. This change: - Adds a spend input, off by default, and gates the broadcasting suites on it - Puts flows back to all, send or swap - Runs the dry runs whenever their flow is picked, they cost nothing either way * chore: cleanup minor issues * test: Cover one seed backing wallets on two chains Wallet groups had no coverage at all, and the part that would break quietly is the seed: a group member that derived its own seed instead of sharing one looks identical from the wallet list. It is also the only way a wallet ends up inside a collapsed group tile, which is the path switchToWallet handles and nothing exercised. This change: - Adds a tier0 suite that creates a solana wallet, adds an ethereum wallet to its seed, and requires both to report the same seed - Switches back to the first afterwards, which only works by opening the group it now sits in - Adds the flow for it, a child wallet skips the seed and verification screens for a page explaining it shares one, so it cannot reuse the normal creation steps - Adds the one key the group page was missing, its next button * test: Cover changing the pin replacing the one that unlocks the wallet Nothing checked that changing the pin actually takes. Watching the setup screen close proves nothing, the only evidence is the new pin opening something the old one guarded. This change: - Adds a tier0 suite that changes the pin and then unlocks the seed and keys page with the new one - Goes through the pin gate on the way in, so it also covers the current pin being required before the change is allowed * test: Cover changing the language, and make settings rows and pickers addressable Writing this turned up two things in the app rather than the test. Only ListItemRegularRow was given its keyValue as a widget key. Toggles, checkboxes, dropdowns and selectors all took the same keyValue and dropped it, so every settings control that is not a plain navigation row could not be found at all. Picker named three item types and gave everything else an empty string, so a picker over plain values, the language list among them, rendered every row with the same key. This change: - Passes keyValue through as a key for the other four row types - Falls back to the item itself for the picker name, so each row is distinct - Adds a tier0 suite that changes the language and requires the row to show the new one * test: Cover switching the node the wallet talks to Which node a wallet is pointed at decides what it sees, and a switch that quietly does not take leaves someone on a node they believe they left. Nothing covered it. This change: - Adds a tier1 suite that reads the current node, switches to another one, confirms, and requires the wallet to be pointed somewhere new - tier1 rather than tier0 because opening the page speed tests every node against the network * test: Cover the address book keeping the address it was given Pasting an address once and trusting it afterwards is the whole point of an address book, so an entry that comes back changed is a way to lose money quietly. Nothing covered it. This change: - Adds a tier0 suite that saves a contact through the form and requires the stored address to match what was typed - Checks the saved record rather than the rows, the list filters what it shows by the currency in play so a contact can be saved and correctly not on screen - Adds keys for the add button, the name field, the currency picker, the address field and save * fix: Key picker rows on a name that does not change with the language Enumerable items fell through to toString, which is translated for some of them, so the same row carried a different key depending on the language in use. * test: Cover the fiat currency setting, transaction details and sending to a contact This change: - Adds a suite for turning the fiat api off and on, which is what decides whether the currency setting is offered at all, then changes the currency through it - Adds a suite that taps a transaction and checks the details that open belong to it - Adds a suite that saves a contact and picks it from the send screen - Adds a suite for restoring a 25 word monero seed, which takes a seed type and a restore height the polyseed path never asks for - Keys the address book button on the send screen so a test can reach it - Lets SUITE_DIR take a single suite file, for iterating on one test - Lists every suite and what it proves in the integration tests doc * test: Cover renaming and deleting a wallet This change: - Adds a suite for renaming a wallet, including the refusal when the name is already held by another wallet - Adds a suite for deleting a wallet, checking it leaves storage and not only the list - Keys the edit button in the wallet list and the name field, the two buttons and both dialogs on the wallet edit page, none of which could be reached from a test before - Fixes the wallets list robot, which looked for a single Wallets title even though the navbar carries the same word, and looked for wallet names across the whole tree even though every dashboard tab stays mounted in an IndexedStack * ci: Report every integration test run to slack This change: - Posts a report to slack on every run, naming each tier's counts, duration and any suite that failed, with the full list of what passed in a reply on the same message - Adds a SUMMARY_FILE knob to the runner, which writes what it counted in a form the report can be built from rather than parsing the run log - Skips with a notice when SLACK_APP_TOKEN or SLACK_TESTS_CHANNEL is missing, and never fails the gate when slack is unreachable - Leaves the funds workflow alone, the new slack_notify input defaults to off * chore: Removing unneeded items * ci: Say which slack scope was missing when a report is rejected A rejection carries the scope it wanted in a field of its own, and dropping it left the warning saying missing_scope without saying what to go and add. * ci: Report funds runs to the tests channel and say when slack refuses them This change: - Sends the funds report to SLACK_TESTS_CHANNEL, so both test workflows land in one place and the apk channel is left to builds - Checks the slack response, the post was going out unchecked so a rejected report looked like a delivered one - Builds the message from the environment instead of interpolating workflow inputs into the shell, a quote in a dispatch input was enough to break the payload * ci: Give the test reports their own slack app Uploading apks and reporting test results are different jobs with different scopes, and the name on a message is how anyone in the channel tells which one sent it. Both test workflows now post through SLACK_TESTS_TOKEN, which only needs chat:write, and the apk uploads keep SLACK_APP_TOKEN and its file scopes. * fix: Restore the swap amount key and say when a suite never ran The AnyPay redesign moved the deposit amount field into SwapAmountBox and it lost the key the swap suite enters an amount through, so that suite could no longer reach it. Both amount fields are keyed now, deposit and receive. This change also: - Records whether a failed suite failed on its own or because the driver never attached, which is what happened to send_validation_test in run 31364715120 where the suite never actually ran - Rebuilds the slack report out of blocks, so the status is a heading, the tiers sit in their own fields, failures name their reason and the run is a button rather than a bare url * perf: End an attempt early when the driver cannot attach flutter drive says it is struggling to attach within about a minute and then, when it is really wedged, sits there until TEST_TIMEOUT kills it. Run 31364715120 lost 20 minutes to two attempts doing exactly that. This change: - Watches the attempt log and ends the attempt VOID_GRACE seconds after the driver first reports trouble, 120 by default, so the retry starts on a fresh app instead of waiting out the remaining eight minutes - Starts that clock at the first complaint rather than at the attempt, since the attempt spends its first minute building and installing - Moves the marker list into one variable, the watchdog and the after the fact check were going to drift apart otherwise * perf: Drop the driver grace to 60s and take the whole drive down with it Measured against run 31364715120: the 19 attempts that attached did so in under a second, while the two that never attached had the warning out at 5 seconds. 60 leaves a wide margin over a healthy attach on a runner that was already running slow, and saves another minute on each dead attempt. Ending the attempt now walks the process tree rather than signalling the wrapper. On linux the drive runs under timeout, and a KILL to that cannot be forwarded, so the drive would have been left holding the device just as the retry went looking for it. * trigger another test run * Triggering another run, its been green so far * fix: Wait for the wallet to be ready before tapping send in the funds suites The send button is disabled until isReadyForSend, which wants a synced wallet, but the funds flow moves on as soon as a balance appears. Every funded chain was therefore tapping a disabled button and then waiting ninety seconds for a confirm sheet that could never open. The robot now waits on the same value the screen gates on, so a wallet that never syncs says so instead of looking like a missing widget. * test: Make a funds send failure say what went wrong and stop it spreading A send that never reaches the confirm sheet was reported as a missing ConfirmSwiper, which says nothing about the cause. It now reports the state of whichever sheet is up, what the wallet holds, and the build error when the chain rejected the transaction. Recovery between chains was a single pop, so the first chain to fail left the app somewhere the next chain's restore could not start from and every chain after it failed too. It now unwinds until the home page is back and stops the suite if it cannot get there. * test: full testing of all the funded tests and fix issues that came up with them * fix: update sync key to fix sync test * fix: out of space error * fix: integration test review issues * fix: merge conflicts and update test suites * fix: false test passes and add funded wallet passphrases chore: remove unused integration test robot methods feat: save integration test screenshots on Android feat: pick the swap pair for the funds dispatch * fix: Keep the runner output on the step log through a file descriptor * fix: timeout because of runner crash * test: handle new seed ui flow and fix hang for runner * fix: emulator teardown not rendering succesfully feat: use currency picker for selecting swap options for funded run selected swap currency * test: add key for compact dot key for sync and link to robot * minor fixes * test: review fixes for integration tests --- .../workflows/automated_integration_test.yml | 487 ------------ .github/workflows/funds_integration_tests.yml | 64 ++ .github/workflows/integration_tests.yml | 28 + .github/workflows/pr_test_build_linux.yml | 87 +-- .github/workflows/reusable-build.yml | 6 - .../workflows/reusable-integration-test.yml | 598 +++++++++++++++ docs/INTEGRATION_TESTS.md | 306 ++++++++ .../components/common_test_cases.dart | 260 ------- .../components/common_test_constants.dart | 15 - .../components/common_test_flows.dart | 414 ---------- integration_test/core/app_launcher.dart | 75 ++ integration_test/core/base_robot.dart | 238 ++++++ integration_test/core/benign_errors.dart | 51 ++ integration_test/core/funded_wallet.dart | 6 + integration_test/core/funded_wallets.dart | 5 + integration_test/core/funds_outcome.dart | 46 ++ integration_test/core/test_config.dart | 112 +++ integration_test/core/test_wallets.dart | 102 +++ integration_test/flows/auth_flows.dart | 41 + integration_test/flows/funds_flows.dart | 155 ++++ integration_test/flows/onboarding_flows.dart | 267 +++++++ integration_test/flows/wallet_flows.dart | 59 ++ integration_test/funds_related_tests.dart | 96 --- .../funds_suites/send_dry_run_test.dart | 107 +++ .../funds_suites/send_funds_test.dart | 109 +++ .../funds_suites/swap_dry_run_test.dart | 69 ++ .../funds_suites/swap_funds_test.dart | 86 +++ integration_test/helpers/mocks.dart | 27 - integration_test/helpers/test_helpers.dart | 99 --- .../integration_response_data.json | 1 - integration_test/robots/auth_page_robot.dart | 36 +- .../robots/connection_sync_page_robot.dart | 40 + integration_test/robots/contact_robot.dart | 66 ++ .../robots/create_pin_welcome_page_robot.dart | 46 +- .../robots/dashboard_menu_widget_robot.dart | 40 - .../robots/dashboard_page_robot.dart | 122 --- .../robots/disclaimer_page_robot.dart | 40 - .../robots/display_settings_page_robot.dart | 78 ++ .../robots/exchange_confirm_page_robot.dart | 46 -- .../robots/exchange_page_robot.dart | 338 --------- ...change_trade_external_send_page_robot.dart | 35 - .../robots/exchange_trade_page_robot.dart | 161 ---- integration_test/robots/home_page_robot.dart | 227 ++++++ .../robots/lightning_username_page_robot.dart | 17 + .../robots/manage_nodes_page_robot.dart | 39 + .../robots/new_dashboard_robot.dart | 25 + .../robots/new_receive_page_robot.dart | 57 ++ .../robots/new_send_page_robot.dart | 205 +++++ .../robots/new_settings_page_robot.dart | 45 ++ .../robots/new_swap_page_robot.dart | 211 ++++++ .../robots/new_wallet_page_robot.dart | 40 +- .../robots/new_wallet_type_page_robot.dart | 72 +- .../robots/pin_code_widget_robot.dart | 50 +- .../robots/pre_seed_page_robot.dart | 26 +- .../restore_from_seed_or_key_robot.dart | 156 ++-- .../robots/restore_options_page_robot.dart | 43 +- .../security_and_backup_page_robot.dart | 28 - .../robots/security_backup_page_robot.dart | 16 + .../robots/seed_verification_page_robot.dart | 42 +- integration_test/robots/send_page_robot.dart | 716 ------------------ .../robots/setup_pin_code_robot.dart | 27 +- .../show_keys_disclaimer_page_robot.dart | 16 + .../transaction_success_info_robot.dart | 21 - .../robots/transactions_page_robot.dart | 439 ----------- .../robots/wallet_edit_page_robot.dart | 50 ++ .../wallet_group_description_page_robot.dart | 36 +- ...wallet_group_existing_seed_page_robot.dart | 16 + .../wallet_groups_display_page_robot.dart | 21 + .../robots/wallet_keys_robot.dart | 226 +++--- .../robots/wallet_list_page_robot.dart | 48 +- .../robots/wallet_seed_page_robot.dart | 62 +- .../robots/welcome_page_robot.dart | 48 +- .../suites/tier0/address_book_test.dart | 46 ++ .../suites/tier0/change_pin_test.dart | 73 ++ .../tier0/duplicate_wallet_name_test.dart | 42 + .../suites/tier0/fiat_currency_test.dart | 107 +++ .../suites/tier0/invalid_seed_test.dart | 51 ++ .../suites/tier0/language_test.dart | 46 ++ .../suites/tier0/onboarding_create_test.dart | 53 ++ .../suites/tier0/onboarding_restore_test.dart | 94 +++ .../suites/tier0/receive_address_test.dart | 43 ++ .../suites/tier0/seed_confirmation_test.dart | 70 ++ .../tier0/send_from_address_book_test.dart | 59 ++ .../suites/tier0/send_validation_test.dart | 53 ++ .../suites/tier0/settings_nav_test.dart | 61 ++ .../suites/tier0/show_keys_auth_test.dart | 61 ++ .../suites/tier0/wallet_delete_test.dart | 85 +++ .../suites/tier0/wallet_group_test.dart | 70 ++ .../suites/tier0/wallet_rename_test.dart | 86 +++ .../suites/tier1/monero_legacy_seed_test.dart | 47 ++ .../suites/tier1/node_switching_test.dart | 49 ++ .../suites/tier1/swap_quote_test.dart | 37 + .../suites/tier1/sync_status_test.dart | 27 + .../tier1/transaction_details_test.dart | 47 ++ .../tier1/transaction_history_test.dart | 37 + .../example_feature_test.dart.example | 55 ++ .../test_suites/confirm_seeds_flow_test.dart | 122 --- .../test_suites/create_wallet_flow_test.dart | 60 -- .../test_suites/exchange_flow_test.dart | 65 -- ...estore_wallet_through_seeds_flow_test.dart | 75 -- .../test_suites/send_flow_test.dart | 133 ---- .../transaction_history_flow_test.dart | 82 -- integration_test_runner.sh | 437 +++++++++-- lib/new-ui/new_dashboard.dart | 1 + lib/new-ui/pages/lightning_username_page.dart | 1 + lib/new-ui/pages/send_page.dart | 2 + lib/new-ui/pages/settings_page.dart | 2 +- .../action_row/coin_action_row.dart | 4 + .../assets_history/assets_top_bar.dart | 1 + .../assets_history/history_modal.dart | 1 + .../assets_history/history_section.dart | 1 + .../assets_history/history_top_bar.dart | 1 + .../coins_page/top_bar_widget/sync_bar.dart | 6 +- .../coins_page/top_bar_widget/top_bar.dart | 1 + .../widgets/coins_page/wallet_info.dart | 1 + lib/new-ui/widgets/line_tab_switcher.dart | 1 + .../receive_page/receive_address_widget.dart | 1 + .../widgets/send_page/send_address_input.dart | 1 + .../send_page/send_confirm_bottom_widget.dart | 1 + .../widgets/swap_page/swap_amount_box.dart | 6 + .../screens/contact/contact_list_page.dart | 1 + lib/src/screens/contact/contact_page.dart | 4 + .../dashboard/favorite_token_modal.dart | 12 +- .../widgets/new_main_navbar_widget.dart | 1 + .../new_wallet/wallet_group_display_page.dart | 1 + .../screens/restore/wallet_restore_page.dart | 1 + lib/src/screens/wallet/wallet_edit_page.dart | 7 + .../screens/wallet_list/wallet_list_page.dart | 2 + lib/src/widgets/alert_with_one_action.dart | 29 +- .../new_list_row/new_list_section.dart | 5 + lib/src/widgets/picker.dart | 17 +- lib/src/widgets/standard_checkbox.dart | 3 +- scripts/ci/emulator_ready.sh | 12 + scripts/ci/run_integration_tests.sh | 115 +++ test/widget_test.dart | 34 - test_driver/integration_test.dart | 44 +- tool/utils/secret_key.dart | 14 +- 137 files changed, 6024 insertions(+), 4772 deletions(-) delete mode 100644 .github/workflows/automated_integration_test.yml create mode 100644 .github/workflows/funds_integration_tests.yml create mode 100644 .github/workflows/integration_tests.yml create mode 100644 .github/workflows/reusable-integration-test.yml create mode 100644 docs/INTEGRATION_TESTS.md delete mode 100644 integration_test/components/common_test_cases.dart delete mode 100644 integration_test/components/common_test_constants.dart delete mode 100644 integration_test/components/common_test_flows.dart create mode 100644 integration_test/core/app_launcher.dart create mode 100644 integration_test/core/base_robot.dart create mode 100644 integration_test/core/benign_errors.dart create mode 100644 integration_test/core/funded_wallet.dart create mode 100644 integration_test/core/funded_wallets.dart create mode 100644 integration_test/core/funds_outcome.dart create mode 100644 integration_test/core/test_config.dart create mode 100644 integration_test/core/test_wallets.dart create mode 100644 integration_test/flows/auth_flows.dart create mode 100644 integration_test/flows/funds_flows.dart create mode 100644 integration_test/flows/onboarding_flows.dart create mode 100644 integration_test/flows/wallet_flows.dart delete mode 100644 integration_test/funds_related_tests.dart create mode 100644 integration_test/funds_suites/send_dry_run_test.dart create mode 100644 integration_test/funds_suites/send_funds_test.dart create mode 100644 integration_test/funds_suites/swap_dry_run_test.dart create mode 100644 integration_test/funds_suites/swap_funds_test.dart delete mode 100644 integration_test/helpers/mocks.dart delete mode 100644 integration_test/helpers/test_helpers.dart delete mode 100644 integration_test/integration_response_data.json create mode 100644 integration_test/robots/connection_sync_page_robot.dart create mode 100644 integration_test/robots/contact_robot.dart delete mode 100644 integration_test/robots/dashboard_menu_widget_robot.dart delete mode 100644 integration_test/robots/dashboard_page_robot.dart delete mode 100644 integration_test/robots/disclaimer_page_robot.dart create mode 100644 integration_test/robots/display_settings_page_robot.dart delete mode 100644 integration_test/robots/exchange_confirm_page_robot.dart delete mode 100644 integration_test/robots/exchange_page_robot.dart delete mode 100644 integration_test/robots/exchange_trade_external_send_page_robot.dart delete mode 100644 integration_test/robots/exchange_trade_page_robot.dart create mode 100644 integration_test/robots/home_page_robot.dart create mode 100644 integration_test/robots/lightning_username_page_robot.dart create mode 100644 integration_test/robots/manage_nodes_page_robot.dart create mode 100644 integration_test/robots/new_dashboard_robot.dart create mode 100644 integration_test/robots/new_receive_page_robot.dart create mode 100644 integration_test/robots/new_send_page_robot.dart create mode 100644 integration_test/robots/new_settings_page_robot.dart create mode 100644 integration_test/robots/new_swap_page_robot.dart delete mode 100644 integration_test/robots/security_and_backup_page_robot.dart create mode 100644 integration_test/robots/security_backup_page_robot.dart delete mode 100644 integration_test/robots/send_page_robot.dart create mode 100644 integration_test/robots/show_keys_disclaimer_page_robot.dart delete mode 100644 integration_test/robots/transaction_success_info_robot.dart delete mode 100644 integration_test/robots/transactions_page_robot.dart create mode 100644 integration_test/robots/wallet_edit_page_robot.dart create mode 100644 integration_test/robots/wallet_group_existing_seed_page_robot.dart create mode 100644 integration_test/robots/wallet_groups_display_page_robot.dart create mode 100644 integration_test/suites/tier0/address_book_test.dart create mode 100644 integration_test/suites/tier0/change_pin_test.dart create mode 100644 integration_test/suites/tier0/duplicate_wallet_name_test.dart create mode 100644 integration_test/suites/tier0/fiat_currency_test.dart create mode 100644 integration_test/suites/tier0/invalid_seed_test.dart create mode 100644 integration_test/suites/tier0/language_test.dart create mode 100644 integration_test/suites/tier0/onboarding_create_test.dart create mode 100644 integration_test/suites/tier0/onboarding_restore_test.dart create mode 100644 integration_test/suites/tier0/receive_address_test.dart create mode 100644 integration_test/suites/tier0/seed_confirmation_test.dart create mode 100644 integration_test/suites/tier0/send_from_address_book_test.dart create mode 100644 integration_test/suites/tier0/send_validation_test.dart create mode 100644 integration_test/suites/tier0/settings_nav_test.dart create mode 100644 integration_test/suites/tier0/show_keys_auth_test.dart create mode 100644 integration_test/suites/tier0/wallet_delete_test.dart create mode 100644 integration_test/suites/tier0/wallet_group_test.dart create mode 100644 integration_test/suites/tier0/wallet_rename_test.dart create mode 100644 integration_test/suites/tier1/monero_legacy_seed_test.dart create mode 100644 integration_test/suites/tier1/node_switching_test.dart create mode 100644 integration_test/suites/tier1/swap_quote_test.dart create mode 100644 integration_test/suites/tier1/sync_status_test.dart create mode 100644 integration_test/suites/tier1/transaction_details_test.dart create mode 100644 integration_test/suites/tier1/transaction_history_test.dart create mode 100644 integration_test/templates/example_feature_test.dart.example delete mode 100644 integration_test/test_suites/confirm_seeds_flow_test.dart delete mode 100644 integration_test/test_suites/create_wallet_flow_test.dart delete mode 100644 integration_test/test_suites/exchange_flow_test.dart delete mode 100644 integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart delete mode 100644 integration_test/test_suites/send_flow_test.dart delete mode 100644 integration_test/test_suites/transaction_history_flow_test.dart create mode 100644 scripts/ci/emulator_ready.sh create mode 100644 scripts/ci/run_integration_tests.sh delete mode 100644 test/widget_test.dart diff --git a/.github/workflows/automated_integration_test.yml b/.github/workflows/automated_integration_test.yml deleted file mode 100644 index 9b803a1f0..000000000 --- a/.github/workflows/automated_integration_test.yml +++ /dev/null @@ -1,487 +0,0 @@ - -name: Automated Integration Tests - -# Temporarily disabled - uncomment to re-enable -on: - # push: - # pull_request: - # branches: [main] - workflow_dispatch: # This is to manually trigger if needed -defaults: - run: - shell: bash -jobs: - Automated_integration_test: - runs-on: linux-amd64 - timeout-minutes: 90 - container: - image: ghcr.io/cake-tech/cake_wallet:debian12-flutter3.27.0-go1.24.1-ruststablenightly - env: - STORE_PASS: test@cake_wallet - KEY_PASS: test@cake_wallet - MONEROC_CACHE_DIR_ROOT: /opt/generic_cache - BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - ANDROID_AVD_HOME: /root/.android/avd - volumes: - - /opt/cw_cache_android/root/.cache:/root/.cache - - /opt/cw_cache_android/root/.android/avd/:/root/.android/avd - - /opt/cw_cache_android/root/.ccache:/root/.ccache - - /opt/cw_cache_android/root/.pub-cache/:/root/.pub-cache - - /opt/cw_cache_android/root/.gradle/:/root/.gradle - - /opt/cw_cache_android/root/.android/:/root/.android - - /opt/cw_cache_android/root/go/pkg:/root/go/pkg - - /opt/cw_cache_android/opt/generic_cache:/opt/generic_cache - - /dev/kvm:/dev/kvm - strategy: - matrix: - api-level: [29] - - steps: - - name: Fix github actions messing up $HOME... - run: 'echo HOME=/root | sudo tee -a $GITHUB_ENV' - - uses: actions/checkout@v4 - with: - ref: ${{ github.event.pull_request.head.sha || github.sha }} - - name: configure git - run: | - git config --global --add safe.directory '*' - git config --global user.email "ci@cakewallet.com" - git config --global user.name "CakeWallet CI" - - name: Get the full commit message - run: | - FULL_MESSAGE="$(git log -1 --pretty=%B)" - echo "message<> $GITHUB_ENV - echo "$FULL_MESSAGE" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - - name: Add secrets - run: | - touch cw_bitcoin/lib/.secrets.g.dart - touch cw_evm/lib/.secrets.g.dart - touch cw_solana/lib/.secrets.g.dart - touch cw_core/lib/.secrets.g.dart - touch cw_nano/lib/.secrets.g.dart - touch cw_tron/lib/.secrets.g.dart - - echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const nowNodesApiKey = '${{ secrets.EVM_NOWNODES_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart - echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart - echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart - echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart - - echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart - echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart - - echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart - echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart - - echo "const breezApiKey = '${{ secrets.BREEZ_API_KEY }}';" >> cw_bitcoin/lib/.secrets.g.dart - - echo "${{ secrets.MAIN_SECRETS_FILE }}" | base64 --decode > lib/.secrets.g.dart - - - name: prepare monero_c and cache - run: | - export MONEROC_HASH=$(cat scripts/prepare_moneroc.sh | grep 'git checkout' | xargs | awk '{ print $3 }') - echo MONEROC_HASH=$MONEROC_HASH >> /etc/environment - mkdir -p "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" - pushd scripts - ln -s "$MONEROC_CACHE_DIR_ROOT/moneroc-$MONEROC_HASH/monero_c" - ./prepare_moneroc.sh - popd - pushd scripts/monero_c - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" || true - mkdir -p "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" || true - - rm -rf "$PWD/contrib/depends/built" "$PWD/monero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" - rm -rf "$PWD/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" - mkdir -p contrib/depends || true - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/built" "$PWD/contrib/depends/built" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/built" "$PWD/monero/contrib/depends/built" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/built" "$PWD/wownero/contrib/depends/built" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/contrib/depends/sources" "$PWD/contrib/depends/sources" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/monero/contrib/depends/sources" "$PWD/monero/contrib/depends/sources" - ln -sf "$MONEROC_CACHE_DIR_ROOT/_cache/wownero/contrib/depends/sources" "$PWD/wownero/contrib/depends/sources" - popd - - - name: Generate KeyStore - run: | - pushd /opt/generic_cache - if [[ ! -f key.jks ]]; - then - keytool -genkey -v -keystore key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS - else - echo "$PWD/key.jks exist, not generating" - fi - popd - cp /opt/generic_cache/key.jks android/app - - - name: Execute Build and Setup Commands - run: | - pushd scripts/android - source ./app_env.sh cakewallet - ./app_config.sh - popd - - - name: Build monero_c - run: | - pushd scripts/android/ - source ./app_env.sh cakewallet - ./build_monero_all.sh - popd - - - name: Install Flutter dependencies - run: | - flutter pub get - - - name: Build mwebd - run: | - set -x -e - export MWEBD_HASH=$(cat scripts/android/build_mwebd.sh | grep 'git reset --hard' | xargs | awk '{ print $4 }') - echo MWEBD_HASH=$MWEBD_HASH >> /etc/environment - pushd scripts/android - gomobile init; - ./build_mwebd.sh - popd - - - name: Build Decred - run: | - set -x -e - pushd scripts/android - ./build_decred.sh - popd - - - name: Build generated code - run: | - flutter --version - flutter pub get - ./model_generator.sh - - - name: Generate key properties - run: | - dart run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS - - - name: Generate localization - run: | - dart run tool/generate_localization.dart - - - name: Rename app - run: | - sanitized_branch_name=${BRANCH_NAME#origin/} # Remove 'origin/' prefix if it exists - sanitized_branch_name=${sanitized_branch_name:0:16} # Take only the first 16 characters - sanitized_branch_name=$(echo "$sanitized_branch_name" | tr '[:upper:]' '[:lower:]') # Convert to lowercase - sanitized_branch_name=$(echo "$sanitized_branch_name" | sed 's/[^a-z0-9]//g') # Remove all special characters - - echo -e "id=com.cakewallet.test_${sanitized_branch_name}\nname=${BRANCH_NAME}" > android/app.properties - - - name: Build - run: | - flutter build apk --dart-define=hasDevOptions=true --release --split-per-abi - - - name: Rename apk file - run: | - sanitized_branch_name=$(grep '^id=' android/app.properties | cut -d'=' -f2 | sed 's/com\.cakewallet\.test_//') - cd build/app/outputs/flutter-apk - mkdir test-apk - - cp app-arm64-v8a-release.apk test-apk/${sanitized_branch_name}.apk - cp app-x86_64-release.apk test-apk/${sanitized_branch_name}_x86.apk - echo "APK files created: test-apk/${sanitized_branch_name}.apk and test-apk/${sanitized_branch_name}_x86.apk" - - - name: Find APK file - id: find_apk - run: | - set -x - # Read the sanitized branch name from the app.properties file - sanitized_branch_name=$(grep '^id=' android/app.properties | cut -d'=' -f2 | sed 's/com\.cakewallet\.test_//') - - echo "Looking for APK file: build/app/outputs/flutter-apk/test-apk/${sanitized_branch_name}.apk" - ls -la build/app/outputs/flutter-apk/test-apk/ || echo "test-apk directory not found" - apk_file=$(ls build/app/outputs/flutter-apk/test-apk/${sanitized_branch_name}.apk || exit 1) - echo "APK_FILE=$apk_file" >> $GITHUB_ENV - echo "APK file found: $apk_file" - ls -la "$apk_file" - - - name: ๐Ÿ” Debug System Environment - run: | - echo "=== System Information ===" - uname -a - echo "=== Container Info ===" - cat /proc/version || echo "Cannot read /proc/version" - echo "=== KVM Device Check ===" - ls -la /dev/kvm || echo "KVM device not found" - echo "=== udev Status ===" - which udevadm || echo "udevadm not found" - echo "=== Current User ===" - whoami - echo "=== Groups ===" - groups - echo "=== Environment ===" - env | grep -E "(HOME|USER|PATH)" | sort - - - name: ๐Ÿงน Clean up existing emulators - run: | - echo "=== Cleaning up existing emulators ===" - # Kill any existing emulator processes - pkill -f emulator || echo "No emulator processes found" - # Kill any existing adb processes - pkill -f adb || echo "No adb processes found" - # Wait a moment for processes to terminate - sleep 2 - # Start adb server fresh - adb start-server || echo "Failed to start adb server" - # Kill adb server to ensure clean state - adb kill-server || echo "Failed to kill adb server" - echo "=== Cleanup complete ===" - - - name: ๐Ÿ” Verify AVD Configuration - run: | - echo "=== AVD Configuration Check ===" - - # Check if AVD directory exists - echo "Checking AVD directory..." - ls -la ~/.android/avd/ || echo "AVD directory not found" - - # List available AVDs - echo "Available AVDs:" - emulator -list-avds || echo "Failed to list AVDs" - - # Check Android SDK location - echo "Android SDK location:" - echo $ANDROID_HOME - echo $ANDROID_SDK_ROOT - - # Check emulator binary - echo "Emulator binary:" - which emulator || echo "Emulator not found in PATH" - emulator -version || echo "Failed to get emulator version" - - echo "=== AVD Check Complete ===" - - - name: ๐Ÿฆพ Enable KVM - run: | - echo "=== KVM Setup and Verification ===" - - # Check if KVM device exists - if [ -e /dev/kvm ]; then - echo "โœ… KVM device found at /dev/kvm" - - # Check current permissions - echo "Current KVM permissions:" - ls -la /dev/kvm - - # Set proper permissions - sudo chmod 666 /dev/kvm || echo "โš ๏ธ Failed to set KVM permissions" - - # Verify permissions were set - if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then - echo "โœ… KVM is readable and writable" - else - echo "โš ๏ธ KVM permissions may not be optimal" - fi - - # Test KVM functionality - echo "Testing KVM functionality..." - if command -v kvm-ok >/dev/null 2>&1; then - if kvm-ok 2>/dev/null; then - echo "โœ… KVM is working properly" - else - echo "โš ๏ธ KVM check failed, but continuing..." - fi - else - echo "โ„น๏ธ kvm-ok command not available, testing manually..." - # Manual KVM test - if [ -r /dev/kvm ] && [ -w /dev/kvm ]; then - echo "โœ… KVM device is accessible" - fi - fi - - # Try to create udev rules (may fail in container, that's OK) - echo "Setting up udev rules..." - sudo mkdir -p /etc/udev/rules.d || echo "โš ๏ธ Could not create udev directory" - echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules || echo "โš ๏ธ Could not create udev rule" - - # Try to reload udev rules (will likely fail in container, that's OK) - sudo udevadm control --reload-rules || echo "โ„น๏ธ udevadm control failed (expected in container)" - sudo udevadm trigger --name-match=kvm || echo "โ„น๏ธ udevadm trigger failed (expected in container)" - - else - echo "โŒ KVM device not found at /dev/kvm" - echo "This will cause the emulator to run in software mode (slower)" - fi - - # Check system resources - echo "=== System Resources ===" - echo "Memory:" - free -h - echo "Disk space:" - df -h - echo "CPU cores:" - nproc - echo "KVM module loaded:" - lsmod | grep kvm || echo "KVM module not loaded (may be built-in)" - - echo "=== KVM Setup Complete ===" - - - name: ๐Ÿฆพ Cache gradle - uses: gradle/actions/setup-gradle@v3 - - - name: ๐Ÿฆพ Cache AVD - uses: actions/cache@v4 - id: avd-cache - with: - path: | - ~/.android/avd/* - ~/.android/adb* - key: avd-${{ matrix.api-level }} - - - name: ๐Ÿ”ง Prepare emulator utilities - run: | - # Create a helper script for emulator readiness checks - cat > /tmp/emulator_ready.sh << 'EOF' - #!/bin/bash - - echo "=== Emulator Readiness Check ===" - - # Wait for boot completion - echo "1. Checking boot completion..." - timeout 300 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do sleep 5; echo " Waiting for boot completion..."; done' - if [ $? -eq 0 ]; then - echo "โœ… Boot completed" - else - echo "โŒ Boot completion timeout" - echo "โš ๏ธ Continuing anyway..." - fi - - # Wait for input service - echo "2. Checking input service..." - timeout 60 bash -c 'until adb shell service list 2>/dev/null | grep -q "input"; do sleep 2; echo " Waiting for input service..."; done' - if [ $? -eq 0 ]; then - echo "โœ… Input service ready" - else - echo "โŒ Input service timeout" - echo "โš ๏ธ Continuing anyway..." - fi - - # Wait for package manager - echo "3. Checking package manager..." - timeout 60 bash -c 'until adb shell pm list packages 2>/dev/null >/dev/null; do sleep 2; echo " Waiting for package manager..."; done' - if [ $? -eq 0 ]; then - echo "โœ… Package manager ready" - else - echo "โŒ Package manager timeout" - echo "โš ๏ธ Continuing anyway..." - fi - - # Wait for settings service - echo "4. Checking settings service..." - timeout 60 bash -c 'until adb shell settings get global window_animation_scale 2>/dev/null >/dev/null; do sleep 2; echo " Waiting for settings service..."; done' - if [ $? -eq 0 ]; then - echo "โœ… Settings service ready" - else - echo "โŒ Settings service timeout" - echo "โš ๏ธ Continuing anyway..." - fi - - echo "โœ… Emulator readiness check completed" - EOF - - chmod +x /tmp/emulator_ready.sh - - - name: ๐Ÿฆพ Create AVD and generate snapshot for caching - if: steps.avd-cache.outputs.cache-hit != 'true' - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - force-avd-creation: false - # arch: ${{ matrix.arch }} - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 1024 -no-snapshot-save - working-directory: ${{ github.workspace }} - disable-animations: false - script: | - echo "=== AVD Snapshot Generation ===" - - # Use the helper script to ensure emulator is fully ready - /tmp/emulator_ready.sh - - # Manually disable animations with error handling - echo "Disabling animations manually..." - adb shell settings put global window_animation_scale 0.0 || echo "Failed to disable window animations" - adb shell settings put global transition_animation_scale 0.0 || echo "Failed to disable transition animations" - adb shell settings put global animator_duration_scale 0.0 || echo "Failed to disable animator duration" - - echo "=== Checking emulator status before termination ===" - adb devices || echo "ADB devices check failed" - echo "=== AVD snapshot generation complete ===" - - - name: ๐Ÿš€ Integration tests on Android Emulator - timeout-minutes: 60 - uses: reactivecircus/android-emulator-runner@v2 - with: - api-level: ${{ matrix.api-level }} - force-avd-creation: false - emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 1024 -no-snapshot-save - disable-animations: false - working-directory: ${{ github.workspace }} - script: | - echo "=== Pre-test Environment Check ===" - pwd - ls -la integration_test_runner.sh || echo "integration_test_runner.sh not found" - - echo "=== Emulator Status Check ===" - adb devices - - echo "=== Emulator Info ===" - adb shell getprop ro.build.version.release || echo "Failed to get Android version" - adb shell getprop ro.product.model || echo "Failed to get device model" - - echo "=== Checking Emulator Performance ===" - adb shell cat /proc/cpuinfo | grep -i "model name" | head -1 || echo "Could not check CPU info" - adb shell cat /proc/meminfo | grep -i "memtotal" || echo "Could not check memory info" - - echo "=== Checking Emulator Process ===" - ps aux | grep emulator | grep -v grep || echo "Emulator process not found in ps output" - - echo "=== Making script executable ===" - chmod a+rx integration_test_runner.sh - - echo "=== Running integration tests ===" - - echo "Final emulator readiness check..." - /tmp/emulator_ready.sh || echo "Emulator readiness check failed, but continuing..." - - echo "Disabling animations for test performance..." - adb shell settings put global window_animation_scale 0.0 || echo "Failed to disable window animations" - adb shell settings put global transition_animation_scale 0.0 || echo "Failed to disable transition animations" - adb shell settings put global animator_duration_scale 0.0 || echo "Failed to disable animator duration" - - echo "Starting integration test runner..." - ./integration_test_runner.sh - TEST_EXIT_CODE=$? - - if [ $TEST_EXIT_CODE -eq 0 ]; then - echo "Integration tests completed successfully" - else - echo "Integration tests failed with exit code $TEST_EXIT_CODE" - fi - - - name: ๐Ÿงน Post-test cleanup - if: always() - run: | - echo "=== Post-test cleanup ===" - # Kill any remaining emulator processes - pkill -f emulator || echo "No emulator processes to kill" - # Kill any remaining adb processes - pkill -f adb || echo "No adb processes to kill" - # Wait for processes to terminate - sleep 2 - # Check for any remaining processes - ps aux | grep -E "(emulator|adb)" | grep -v grep || echo "No remaining emulator/adb processes" - echo "=== Cleanup complete ===" - - name: cleanup - run: rm -rf build/app/outputs/flutter-apk/test-apk/ diff --git a/.github/workflows/funds_integration_tests.yml b/.github/workflows/funds_integration_tests.yml new file mode 100644 index 000000000..bfa02a269 --- /dev/null +++ b/.github/workflows/funds_integration_tests.yml @@ -0,0 +1,64 @@ +name: Funds Integration Tests + +permissions: + contents: read + packages: write + +# This test is never triggered automatically as it moves real funds +on: + workflow_dispatch: + inputs: + flows: + description: "Which flows to run" + required: true + type: choice + options: + - all + - send + - swap + default: all + chains: + description: "Chains the send suites run on. auto discovers every funded chain, or a comma separated list of wallet type names" + required: true + type: string + default: auto + swap_from: + description: "Wallet type the swap deposits from. Empty takes the first funded chain" + required: false + type: string + default: "" + swap_to: + description: "Currency the swap receives, for example eth or btc. Empty leaves the swap page's own pairing" + required: false + type: string + default: "" + spend: + description: "Actually broadcast and create trades. Off runs the dry runs only" + required: true + type: boolean + default: false + record_screen: + description: "Record the emulator screen, uploaded GPG encrypted" + required: true + type: boolean + default: true + +concurrency: + group: funds-integration-tests + cancel-in-progress: false + +jobs: + funds-tests: + uses: ./.github/workflows/reusable-integration-test.yml + with: + environment: funds + ref: ${{ github.sha }} + suite_dir: integration_test/funds_suites + test_tier: all + write_funds_secrets: true + record_screen: ${{ inputs.record_screen }} + timeout_minutes: 120 + slack_notify: true + extra_dart_defines: "FLOWS=${{ inputs.flows }};CHAINS=${{ inputs.chains }};SPEND=${{ inputs.spend }};SWAP_FROM=${{ inputs.swap_from }};SWAP_RECEIVE=${{ inputs.swap_to }}" + secrets: inherit + diff --git a/.github/workflows/integration_tests.yml b/.github/workflows/integration_tests.yml new file mode 100644 index 000000000..a310bb13a --- /dev/null +++ b/.github/workflows/integration_tests.yml @@ -0,0 +1,28 @@ +name: Integration Tests + +permissions: + contents: read + packages: write + +on: + pull_request: + branches: [dev] + paths-ignore: + - "**.md" + - "docs/**" + workflow_dispatch: + +concurrency: + group: integration-tests-${{ github.event.pull_request.number || github.ref }} + cancel-in-progress: true + +jobs: + android-integration-tests: + if: github.event_name == 'workflow_dispatch' || github.event.pull_request.head.repo.fork == false + uses: ./.github/workflows/reusable-integration-test.yml + with: + ref: ${{ github.event.pull_request.head.sha || github.sha }} + suite_dir: integration_test/suites + test_tier: tier0 + slack_notify: true + secrets: inherit diff --git a/.github/workflows/pr_test_build_linux.yml b/.github/workflows/pr_test_build_linux.yml index 37c2bcf3f..80713b7d2 100644 --- a/.github/workflows/pr_test_build_linux.yml +++ b/.github/workflows/pr_test_build_linux.yml @@ -24,7 +24,6 @@ jobs: KEY_PASS: test@cake_wallet MONEROC_CACHE_DIR_ROOT: /opt/generic_cache BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - DESKTOP_FORCE_MOBILE: Y volumes: - /opt/cw_cache_linux/root/.cache:/root/.cache - /opt/cw_cache_linux/root/.ccache:/root/.ccache @@ -61,14 +60,6 @@ jobs: git config --global --add safe.directory '*' git config --global user.email "ci@cakewallet.com" git config --global user.name "CakeWallet CI" - - - name: Get the full commit message - run: | - FULL_MESSAGE="$(git log -1 --pretty=%B)" - echo "message<> $GITHUB_ENV - echo "$FULL_MESSAGE" >> $GITHUB_ENV - echo "EOF" >> $GITHUB_ENV - - name: Add secrets run: | touch cw_bitcoin/lib/.secrets.g.dart @@ -143,54 +134,7 @@ jobs: path: ${{ github.workspace }}/build/linux/x64/release/cakewallet_linux.zip name: cakewallet_linux - - name: Prepare virtual desktop - if: ${{ contains(env.message, 'run tests') }} - run: | - nohup Xvfb :99 -screen 0 720x1280x16 & - echo DISPLAY=:99 | sudo tee -a $GITHUB_ENV - dbus-daemon --system --fork - nohup NetworkManager & - nohup ffmpeg -framerate 60 -video_size 720x1280 -f x11grab -i :99 -c:v libx264 -c:a aac /opt/screen_grab.mkv & - - # Note for people adding tests: - # - Tests are ran on Linux, with some things being mocked out. - # - Screen recording is being provided for the entire length of the test, you can download it in github articats. - # - Screen recordeding is encrypted, look at step "Stop screen recording, encrypt and upload", and add your key if you want - # Reason for encryption is the fact that we restore the wallet from seed, and we don't want to leak that, while there - # isn't much in those wallets anyway, we still wouldn't like to leak it to anyone who is able to access github. - - - name: Test [confirm_seeds_flow_test] - if: ${{ contains(env.message, 'run tests') }} - timeout-minutes: 20 - run: | - xmessage -timeout 30 "confirm_seeds_flow_test" & - rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/confirm_seeds_flow_test.dart - - - name: Test [create_wallet_flow_test] - if: ${{ contains(env.message, 'run tests') }} - timeout-minutes: 20 - run: | - xmessage -timeout 30 "create_wallet_flow_test" & - rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/create_wallet_flow_test.dart - - # - name: Test [exchange_flow_test] - # if: ${{ contains(env.message, 'run tests') }} - # timeout-minutes: 20 - # run: | - # xmessage -timeout 30 "exchange_flow_test" & - # rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - # exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/exchange_flow_test.dart - - - name: Test [restore_wallet_through_seeds_flow_test] - if: ${{ contains(env.message, 'run tests') }} - timeout-minutes: 20 - run: | - xmessage -timeout 30 "restore_wallet_through_seeds_flow_test" & - rm -rf ~/.local/share/com.example.cake_wallet/ ~/Documents/cake_wallet/ ~/cake_wallet - exec timeout --signal=SIGKILL 900 flutter drive --driver=test_driver/integration_test.dart --target=integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart - + # Integration tests moved to integration_tests.yml - name: Test [cw_core] timeout-minutes: 15 run: cd cw_core && flutter test @@ -198,32 +142,3 @@ jobs: - name: Test [cw_monero] timeout-minutes: 15 run: cd cw_monero && flutter test - - - name: Stop screen recording, encrypt and upload - if: always() - run: | - if [[ ! -f "/opt/screen_grab.mkv" ]]; - then - exit 0; - fi - killall ffmpeg - sleep 5 - killall -9 ffmpeg || true - sleep 5 - # Feel free to add your own public key if you wish - gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 6B3199AD9B3D23B8 # konstantin@cakewallet.com - gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 35C8DBAFB8D9ACAC # cyjan@mrcyjanek.net - gpg --trust-model always --encrypt --output /opt/screen_grab.mkv.gpg \ - --recipient 6B3199AD9B3D23B8 \ - --recipient 35C8DBAFB8D9ACAC \ - /opt/screen_grab.mkv - rm /opt/screen_grab.mkv - mv /opt/screen_grab.mkv.gpg ./screen_grab.mkv.gpg - - - name: Upload Artifact to github - if: always() - continue-on-error: true - uses: actions/upload-artifact@v4 - with: - path: ${{ github.workspace }}/screen_grab.mkv.gpg - name: tests_screen_grab diff --git a/.github/workflows/reusable-build.yml b/.github/workflows/reusable-build.yml index 4dc447573..aed7afc8c 100644 --- a/.github/workflows/reusable-build.yml +++ b/.github/workflows/reusable-build.yml @@ -27,21 +27,15 @@ jobs: KEY_PASS: test@cake_wallet MONEROC_CACHE_DIR_ROOT: /opt/generic_cache BRANCH_NAME: ${{ github.head_ref || github.ref_name }} - ANDROID_AVD_HOME: /root/.android/avd volumes: - /opt/cw_cache_android/root/.cache:/root/.cache - - /opt/cw_cache_android/root/.android/avd/:/root/.android/avd - /opt/cw_cache_android/root/.ccache:/root/.ccache - /opt/cw_cache_android/root/.pub-cache/:/root/.pub-cache - /opt/cw_cache_android/root/.gradle/:/root/.gradle - /opt/cw_cache_android/root/.android/:/root/.android - /opt/cw_cache_android/root/go/pkg:/root/go/pkg - /opt/cw_cache_android/opt/generic_cache:/opt/generic_cache - - /dev/kvm:/dev/kvm - /var/run/docker.sock:/var/run/docker.sock - strategy: - matrix: - api-level: [29] steps: - name: Fix github actions messing up $HOME... diff --git a/.github/workflows/reusable-integration-test.yml b/.github/workflows/reusable-integration-test.yml new file mode 100644 index 000000000..bd8bafd30 --- /dev/null +++ b/.github/workflows/reusable-integration-test.yml @@ -0,0 +1,598 @@ +name: Reusable Integration Test Logic + +permissions: + contents: read + packages: write + +on: + workflow_call: + inputs: + ref: + required: true + type: string + suite_dir: + required: false + type: string + default: "integration_test/suites" + test_tier: + required: false + type: string + default: "tier0" + extra_dart_defines: + required: false + type: string + default: "" + environment: + required: false + type: string + default: "" + record_screen: + required: false + type: boolean + default: false + write_funds_secrets: + required: false + type: boolean + default: false + timeout_minutes: + required: false + type: number + default: 240 + slack_notify: + required: false + type: boolean + default: false + +defaults: + run: + shell: bash + +jobs: + integration-test: + runs-on: ubuntu-latest + environment: ${{ inputs.environment }} + timeout-minutes: ${{ inputs.timeout_minutes }} + env: + STORE_PASS: test@cake_wallet + KEY_PASS: test@cake_wallet + BRANCH_NAME: ${{ github.head_ref || github.ref_name }} + + steps: + - uses: actions/checkout@v4 + with: + ref: ${{ inputs.ref }} + - name: Free disk space + run: | + sudo rm -rf /usr/share/dotnet /opt/ghc /usr/local/.ghcup /usr/local/lib/node_modules /opt/hostedtoolcache/CodeQL + sudo rm -rf /usr/share/swift /usr/local/share/powershell /usr/share/miniconda /opt/az /usr/local/graalvm + sudo docker image prune -af > /dev/null + df -h / + + - name: Enable KVM + run: | + echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules + sudo udevadm control --reload-rules + sudo udevadm trigger --name-match=kvm + + if [[ ! -c /dev/kvm ]]; then + echo "::error::/dev/kvm is not a character device, this runner has no usable KVM" + exit 1 + fi + + - uses: actions/setup-java@v4 + with: + distribution: temurin + java-version: "17" + + - name: Restore flutter sdk + id: flutter-cache + uses: actions/cache/restore@v4 + with: + path: ~/flutter + key: flutter-sdk-3.41.9-${{ runner.os }} + + - name: Install Flutter + run: | + if [[ ! -d "$HOME/flutter/bin" ]]; then + git clone --branch 3.41.9 --depth 1 https://github.com/flutter/flutter.git "$HOME/flutter" + fi + echo "$HOME/flutter/bin" >> $GITHUB_PATH + export PATH="$PATH:$HOME/flutter/bin" + flutter --version + + - name: Save flutter sdk + if: steps.flutter-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ~/flutter + key: flutter-sdk-3.41.9-${{ runner.os }} + + - name: Setup gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: false + + - name: Restore pub packages + id: pub-cache + uses: actions/cache/restore@v4 + with: + path: ~/.pub-cache + key: pub-cache-${{ runner.os }}-${{ hashFiles('pubspec_base.yaml', 'cw_*/pubspec.yaml') }} + restore-keys: pub-cache-${{ runner.os }}- + + - name: Add rust android targets + run: | + rustup target add x86_64-linux-android aarch64-linux-android armv7-linux-androideabi i686-linux-android + + - name: Log in to GitHub Container Registry + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.actor }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Add secrets + run: | + touch cw_bitcoin/lib/.secrets.g.dart + touch cw_evm/lib/.secrets.g.dart + touch cw_solana/lib/.secrets.g.dart + touch cw_core/lib/.secrets.g.dart + touch cw_nano/lib/.secrets.g.dart + touch cw_tron/lib/.secrets.g.dart + + echo "const polygonScanApiKey = '${{ secrets.POLYGON_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const etherScanApiKey = '${{ secrets.ETHER_SCAN_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const nowNodesApiKey = '${{ secrets.EVM_NOWNODES_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const blinkApiKey = '${{ secrets.BLINK_API_KEY }}';" >> cw_evm/lib/.secrets.g.dart + echo "const ankrApiKey = '${{ secrets.ANKR_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const chainStackApiKey = '${{ secrets.CHAIN_STACK_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + echo "const moralisApiKey = '${{ secrets.MORALIS_API_KEY }}';" >> cw_solana/lib/.secrets.g.dart + + echo "const nano2ApiKey = '${{ secrets.NANO2_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + echo "const nanoNowNodesApiKey = '${{ secrets.NANO_NOW_NODES_API_KEY }}';" >> cw_nano/lib/.secrets.g.dart + + echo "const tronGridApiKey = '${{ secrets.TRON_GRID_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + echo "const tronNowNodesApiKey = '${{ secrets.TRON_NOW_NODES_API_KEY }}';" >> cw_tron/lib/.secrets.g.dart + + echo "const breezApiKey = '${{ secrets.BREEZ_API_KEY }}';" >> cw_bitcoin/lib/.secrets.g.dart + + echo "${{ secrets.MAIN_SECRETS_FILE }}" | base64 --decode > lib/.secrets.g.dart + - name: Add funded wallet secrets + if: ${{ inputs.write_funds_secrets }} + run: | + echo "${{ secrets.FUNDS_SECRETS_FILE }}" | base64 --decode > integration_test/core/funded_wallets.dart + + - name: Build dependencies utilizing ghcr cache + run: | + env \ + CW_DOCKER_REGISTRY=ghcr.io/cake-tech/cake_wallet \ + CW_DOCKER_USE_CLOUD=true \ + ./scripts/android/docker/build.sh + + - name: Reclaim the space the dependency images took + run: | + docker system prune -af --volumes > /dev/null + df -h / + + - name: Regenerate mweb bindings + run: | + git checkout -- cw_mweb/pubspec.yaml + rm -f cw_mweb/pubspec.lock + rm -rf cw_mweb/.dart_tool + + # Hosted runners ship libclang, only install when it is actually missing + if ! ldconfig -p | grep -q libclang; then + sudo apt-get install -y --no-install-recommends libclang-dev > /dev/null + fi + + if ! command -v protoc > /dev/null || + [[ ! -f /usr/include/google/protobuf/descriptor.proto ]]; then + sudo apt-get install -y --no-install-recommends protobuf-compiler libprotobuf-dev > /dev/null + fi + + pushd cw_mweb + flutter pub get + dart run ffigen --config ffigen_config.yaml + popd + + # Fail here in seconds rather than minutes into the gradle build + if ! grep -q "final class GoSlice" cw_mweb/lib/generated_bindings.g.dart; then + echo "::error::mweb bindings regenerated without class modifiers, ffigen resolved too old" + exit 1 + fi + + - name: Execute Build and Setup Commands + run: | + pushd scripts/android + source ./app_env.sh cakewallet + ./app_config.sh + popd + + - name: Install Flutter dependencies + run: | + flutter pub get + + - name: Save pub packages + if: steps.pub-cache.outputs.cache-hit != 'true' + continue-on-error: true + uses: actions/cache/save@v4 + with: + path: ~/.pub-cache + key: pub-cache-${{ runner.os }}-${{ hashFiles('pubspec_base.yaml', 'cw_*/pubspec.yaml') }} + + - name: Build generated code + run: | + flutter --version + ./model_generator.sh + + - name: Generate KeyStore + run: | + keytool -genkey -v -keystore android/app/key.jks -keyalg RSA -keysize 2048 -validity 10000 -alias testKey -noprompt -dname "CN=CakeWallet, OU=CakeWallet, O=CakeWallet, L=Florida, S=America, C=USA" -storepass $STORE_PASS -keypass $KEY_PASS + + - name: Generate key properties + run: | + dart run tool/generate_android_key_properties.dart keyAlias=testKey storeFile=key.jks storePassword=$STORE_PASS keyPassword=$KEY_PASS + + - name: Generate localization + run: | + dart run tool/generate_localization.dart + + - name: Compile graphics + run: | + ./compile_graphics.sh + + - name: Compile assets/images graphics + run: | + while read -r dir; do + dart run vector_graphics_compiler --input-dir "$dir" --out-dir "$dir" > /dev/null & + done < <(find assets/images -type d) + wait + + - name: Rename app + run: | + sanitized_branch_name=${BRANCH_NAME#origin/} # Remove 'origin/' prefix if it exists + sanitized_branch_name=${sanitized_branch_name:0:16} # Take only the first 16 characters + sanitized_branch_name=$(echo "$sanitized_branch_name" | tr '[:upper:]' '[:lower:]') # Convert to lowercase + sanitized_branch_name=$(echo "$sanitized_branch_name" | sed 's/[^a-z0-9]//g') # Remove all special characters + + echo -e "id=com.cakewallet.test_${sanitized_branch_name}\nname=${BRANCH_NAME}" > android/app.properties + + - name: Prime test build + env: + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + TEST_TIER_INPUT: ${{ inputs.test_tier }} + run: | + search_dir="$SUITE_DIR_INPUT" + if [[ "$TEST_TIER_INPUT" != "all" ]]; then + search_dir="$search_dir/$TEST_TIER_INPUT" + fi + first_suite=$(find "$search_dir" -name "*_test.dart" -type f | sort | head -1) + echo "Priming build with $first_suite" + flutter build apk --debug --target-platform android-x64 --target="$first_suite" --dart-define=CI_BUILD=true + + - name: Cap the gradle heap for the test phase + run: | + sed -i 's/^org.gradle.jvmargs=.*/org.gradle.jvmargs=-Xmx3072M/' android/gradle.properties + grep '^org.gradle.jvmargs' android/gradle.properties + pkill -f GradleDaemon || echo "No gradle daemon to stop" + free -h || true + df -h / || true + + - name: Integration tests on Android Emulator + uses: reactivecircus/android-emulator-runner@v2 + env: + RECORD_SCREEN: ${{ inputs.record_screen }} + ENCRYPT_LOGS: ${{ inputs.write_funds_secrets }} + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + EXTRA_DART_DEFINES_INPUT: ${{ inputs.extra_dart_defines }} + with: + api-level: 29 + arch: x86_64 + profile: pixel_5 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 3072 -cores 4 -no-snapshot-save + disable-animations: false + working-directory: ${{ github.workspace }} + script: bash scripts/ci/run_integration_tests.sh ${{ inputs.test_tier }} + + - name: Upload test logs + if: always() + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: integration-test-logs + path: | + ${{ github.workspace }}/${{ inputs.test_tier }}_run.log + ${{ github.workspace }}/${{ inputs.test_tier }}_run.log.gpg + ${{ github.workspace }}/${{ inputs.test_tier }}_logcat.txt + ${{ github.workspace }}/${{ inputs.test_tier }}_logcat.txt.gpg + ${{ github.workspace }}/recordings/*.gpg + + - name: Integration tests on Android Emulator (tier1, non-blocking) + if: ${{ inputs.test_tier == 'tier0' }} + continue-on-error: true + uses: reactivecircus/android-emulator-runner@v2 + env: + RECORD_SCREEN: ${{ inputs.record_screen }} + ENCRYPT_LOGS: ${{ inputs.write_funds_secrets }} + SUITE_DIR_INPUT: ${{ inputs.suite_dir }} + EXTRA_DART_DEFINES_INPUT: ${{ inputs.extra_dart_defines }} + with: + api-level: 29 + arch: x86_64 + profile: pixel_5 + force-avd-creation: false + emulator-options: -no-window -gpu swiftshader_indirect -noaudio -no-boot-anim -camera-back none -read-only -memory 3072 -cores 4 -no-snapshot-save + disable-animations: false + working-directory: ${{ github.workspace }} + script: bash scripts/ci/run_integration_tests.sh tier1 + + - name: Upload tier1 logs + if: ${{ always() && inputs.test_tier == 'tier0' }} + continue-on-error: true + uses: actions/upload-artifact@v4 + with: + name: integration-test-logs-tier1 + path: | + ${{ github.workspace }}/tier1_run.log + ${{ github.workspace }}/tier1_run.log.gpg + ${{ github.workspace }}/tier1_logcat.txt + ${{ github.workspace }}/tier1_logcat.txt.gpg + + # Superseded runs are cancelled on purpose, reporting those would be noise + - name: Report the run to slack + if: ${{ !cancelled() && inputs.slack_notify }} + continue-on-error: true + env: + SLACK_TOKEN: ${{ secrets.SLACK_TESTS_TOKEN }} + SLACK_CHANNEL: ${{ secrets.SLACK_TESTS_CHANNEL }} + BRANCH: ${{ github.head_ref || github.ref_name }} + PR_NUMBER: ${{ github.event.pull_request.number }} + PR_TITLE: ${{ github.event.pull_request.title }} + COMMIT: ${{ github.event.pull_request.head.sha || github.sha }} + ACTOR: ${{ github.actor }} + REPO_URL: ${{ github.server_url }}/${{ github.repository }} + RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} + DART_DEFINES: ${{ inputs.extra_dart_defines }} + run: | + if [[ -z "$SLACK_TOKEN" || -z "$SLACK_CHANNEL" ]]; then + echo "::notice::No slack token or channel configured, skipping the report" + exit 0 + fi + + fmt() { + printf '%dm %02ds' $(( $1 / 60 )) $(( $1 % 60 )) + } + + field() { + awk -F= -v key="$2" '$1 == key { print $2; exit }' "$1" + } + + tier_line() { + local file="$1" label="$2" note="$3" + + if [[ ! -f "$file" ]]; then + printf '*%s*\n%s' "$label" "$note" + return + fi + + printf '*%s*\n%s/%s passed in %s' "$label" \ + "$(field "$file" passed)" "$(field "$file" total)" \ + "$(fmt "$(field "$file" duration)")" + } + + # For scenarios where the suite never ran because of driver issues, + # we can't call it a failing test, so we just log it + failure_lines() { + local file="$1" label="$2" + + [[ -f "$file" ]] || return 0 + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + + local rest="${entry#*|}" + local name="${entry%%|*}" + local duration="${rest%%|*}" + local reason="${rest#*|}" + + if [[ "$reason" == "void" ]]; then + printf '\nโ€ข `%s` (%s) _%s, driver never attached, the suite did not run_' \ + "$name" "$(fmt "$duration")" "$label" + else + printf '\nโ€ข `%s` (%s) _%s_' "$name" "$(fmt "$duration")" "$label" + fi + done < <(sed -n 's/^fail=//p' "$file") + } + + # Adding the results for the funds suites to the report too, so we know if we need to topup + chain_lines() { + local status_wanted="$1" icon="$2" file + local seen="" + + for file in *_summary.txt; do + [[ -f "$file" ]] || continue + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + + local chain="${entry%%|*}" + local rest="${entry#*|}" + local status="${rest%%|*}" + local detail="${rest#*|}" + + [[ "$status" == "$status_wanted" ]] || continue + [[ "$seen" == *"[$chain]"* ]] && continue + seen="$seen[$chain]" + + printf '\n%s *%s* _%s_' "$icon" "$chain" "$detail" + done < <(sed -n 's/^chain=//p' "$file") + done + } + + pass_names() { + local file="$1" label="$2" + + [[ -f "$file" ]] || return 0 + + printf '*%s* (%s)' "$label" "$(field "$file" passed)" + + while IFS= read -r entry; do + [[ -n "$entry" ]] || continue + printf '\nโ€ข %s _%s_' "${entry%%|*}" "$(fmt "${entry##*|}")" + done < <(sed -n 's/^pass=//p' "$file") + } + + tier0_failed=1 + tier1_failed=0 + total_passed=0 + + if [[ -f all_summary.txt ]]; then + tier0_failed=$(field all_summary.txt failed) + else + [[ -f tier0_summary.txt ]] && tier0_failed=$(field tier0_summary.txt failed) + [[ -f tier1_summary.txt ]] && tier1_failed=$(field tier1_summary.txt failed) + fi + + for file in tier0_summary.txt tier1_summary.txt; do + [[ -f "$file" ]] || continue + total_passed=$(( total_passed + $(field "$file" passed) )) + done + + if (( tier0_failed > 0 )); then + headline=":x: Integration tests failed" + elif (( tier1_failed > 0 )); then + headline=":warning: Gate passed, tier1 has failures" + else + headline=":white_check_mark: Integration tests passed" + fi + + if (( tier0_failed > 0 )); then + tier1_note="_skipped, tier0 failed_" + else + tier1_note="_did not run_" + fi + + where="<$REPO_URL/tree/$BRANCH|$BRANCH>" + if [[ -n "$PR_NUMBER" ]]; then + where="$where ยท <$REPO_URL/pull/$PR_NUMBER|#$PR_NUMBER ${PR_TITLE:-}>" + fi + + failures=$(printf '%s%s%s' \ + "$(failure_lines tier0_summary.txt tier0)" \ + "$(failure_lines tier1_summary.txt tier1)" \ + "$(failure_lines all_summary.txt suites)") + + if [[ -f all_summary.txt ]]; then + primary=$(tier_line all_summary.txt suites '') + secondary="" + else + primary=$(tier_line tier0_summary.txt tier0 '_did not run_') + secondary=$(tier_line tier1_summary.txt tier1 "$tier1_note") + fi + + chains=$(printf '%s%s%s%s' \ + "$(chain_lines ok ':white_check_mark:')" \ + "$(chain_lines empty ':moneybag:')" \ + "$(chain_lines waiting ':hourglass:')" \ + "$(chain_lines failed ':x:')") + + needs_funding=$(chain_lines empty ':moneybag:') + too_slow=$(chain_lines waiting ':hourglass:') + + blocks=$(jq -n \ + --arg headline "$headline" \ + --arg where "$where" \ + --arg primary "$primary" \ + --arg secondary "$secondary" \ + --arg failures "$failures" \ + --arg chains "$chains" \ + --arg funding "$needs_funding" \ + --arg slow "$too_slow" \ + --arg defines "$(printf '%s' "$DART_DEFINES" | tr ';' ' ')" \ + --arg commit "${COMMIT:0:8}" \ + --arg actor "$ACTOR" \ + --arg repo "$REPO_URL" \ + --arg run "$RUN_URL" \ + '[ + { type: "header", text: { type: "plain_text", text: $headline, emoji: true } }, + { type: "section", text: { type: "mrkdwn", text: $where } }, + { type: "section", fields: ( + [ { type: "mrkdwn", text: $primary } ] + + (if $secondary == "" then [] else + [ { type: "mrkdwn", text: $secondary } ] end) + ) } + ] + + (if $failures == "" then [] else + [ { type: "section", text: { type: "mrkdwn", + text: ("*Did not pass*" + $failures) } } ] + end) + + (if $chains == "" then [] else + [ { type: "divider" }, + { type: "section", text: { type: "mrkdwn", + text: ("*Chains*" + $chains) } } ] + end) + + (if $funding == "" then [] else + [ { type: "context", elements: [ { type: "mrkdwn", + text: ":moneybag: The chains above could not cover a send. Top those wallets up and re run the funds workflow, nothing there needs debugging :)." } ] } ] + end) + + (if $slow == "" then [] else + [ { type: "context", elements: [ { type: "mrkdwn", + text: ":hourglass: Still rescanning when the clock ran out, so nothing was proven either way and the run is not red for them." } ] } ] + end) + + [ + { type: "context", elements: [ + { type: "mrkdwn", + text: ("<" + $repo + "/commit/" + $commit + "|" + $commit + "> ยท " + $actor + + (if $defines == "" then "" else " ยท `" + $defines + "`" end)) } + ] }, + { type: "actions", elements: [ + { type: "button", url: $run, + text: { type: "plain_text", text: "View run", emoji: false } } + ] } + ]') + + response=$(curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_TOKEN" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL" --arg text "$headline" \ + --argjson blocks "$blocks" \ + '{channel: $channel, text: $text, blocks: $blocks}')") + + if [[ "$(jq -r '.ok' <<< "$response")" != "true" ]]; then + error=$(jq -r '.error // "unknown"' <<< "$response") + needed=$(jq -r '.needed // empty' <<< "$response") + + if [[ -n "$needed" ]]; then + echo "::warning::Slack rejected the report: $error, it wants the $needed scope" + else + echo "::warning::Slack rejected the report: $error" + fi + + exit 0 + fi + + thread_ts=$(jq -r '.ts // empty' <<< "$response") + + if [[ -z "$thread_ts" ]] || (( total_passed == 0 )); then + exit 0 + fi + + # So that the channel is not clutter (and uglyyyy), we put everything that passed in the thread + # and the short version is the one that shows up in the channel + passed=$(printf '%s\n\n%s' \ + "$(pass_names tier0_summary.txt tier0)" "$(pass_names tier1_summary.txt tier1)") + + curl -sS -X POST https://slack.com/api/chat.postMessage \ + -H "Authorization: Bearer $SLACK_TOKEN" \ + -H "Content-type: application/json; charset=utf-8" \ + --data "$(jq -n --arg channel "$SLACK_CHANNEL" --arg ts "$thread_ts" --arg text "$passed" \ + '{channel: $channel, thread_ts: $ts, text: $text}')" > /dev/null + + - name: Post-test cleanup + if: always() + run: | + pkill -f emulator || echo "No emulator processes to kill" + pkill -f adb || echo "No adb processes to kill" diff --git a/docs/INTEGRATION_TESTS.md b/docs/INTEGRATION_TESTS.md new file mode 100644 index 000000000..d405cf578 --- /dev/null +++ b/docs/INTEGRATION_TESTS.md @@ -0,0 +1,306 @@ +# Integration tests + +End to end tests that drive the real app on a device. They run on an Android emulator in CI +for every pull request into `dev`, and the funds suites run manually before releases. + +## Architecture + +Four layers, each one only talks to the layer below it: + +``` +suites/ the runnable tests, one file per user journey + tier0/ offline capable, no network assertions, the hard PR gate + tier1/ needs live network (nodes, swap providers), non blocking on PRs +funds_suites/ moves real funds, only ever run by the manual funds workflow +flows/ multi screen journeys shared between suites (onboarding, auth, wallet list) +robots/ one class per screen, knows that screen's keys and assertions +core/ the harness: BaseRobot primitives, AppLauncher, TestConfig, TestWallets +``` + +New robots extend `BaseRobot`. The older onboarding robots still hold a `CommonTestCases` +instead, which is the same idea with a weaker waiting story, migrate one over whenever you +touch it. + +Supporting pieces: + +- `test_driver/integration_test.dart` is the standard flutter drive driver. +- `integration_test_runner.sh` discovers and runs suites, see Running locally. +- `.github/workflows/integration_tests.yml` is the PR gate, + `reusable-integration-test.yml` holds the shared build and emulator pipeline. + +## Running locally + +Prerequisites, one time: + +1. A generated `lib/.secrets.g.dart` with the `*TestWalletSeeds` values populated. +2. App config for the cakewallet flavor: + `cd scripts/android && source ./app_env.sh cakewallet && ./app_config.sh` +3. Rename the app, every run wipes its data between suites: + + ``` + printf 'id=com.cakewallet.test_local\nname=local\n' > android/app.properties + ``` + + Step 2 writes the real `com.cakewallet.cake_wallet` id, and clearing that package takes + your own wallets with it. CI does this same rename after app_config for the same reason. + The runner refuses to clear a package that is not named for testing. +4. Compiled vector assets, both passes: + + ``` + ./compile_graphics.sh + while read -r dir; do + dart run vector_graphics_compiler --input-dir "$dir" --out-dir "$dir" + done < <(find assets/images -type d) + ``` + + `compile_graphics.sh` does not cover `assets/images`, and those `.svg.vec` files are + neither committed nor ignored, so a fresh checkout has none of them. Without this the + app throws "Unable to load asset" on the welcome screen and the first suite fails. +5. Native libs in `android/app/src/main/jniLibs/` for monero, wownero and zano + (prebuilts from the pinned monero_c release). +6. `~/.cargo/bin` on PATH so gradle can build the breez rust crates, and `protoc` installed + (`brew install protobuf`), the spark crate generates its protobuf bindings at build time. + +If the build sits at 0% CPU for minutes, close Android Studio or stop its Gradle daemon, +two daemons on this project deadlock on the same locks. + +Re-run steps 2 to 4 after every merge from dev, then `flutter pub get` at the root and the +codegen for `lib/`. The chain proxies in `lib//.dart` are generated and +gitignored, so a merge that adds a call like `bitcoin!.hasSelectedLightning` leaves them +behind and the build fails on a method that looks like it should exist. Step 2 also +rewrites `android/app.properties` with the real app id, so redo step 3 alongside it, and it +finishes by rewriting `pubspec.yaml` after its own last pub get, which is why the root pub +get has to come after it. Skipping step 4 shows up as "Unable to load asset" on whichever +screen dev added an icon to, one suite in, rather than at build time. + +Run one suite against a booted emulator: + +``` +flutter drive --driver=test_driver/integration_test.dart \ + --target=integration_test/suites/tier0/onboarding_create_test.dart \ + --dart-define=CI_BUILD=true -d +``` + +Run a whole tier through the runner: + +``` +TEST_TIER=tier0 FLUTTER_DEVICE= ./integration_test_runner.sh +``` + +`SUITE_DIR` also takes a single file, which is what you want while iterating on one suite: + +```bash +SUITE_DIR=integration_test/suites/tier0/fiat_currency_test.dart ./integration_test_runner.sh +``` + +Runner knobs (environment variables): `SUITE_DIR`, `TEST_TIER` (tier0, tier1, all), +`PLATFORM` (android, linux, macos, auto), `FLUTTER_DEVICE`, `RETRY_COUNT`, +`EXTRA_DART_DEFINES`, `REMOVE_DATA_DIRECTORY=N` to keep app data between suites, +`REMOVE_DESKTOP_DATA=Y` to let a desktop run wipe the wallet directories it shares with the +installed app, `VOID_GRACE` for how long a driver that cannot attach is given to recover. + +Test knobs (dart defines): `TEST_WALLET_TYPES=all` runs every available wallet type, +a comma separated list of type names runs just those, unset runs the representative set +(solana, ethereum, bitcoin, monero). + +## Which tier does my test go in + +- Creates wallets, navigates screens, checks local state: **tier0**. +- Needs a node, a provider API or any live network response: **tier1**. +- Signs and broadcasts a real transaction: **funds_suites**, never anywhere else. + +tier0 blocks merges. tier1 runs on every PR but reports without blocking until it has a +stable history. funds_suites run only from the manual funds workflow. + +## What is covered today + +Twenty seven suites. Add a line here when you add one. + +### tier0, no network needed + +| Suite | What it proves | +| --- | --- | +| `onboarding_create_test` | A wallet can be created for every configured type, and the app lands on the dashboard with that wallet open | +| `onboarding_restore_test` | A wallet restores from seed for every configured type, and the restored wallet reports the seed it was given | +| `invalid_seed_test` | A seed the wallet cannot parse leaves the user on the restore screen with nothing created | +| `duplicate_wallet_name_test` | A name already taken is refused instead of quietly overwriting the wallet holding it | +| `wallet_group_test` | A wallet added to an existing seed shares that seed, and both wallets stay reachable through the group | +| `wallet_rename_test` | A wallet can be renamed, and cannot be renamed onto a name another wallet already holds | +| `wallet_delete_test` | Deleting a wallet takes it off the list and out of storage, and leaves the others alone | +| `receive_address_test` | The receive sheet shows an address the opened wallet owns, not a leftover from the previous one | +| `seed_confirmation_test` | The seed and keys the app displays are the ones the opened wallet holds | +| `show_keys_auth_test` | The seed and keys page turns away a wrong pin and opens for the right one | +| `change_pin_test` | A changed pin is the one that opens what the old pin used to guard | +| `send_validation_test` | The send screen refuses an empty address, a malformed one, an empty amount and more than the balance | +| `address_book_test` | A saved contact keeps the address it was given | +| `send_from_address_book_test` | A contact picked on the send screen fills in the address that was saved for it | +| `settings_nav_test` | Every settings row opens its page and backs out of it | +| `language_test` | Changing the language changes what the settings screen shows | +| `fiat_currency_test` | Turning the fiat api off hides the currency setting, turning it back on returns it and the currency can be changed | + +### tier1, needs a node or a provider + +| Suite | What it proves | +| --- | --- | +| `sync_status_test` | A restored wallet reaches a node and starts syncing rather than sitting at connecting | +| `node_switching_test` | Confirming another node repoints the wallet at it | +| `transaction_history_test` | A wallet with known transactions renders them, both in the home preview and the full list | +| `transaction_details_test` | Tapping a transaction opens the details of that transaction and not another one | +| `swap_quote_test` | The swap sheet reaches a provider and returns a real quote, no trade is created | +| `monero_legacy_seed_test` | A 25 word monero seed restores through the legacy path, which takes a seed type and a restore height the polyseed path never asks for | + +### funds_suites, manual workflow only + +| Suite | What it proves | +| --- | --- | +| `send_dry_run_test` | Every funded chain builds and signs a transaction, then throws it away without broadcasting | +| `send_funds_test` | A real self transaction is broadcast on every funded chain | +| `swap_dry_run_test` | A real deposit is priced end to end, stopping before the trade is created | +| `swap_funds_test` | A swap is created and its deposit broadcast | + +The two dry runs need funded seeds because a quote and a signed transaction both depend on a +real balance, but neither spends anything. `SPEND=true` is what lets the two that do spend +past their guard. + +## Adding a test for your feature + +1. Give the widgets your test touches stable keys, inline in the widget: + `key: ValueKey('__key')`. Keys go on the widget the test taps or reads, + never on AnimatedSwitcher children where the key is the animation identity. +2. Create or extend the robot for the screen in `robots/`. One robot per screen, it + extends `BaseRobot` and implements `isDisplayed()`. Robots own every finder for their + screen, suites never call `find` directly. +3. If the journey spans several screens, add a flow to `flows/` composing the robots. +4. Copy `integration_test/templates/example_feature_test.dart.example` into the right + tier directory, rename it to `_test.dart` and fill it in. The runner only + picks up files ending in `_test.dart`. +5. Run it twice locally against an emulator before pushing. +6. Add it to the table above. + +## Harness rules + +- Suites use `integrationTest` from `core/app_launcher.dart`, not `testWidgets`. It contains + background async errors from app networking that would otherwise fail the test, awaited + assertion failures still fail the test normally. +- No `sleep` or fixed delays, use `pumpUntilFound` and `pumpUntil` with a timeout. +- No `pumpAndSettle` in new code, screens with endless animations never settle, + use the bounded `settle()` from BaseRobot. +- No `find.text` on localized strings, keys only. Wallet names and addresses are data, + matching on those is fine. +- `.secrets.g.dart` is only imported by `core/test_wallets.dart`. +- Every suite starts from a fresh install, the runner wipes app data between suites. +- Reading a view model through the page widget for a state assertion is fine, driving + the app through view models instead of the UI is not. + +## Secrets + +Test wallet seeds and receive addresses live in `lib/.secrets.g.dart` under names like +`solanaTestWalletSeeds`, defined in `tool/utils/secret_key.dart` and reachable through +`TestWallets`. CI injects the real values from the `MAIN_SECRETS_FILE` repository secret. + +Adding a new test secret means adding it in `tool/utils/secret_key.dart`, mapping it in +`TestWallets` and asking the team lead to add the value to the CI secret. + +The slack report uses two more repository secrets, `SLACK_TESTS_TOKEN` and +`SLACK_TESTS_CHANNEL`, which is a channel id rather than a webhook. Both the PR gate and +the funds workflow report through them, so every test result lands in one channel. + +That is a separate slack app from the one behind `SLACK_APP_TOKEN`, which uploads apks to +the builds channel. They are split so each token carries only what it needs, `chat:write` +for this one and the file scopes for that one, and so the name on a message says which of +the two sent it. A post coming back `missing_scope` means the app is short the +`chat:write` scope or was never reinstalled after it was added. + +## Funds suites + +`funds_suites/` restore funded wallets and move real funds: a small self send on every +funded chain, and one real swap with its deposit broadcast. They only run from the manual +`Funds Integration Tests` workflow, which the team lead dispatches from the Actions tab +before a release. The job runs in the `funds` environment, so add required reviewers to that +environment under Settings, Environments, or a dispatch still starts without an approval. + +Funded seeds live in the `FUNDS_SECRETS_FILE` secret, a base64 encoded replacement for +`integration_test/core/funded_wallets.dart` mapping wallet type names to that chain's +funded wallets, at least two per chain. A wallet with a passphrase carries it and the restore +flow ticks the passphrase box and enters it. A wallet without one leaves it out: + +```dart +import "funded_wallet.dart"; + +const Map> fundedWallets = { + "solana": [ + FundedWallet(seed: "first wallet seed words ...", passphrase: "first passphrase"), + FundedWallet(seed: "second wallet seed words ...", passphrase: "second passphrase"), + ], + "ethereum": [ + FundedWallet(seed: "seed words ..."), + ], +}; +``` + +A wrong passphrase restores an empty derivation, so it shows up the same way a drained +wallet does, as "no spendable balance". + +The suites restore a chain's funded wallets one by one and use the first that shows a +spendable balance, a wallet that finishes syncing while still empty counts as drained. +A chain where every funded wallet is empty fails with a message asking for a top up. + +The checked in default is an empty map and must stay empty. Only the funds workflow turns +on `write_funds_secrets`, and that input is the one thing keeping funded seeds out of a PR +build now that no environment scopes the secret. In auto mode the suites discover every +chain present in the map, so funding a new chain only means adding its entry to the +secret. The dispatch inputs narrow a run: `flows` picks send, swap or both, `chains` lists +the chains the send suites run on, `swap_from` picks the wallet type the swap deposits from +(empty takes the first funded chain) and `swap_to` the currency it receives (empty leaves +the swap page's own pairing, which is XMR for everything but a monero wallet). Whatever +`swap_to` names must have a fixture address in `TestWallets.receiveAddressFor`, that is +where the payout goes. Per chain send amounts are tuned in `TestConfig._fundsSendAmounts`. +The swap suite enters an amount just above the provider minimum, the deposit wallet's +balance has to cover that minimum plus fees. + +## How CI runs them + +The PR gate runs on GitHub hosted runners, the self-hosted android builders have no +usable KVM so the emulator cannot start there. Moving back is a one line change to +`runs-on` in `reusable-integration-test.yml` once a KVM capable builder exists. + +tier0 and tier1 each get their own emulator step and upload their logs separately, so a +runner that dies during tier1 cannot take the gate's own results with it. The gradle heap +is capped before the test phase because the daemon and the emulator together were +exhausting the runner. + +Every run posts to slack. The message names each tier's counts, its duration and any suite +that failed, and the full list of what passed goes in a reply on the same message so the +channel keeps the short version. A suite that failed because the driver never attached is +labelled as such rather than reported as a failing test, since in that case the suite never +ran at all. It needs `SLACK_TESTS_TOKEN` and `SLACK_TESTS_CHANNEL`, and +without either the step logs a notice and skips rather than failing the gate. Cancelled +runs stay quiet, since superseded runs are cancelled on purpose. The report is built from +the `SUMMARY_FILE` each tier writes, so anything the runner counts is available to it. + +Two environment quirks are worth knowing when a build fails in CI but not locally: the +deps docker image regenerates the mweb ffi bindings with an ffigen too old for the current +compiler and its rsync overwrites `cw_mweb/pubspec.yaml`, so the workflow restores that +file and regenerates the bindings itself. And `assets/images` svgs need compiling to +`.vec`, which `compile_graphics.sh` does not cover. + +## Flakiness playbook + +- A timeout in `pumpUntilFound` throws with the finder description. Diagnose through the + `integration-test-logs` artifact in CI, it holds the run log and a final logcat dump. + The timeout also takes a screenshot, it lands in `build/integration_test_screenshots/` + on the machine that ran the suite, named after the robot and the helper that timed out. + CI does not upload them. +- The app's own error handling ignores missing `.svg.vec` assets by design, the harness + filters those too. Any other FlutterError fails the test, that is intentional. +- Monero and wownero load native libraries on first open, give their steps generous + timeouts rather than retries. +- A driver that cannot attach is watched rather than waited out. Once flutter drive says + it is struggling, the attempt gets `VOID_GRACE` seconds (60 by default) to come back and + is then ended so the retry can start, instead of sitting there for the rest of + `TEST_TIMEOUT`. Run 31364715120 lost 20 minutes to two attempts that did exactly that. + 60 is well clear of a healthy attach, which took under a second on all 19 attempts that + worked in that same run, against 5 seconds for the warning to appear on the two that + did not. +- A suite that fails once in CI retries once with wiped app data. A red gate means the + same suite failed twice in a row. diff --git a/integration_test/components/common_test_cases.dart b/integration_test/components/common_test_cases.dart deleted file mode 100644 index c8f9843a6..000000000 --- a/integration_test/components/common_test_cases.dart +++ /dev/null @@ -1,260 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -class CommonTestCases { - WidgetTester tester; - CommonTestCases(this.tester); - - Future isSpecificPage() async { - await tester.pumpAndSettle(); - hasType(); - } - - Future tapItemByKey( - String key, { - bool shouldPumpAndSettle = true, - int pumpDuration = 100, - }) async { - await tester.pump(Duration(milliseconds: 500)); - - final widgetFinder = find.byKey(ValueKey(key)); - - expect(tester.any(widgetFinder), true, reason: 'Widget with key "$key" should be visible'); - - final widget = widgetFinder.first; - await tester.tap(widget, warnIfMissed: false); - shouldPumpAndSettle - ? await tester.pumpAndSettle(Duration(milliseconds: pumpDuration)) - : await tester.pump(); - } - - Future tapItemByFinder(Finder finder, {bool shouldPumpAndSettle = true}) async { - await tester.tap(finder); - shouldPumpAndSettle ? await tester.pumpAndSettle() : await tester.pump(); - } - - void hasText(String text, {bool hasWidget = true}) { - final textWidget = find.text(text); - expect(textWidget, hasWidget ? findsOneWidget : findsNothing); - } - - void hasTextAtLestOnce(String text, {bool hasWidget = true}) { - final textWidget = find.text(text); - expect(textWidget, hasWidget ? findsAny : findsNothing); - } - - void hasType() { - final typeWidget = find.byType(T); - expect(typeWidget, findsOneWidget); - } - - bool isKeyPresent(String key) { - final typeWidget = find.byKey(ValueKey(key)); - return typeWidget.tryEvaluate(); - } - - void hasValueKey(String key) { - final typeWidget = find.byKey(ValueKey(key)); - expect(typeWidget, findsOneWidget); - } - - Future swipePage({bool swipeRight = true}) async { - await tester.drag(find.byType(PageView), Offset(swipeRight ? -300 : 300, 0)); - await tester.pumpAndSettle(); - } - - Future swipeByPageKey({required String key, bool swipeRight = true}) async { - await tester.drag(find.byKey(ValueKey(key)), Offset(swipeRight ? -300 : 300, 0)); - await tester.pumpAndSettle(); - } - - Future goBack() async { - tester.printToConsole('Routing back to previous screen'); - final NavigatorState navigator = tester.state(find.byType(Navigator)); - navigator.pop(); - await tester.pumpAndSettle(); - } - - Future startGesture(String key, Offset gestureOffset) async { - await tester.pumpAndSettle(); - - final hasKey = isKeyPresent(key); - - tester.printToConsole("Has gestureKey: $hasKey"); - - if (!hasKey) return; - - final gesture = await tester.startGesture(tester.getCenter(find.byKey(ValueKey(key)))); - - // Drag to the left - await gesture.moveBy(gestureOffset); - - // End the gesture - await gesture.up(); - - await tester.pump(); - } - - Future dragUntilVisible( - String childKey, - String parentKey, { - int maxScrolls = 100, - double scrollStep = 50.0, - int maxReverseScrolls = 50, - }) async { - await tester.pumpAndSettle(); - - final itemFinder = find.byKey(ValueKey(childKey)); - final listFinder = find.byKey(ValueKey(parentKey)); - - // Check if the widget is already in the widget tree - if (tester.any(itemFinder)) { - // Widget is already built and in the tree - tester.printToConsole('Child is already present'); - return; - } - - // Find the Scrollable associated with the Parent Ad - final scrollableFinder = find.descendant( - of: listFinder, - matching: find.byType(Scrollable), - ); - - // Ensure that the Scrollable is found - expect( - scrollableFinder, - findsOneWidget, - reason: 'Scrollable descendant of the Parent Widget not found.', - ); - - // Get the initial scroll position - final scrollableState = tester.state(scrollableFinder); - double previousScrollPosition = scrollableState.position.pixels; - bool scrollDown = true; - bool reversedDirection = false; - bool found = false; - - int reverseScrollCount = 0; - - for (int scrolls = 0; scrolls < maxScrolls; scrolls++) { - await tester.pumpAndSettle(); - - // Check if the widget is visible - if (tester.any(itemFinder)) { - found = true; - break; - } - - // Log current state for debugging - tester.printToConsole('Scrolling ${scrollDown ? 'down' : 'up'}, attempt $scrolls'); - - // Stop if reverse scroll limit is exceeded - if (!scrollDown && reverseScrollCount >= maxReverseScrolls) { - tester.printToConsole('Maximum reverse scrolls reached. Widget not found.'); - break; - } - - // Perform scrolling in the current direction - await tester.drag( - scrollableFinder, - scrollDown ? Offset(0, -scrollStep) : Offset(0, scrollStep), - ); - await tester.pumpAndSettle(); - - // Update the scroll position after the drag - final currentScrollPosition = scrollableState.position.pixels; - - if (currentScrollPosition == previousScrollPosition) { - // Cannot scroll further in the current direction - if (reversedDirection) { - // We've already tried both directions - tester.printToConsole('Reached the scroll limit in both directions. Widget not found.'); - break; - } else { - // Reverse the scroll direction and reset reverse scroll count - scrollDown = !scrollDown; - reversedDirection = true; - reverseScrollCount = 0; - tester.printToConsole('Reached the end, reversing direction'); - } - } else { - // Update scroll position and reverse scroll count, incrementing only for reverse scrolling - previousScrollPosition = currentScrollPosition; - if (!scrollDown) reverseScrollCount++; - } - } - - if (!found) { - tester.printToConsole('Widget not found after $maxScrolls scrolls.'); - } - } - - Future scrollItemIntoView( - String itemKeyId, - double scrollPixels, - String scrollableFinderKey, - ) async { - final Finder itemFinder = find.byKey(ValueKey(itemKeyId)); - - final scrollableFinder = find.descendant( - of: find.byKey(ValueKey(scrollableFinderKey)), - matching: find.byType(Scrollable), - ); - - // Check if the item is already visible - if (tester.any(itemFinder)) { - tester.printToConsole('Item $itemKeyId is already visible'); - return; - } - - // Check if scrollable exists - if (!tester.any(scrollableFinder)) { - tester.printToConsole('Scrollable not found for $itemKeyId'); - return; - } - - try { - await tester.scrollUntilVisible( - itemFinder, - scrollPixels, - scrollable: scrollableFinder, - maxScrolls: 10, - ); - - // Wait for the scroll to complete - await tester.pumpAndSettle(Duration(milliseconds: 500)); - - // Verify the item is now visible - if (!tester.any(itemFinder)) { - tester.printToConsole('Item $itemKeyId not found after scrolling'); - } - } catch (e) { - tester.printToConsole('Could not scroll to $itemKeyId: $e'); - } - } - - Future enterText(String text, String editableTextKey) async { - final editableTextWidget = find.byKey(ValueKey((editableTextKey))); - - await tester.enterText(editableTextWidget, text); - - await tester.pumpAndSettle(); - } - - void findWidgetViaDescendant({ - required FinderBase of, - required FinderBase matching, - }) { - final textWidget = find.descendant(of: of, matching: matching); - - expect(textWidget, findsOneWidget); - } - - Future defaultSleepTime({int seconds = 2}) async => - await Future.delayed(Duration(seconds: seconds)); - - Future takeScreenshots(String screenshotName) async { - // Pausing this for now - // await (tester.binding as IntegrationTestWidgetsFlutterBinding).takeScreenshot(screenshotName); - } -} diff --git a/integration_test/components/common_test_constants.dart b/integration_test/components/common_test_constants.dart deleted file mode 100644 index f26254401..000000000 --- a/integration_test/components/common_test_constants.dart +++ /dev/null @@ -1,15 +0,0 @@ -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/wallet_type.dart'; - -class CommonTestConstants { - static final pin = [0, 8, 0, 1]; - static final String sendTestAmount = '0.00008'; - static final String sendTestFiatAmount = '1.00'; - static final String exchangeTestAmount = '0.01'; - static final WalletType testWalletType = WalletType.solana; - static final String testWalletName = 'Integrated Testing Wallet'; - static final CryptoCurrency sendTestReceiveCurrency = CryptoCurrency.sol; - static final CryptoCurrency exchangeTestReceiveCurrency = CryptoCurrency.usdtSol; - static final CryptoCurrency exchangeTestDepositCurrency = CryptoCurrency.sol; - static final String testWalletAddress = '7TSTXnQZxQDA4JoNqs4DqVqE7oNWk2kmHkMT6DD6vw2S'; -} diff --git a/integration_test/components/common_test_flows.dart b/integration_test/components/common_test_flows.dart deleted file mode 100644 index 888c2ce03..000000000 --- a/integration_test/components/common_test_flows.dart +++ /dev/null @@ -1,414 +0,0 @@ -import 'dart:io'; - -import 'package:cake_wallet/entities/seed_type.dart'; -import 'package:cake_wallet/reactions/wallet_utils.dart'; -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/currency_for_wallet_type.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:cake_wallet/main.dart' as app; - -import '../robots/create_pin_welcome_page_robot.dart'; -import '../robots/new_wallet_page_robot.dart'; -import '../robots/new_wallet_type_page_robot.dart'; -import '../robots/pre_seed_page_robot.dart'; -import '../robots/restore_from_seed_or_key_robot.dart'; -import '../robots/restore_options_page_robot.dart'; -import '../robots/seed_verification_page_robot.dart'; -import '../robots/setup_pin_code_robot.dart'; -import '../robots/wallet_group_description_page_robot.dart'; -import '../robots/wallet_list_page_robot.dart'; -import '../robots/wallet_seed_page_robot.dart'; -import '../robots/welcome_page_robot.dart'; -import 'common_test_cases.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -import 'common_test_constants.dart'; - -class CommonTestFlows { - CommonTestFlows(this._tester) - : _commonTestCases = CommonTestCases(_tester), - _welcomePageRobot = WelcomePageRobot(_tester), - _preSeedPageRobot = PreSeedPageRobot(_tester), - _setupPinCodeRobot = SetupPinCodeRobot(_tester), - _newWalletPageRobot = NewWalletPageRobot(_tester), - _walletSeedPageRobot = WalletSeedPageRobot(_tester), - _walletListPageRobot = WalletListPageRobot(_tester), - _newWalletTypePageRobot = NewWalletTypePageRobot(_tester), - _restoreOptionsPageRobot = RestoreOptionsPageRobot(_tester), - _seedVerificationPageRobot = SeedVerificationPageRobot(_tester), - _createPinWelcomePageRobot = CreatePinWelcomePageRobot(_tester), - _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(_tester), - _walletGroupDescriptionPageRobot = WalletGroupDescriptionPageRobot(_tester); - - final WidgetTester _tester; - final CommonTestCases _commonTestCases; - - final WelcomePageRobot _welcomePageRobot; - final PreSeedPageRobot _preSeedPageRobot; - final SetupPinCodeRobot _setupPinCodeRobot; - final NewWalletPageRobot _newWalletPageRobot; - final WalletSeedPageRobot _walletSeedPageRobot; - final WalletListPageRobot _walletListPageRobot; - final NewWalletTypePageRobot _newWalletTypePageRobot; - final RestoreOptionsPageRobot _restoreOptionsPageRobot; - final CreatePinWelcomePageRobot _createPinWelcomePageRobot; - final SeedVerificationPageRobot _seedVerificationPageRobot; - final RestoreFromSeedOrKeysPageRobot _restoreFromSeedOrKeysPageRobot; - final WalletGroupDescriptionPageRobot _walletGroupDescriptionPageRobot; - - //* ========== Handles flow to start the app afresh and accept disclaimer ============= - Future startAppFlow(Key key) async { - await app.main(topLevelKey: key); - - await _tester.pump(Duration(seconds: 2)); - - // // --------- Disclaimer Page ------------ - // // Tap checkbox to accept disclaimer - // await _disclaimerPageRobot.tapDisclaimerCheckbox(); - - // // Tap accept button - // await _disclaimerPageRobot.tapAcceptButton(); - } - - //* ========== Handles flow from welcome to creating a new wallet =============== - Future welcomePageToCreateNewWalletFlow( - WalletType walletTypeToCreate, - List walletPin, - ) async { - await _welcomeToCreateWalletPath(walletTypeToCreate, walletPin); - - await _generateNewWalletDetails(); - - await _confirmPreSeedInfo(); - - await _confirmWalletDetails(); - - await _verifyWalletSeed(); - } - - //* ========== Handles flow from welcome to restoring wallet from seeds =============== - Future welcomePageToRestoreWalletThroughSeedsFlow( - WalletType walletTypeToRestore, - String walletSeed, - List walletPin, - ) async { - await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); - await _restoreFromSeeds(walletTypeToRestore, walletSeed); - } - - //* ========== Handles flow from welcome to restoring wallet from keys =============== - Future welcomePageToRestoreWalletThroughKeysFlow( - WalletType walletTypeToRestore, - List walletPin, - ) async { - await _welcomeToRestoreFromSeedsOrKeysPath(walletTypeToRestore, walletPin); - await _restoreFromKeys(); - } - - void confirmAllAvailableWalletTypeIconsDisplayCorrectly() { - for (var walletType in availableWalletTypes) { - final imageUrl = walletTypeToCryptoCurrency(walletType).iconPath; - - final walletIconFinder = find.image( - Image.asset( - imageUrl!, - width: 32, - height: 32, - ).image, - ); - - expect(walletIconFinder, findsAny); - } - } - - //* ========== Handles creating new wallet flow from wallet list/menu =============== - Future createNewWalletFromWalletMenu(WalletType walletTypeToCreate) async { - _tester.printToConsole('Creating ${walletTypeToCreate.name} Wallet'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _walletListPageRobot.navigateToCreateNewWalletPage(); - await _commonTestCases.defaultSleepTime(); - - await _selectWalletTypeForWallet(walletTypeToCreate); - await _commonTestCases.defaultSleepTime(); - - // ---- Wallet Group/New Seed Implementation Comes here - await _walletGroupDescriptionPageFlow(true, walletTypeToCreate); - - await _generateNewWalletDetails(); - - await _confirmPreSeedInfo(); - - await _confirmWalletDetails(); - - await _verifyWalletSeed(); - - await _commonTestCases.defaultSleepTime(); - } - - Future _walletGroupDescriptionPageFlow(bool isNewSeed, WalletType walletType) async { - if (!isBIP39Wallet(walletType)) return; - - await _walletGroupDescriptionPageRobot.isWalletGroupDescriptionPage(); - - if (isNewSeed) { - await _walletGroupDescriptionPageRobot.navigateToCreateNewSeedPage(); - } else { - await _walletGroupDescriptionPageRobot.navigateToChooseWalletGroup(); - } - } - - //* ========== Handles restore wallet flow from wallet list/menu =============== - Future restoreWalletFromWalletMenu(WalletType walletType, String walletSeed) async { - _tester.printToConsole('Restoring ${walletType.name} Wallet'); - await _walletListPageRobot.navigateToRestoreWalletOptionsPage(); - await _commonTestCases.defaultSleepTime(); - - if (!Platform.isLinux) await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); - if (!Platform.isLinux) await _commonTestCases.defaultSleepTime(); - - await _selectWalletTypeForWallet(walletType); - await _commonTestCases.defaultSleepTime(); - - await _restoreFromSeeds(walletType, walletSeed); - await _commonTestCases.defaultSleepTime(); - } - - //* ========== Handles setting up pin code for wallet on first install =============== - Future setupPinCodeForWallet(List pin) async { - if (Platform.isLinux) return; - // ----------- SetupPinCode Page ------------- - // Confirm initial defaults - Widgets to be displayed etc - await _setupPinCodeRobot.isSetupPinCodePage(); - - await _setupPinCodeRobot.enterPinCode(pin); - await _setupPinCodeRobot.enterPinCode(pin); - await _setupPinCodeRobot.tapSuccessButton(); - } - - Future _welcomeToCreateWalletPath( - WalletType walletTypeToCreate, - List pin, - ) async { - await _createPinWelcomePageRobot.tapSetAPinButton(); - - await setupPinCodeForWallet(pin); - - await _welcomePageRobot.navigateToCreateNewWalletPage(); - - await _selectWalletTypeForWallet(walletTypeToCreate); - - if (_welcomePageRobot.hasNewSingleSeedButton()) { - await _welcomePageRobot.tapNewSingleSeed(); - } - } - - Future _welcomeToRestoreFromSeedsOrKeysPath( - WalletType walletTypeToRestore, - List pin, - ) async { - await _createPinWelcomePageRobot.tapSetAPinButton(); - - await setupPinCodeForWallet(pin); - - await _welcomePageRobot.navigateToRestoreWalletPage(); - - if (!Platform.isLinux) await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); - - await _selectWalletTypeForWallet(walletTypeToRestore); - } - - //* ============ Handles New Wallet Type Page ================== - Future _selectWalletTypeForWallet(WalletType type) async { - // ----------- NewWalletType Page ------------- - _tester.printToConsole('Selecting wallet type: ${type.name}'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _newWalletTypePageRobot.findParticularWalletTypeInScrollableList(type); - - // Tapping the wallet type navigates directly (no separate Next button). - _tester.printToConsole('Tapping wallet type: ${type.name}'); - await _newWalletTypePageRobot.selectWalletType(type); - - await _tester.pumpAndSettle(Duration(milliseconds: 2000)); - - _tester.printToConsole('Wallet type selection completed for: ${type.name}'); - } - - //* ============ Handles New Wallet Page ================== - Future _generateNewWalletDetails() async { - _tester.printToConsole('Attempting to find NewWalletPage...'); - - await _tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await _newWalletPageRobot.isNewWalletPage(); - _tester.printToConsole('NewWalletPage found successfully'); - - await _newWalletPageRobot.generateWalletName(); - - if (Platform.isLinux) { - // manual pin input - await _restoreFromSeedOrKeysPageRobot - .enterPasswordForWalletRestore(CommonTestConstants.pin.join("")); - await _restoreFromSeedOrKeysPageRobot - .enterPasswordRepeatForWalletRestore(CommonTestConstants.pin.join("")); - } - - await _newWalletPageRobot.onNextButtonPressed(); - } - - //* ============ Handles Pre Seed Page ===================== - Future _confirmPreSeedInfo() async { - await _preSeedPageRobot.isPreSeedPage(); - - await _preSeedPageRobot.onConfirmButtonPressed(); - } - - //* ============ Handles Wallet Seed Page ================== - Future _confirmWalletDetails() async { - await _walletSeedPageRobot.isWalletSeedPage(); - - _walletSeedPageRobot.confirmWalletDetailsDisplayCorrectly(); - - _walletSeedPageRobot.confirmWalletSeedReminderDisplays(); - - // await _walletSeedPageRobot.onCopySeedsButtonPressed(); - - await _walletSeedPageRobot.onVerifySeedButtonPressed(); - } - - //* ============ Handles Wallet Seed Verification Page ================== - - Future _verifyWalletSeed() async { - await _seedVerificationPageRobot.isSeedVerificationPage(); - - _seedVerificationPageRobot.hasTitle(); - - await _seedVerificationPageRobot.verifyWalletSeeds(); - } - - //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Seeds Action - Future _restoreFromSeeds(WalletType type, String walletSeed) async { - // ----------- RestoreFromSeedOrKeys Page ------------- - - await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions(); - await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(walletSeed); - - final numberOfWords = walletSeed.split(' ').length; - - if (numberOfWords == 25 && (type == WalletType.monero)) { - await _restoreFromSeedOrKeysPageRobot - .chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType.legacy); - - // Using a constant value of 2831400 for the blockheight as its the restore blockheight for our testing wallet - await _restoreFromSeedOrKeysPageRobot - .enterBlockHeightForWalletRestore(secrets.moneroTestWalletBlockHeight); - } - - if (Platform.isLinux) { - // manual pin input - await _restoreFromSeedOrKeysPageRobot - .enterPasswordForWalletRestore(CommonTestConstants.pin.join("")); - await _restoreFromSeedOrKeysPageRobot - .enterPasswordRepeatForWalletRestore(CommonTestConstants.pin.join("")); - } - - await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); - } - - //* Main Restore Actions - On the RestoreFromSeed/Keys Page - Restore from Keys Action - Future _restoreFromKeys() async { - await _commonTestCases.swipePage(); - await _commonTestCases.defaultSleepTime(); - - await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions( - isSeedFormEntry: false, - ); - - await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(''); - await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); - } - - //* ====== Utility Function to get test wallet seeds for each wallet type ======== - String getWalletSeedsByWalletType(WalletType walletType) { - switch (walletType) { - case WalletType.monero: - return secrets.moneroTestWalletSeeds; - case WalletType.bitcoin: - return secrets.bitcoinTestWalletSeeds; - case WalletType.ethereum: - return secrets.ethereumTestWalletSeeds; - case WalletType.litecoin: - return secrets.litecoinTestWalletSeeds; - case WalletType.bitcoinCash: - return secrets.bitcoinCashTestWalletSeeds; - case WalletType.polygon: - return secrets.polygonTestWalletSeeds; - case WalletType.solana: - return secrets.solanaTestWalletSeeds; - case WalletType.base: - return secrets.baseTestWalletSeeds; - case WalletType.arbitrum: - return secrets.arbitrumTestWalletSeeds; - case WalletType.bsc: - return secrets.bscTestWalletSeeds; - case WalletType.tron: - return secrets.tronTestWalletSeeds; - case WalletType.nano: - return secrets.nanoTestWalletSeeds; - case WalletType.wownero: - return secrets.wowneroTestWalletSeeds; - case WalletType.zano: - return secrets.zanoTestWalletSeeds; - case WalletType.decred: - return secrets.decredTestWalletSeeds; - case WalletType.dogecoin: - return secrets.dogeTestWalletSeeds; - case WalletType.zcash: - return secrets.zcashTestWalletSeeds; - case WalletType.none: - case WalletType.haven: - case WalletType.banano: - throw Exception("Unable to get seeds for ${walletType}"); - } - } - - //* ====== Utility Function to get test receive address for each wallet type ======== - String getReceiveAddressByWalletType(WalletType walletType) { - switch (walletType) { - case WalletType.monero: - return secrets.moneroTestWalletReceiveAddress; - case WalletType.bitcoin: - return secrets.bitcoinTestWalletReceiveAddress; - case WalletType.ethereum: - return secrets.ethereumTestWalletReceiveAddress; - case WalletType.litecoin: - return secrets.litecoinTestWalletReceiveAddress; - case WalletType.bitcoinCash: - return secrets.bitcoinCashTestWalletReceiveAddress; - case WalletType.polygon: - return secrets.polygonTestWalletReceiveAddress; - case WalletType.base: - return secrets.baseTestWalletReceiveAddress; - case WalletType.arbitrum: - return secrets.arbitrumTestWalletReceiveAddress; - case WalletType.bsc: - return secrets.bscTestWalletReceiveAddress; - case WalletType.solana: - return secrets.solanaTestWalletReceiveAddress; - case WalletType.tron: - return secrets.tronTestWalletReceiveAddress; - case WalletType.nano: - return secrets.nanoTestWalletReceiveAddress; - case WalletType.wownero: - return secrets.wowneroTestWalletReceiveAddress; - default: - return ''; - } - } -} diff --git a/integration_test/core/app_launcher.dart b/integration_test/core/app_launcher.dart new file mode 100644 index 000000000..6f12bec19 --- /dev/null +++ b/integration_test/core/app_launcher.dart @@ -0,0 +1,75 @@ +import "dart:async"; + +import "package:cake_wallet/main.dart" as app; +import "package:flutter/foundation.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "benign_errors.dart"; + +void integrationTest(String description, Future Function(WidgetTester tester) body) { + testWidgets(description, (tester) async { + final completer = Completer(); + + final run = runZonedGuarded(() async { + try { + await body(tester); + + if (!completer.isCompleted) { + completer.complete(); + } + } catch (error, stack) { + if (!completer.isCompleted) { + completer.completeError(error, stack); + } + } + }, (error, stack) { + final kind = benignErrorKind(error.toString()); + + if (kind != null) { + debugPrint("Ignoring benign $kind background error: $error"); + return; + } + + if (completer.isCompleted) { + debugPrint("Background error after the test finished: $error"); + return; + } + + completer.completeError(error, stack); + }); + + unawaited(run); + + await completer.future; + }); +} + +class AppLauncher { + AppLauncher(this.tester); + + final WidgetTester tester; + + Future launchApp({required String testKey}) async { + final bindingHandler = FlutterError.onError; + + await app.main(topLevelKey: ValueKey(testKey)); + + _installTestErrorHandler(bindingHandler); + + await tester.pump(const Duration(seconds: 2)); + } + + void _installTestErrorHandler(FlutterExceptionHandler? bindingHandler) { + FlutterError.onError = (details) { + final reportedByTestFramework = details.library == "Flutter test framework"; + final kind = reportedByTestFramework ? null : benignErrorKind(details.exceptionAsString()); + + if (kind != null) { + debugPrint("Ignoring benign $kind error: ${details.exceptionAsString()}"); + return; + } + + bindingHandler?.call(details); + }; + } +} diff --git a/integration_test/core/base_robot.dart b/integration_test/core/base_robot.dart new file mode 100644 index 000000000..7ee2113d2 --- /dev/null +++ b/integration_test/core/base_robot.dart @@ -0,0 +1,238 @@ +import "dart:async"; + +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "benign_errors.dart"; + +abstract class BaseRobot { + BaseRobot(this.tester); + + final WidgetTester tester; + + Future isDisplayed(); + + void drainBackgroundErrors() { + final Object? pending = tester.takeException(); + + if (pending == null) { + return; + } + + final kind = benignErrorKind(pending.toString()); + + if (kind != null) { + tester.printToConsole("Ignoring benign $kind background error: $pending"); + return; + } + + throw TestFailure("Unhandled background error: $pending"); + } + + Future _pumpStep(Duration step) async { + await tester.pump(step); + + drainBackgroundErrors(); + } + + Future pumpUntilFound( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (tester.any(finder)) { + return; + } + } + + await takeScreenshot("${runtimeType}_pump_until_found_timeout"); + throw TestFailure("Widget not found after ${timeout.inSeconds}s: $finder"); + } + + Future pumpUntilGone( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (!tester.any(finder)) { + return; + } + } + + await takeScreenshot("${runtimeType}_pump_until_gone_timeout"); + throw TestFailure("Widget still present after ${timeout.inSeconds}s: $finder"); + } + + Future tapByKey(String key, {Duration timeout = const Duration(seconds: 30)}) async { + final finder = find.byKey(ValueKey(key)); + + await pumpUntilFound(finder, timeout: timeout); + + // A tap during a route transition lands on the animating overlay instead of the widget + await settle(max: const Duration(seconds: 2)); + + await tester.tap(finder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future tapWhenVisible( + Finder finder, { + Duration timeout = const Duration(seconds: 30), + }) async { + await pumpUntilFound(finder, timeout: timeout); + + await settle(max: const Duration(seconds: 2)); + + await tester.tap(finder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future enterTextByKey(String key, String text) async { + final finder = find.byKey(ValueKey(key)); + + await pumpUntilFound(finder); + + await tester.enterText(finder.first, text); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future pumpUntil( + bool Function() condition, { + Duration timeout = const Duration(seconds: 30), + Duration step = const Duration(milliseconds: 100), + }) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(step); + + if (condition()) { + return true; + } + } + + return false; + } + + Future goBack() async { + final navigator = tester.state(find.byType(Navigator).last); + navigator.pop(); + + await tester.pump(const Duration(milliseconds: 500)); + } + + Future dismissModal() async { + final navigator = tester.state(find.byType(Navigator).first); + navigator.pop(); + + await tester.pump(const Duration(milliseconds: 500)); + } + + // We make this bounded instead of pumpAndSettle + Future settle({Duration max = const Duration(seconds: 3)}) async { + final endTime = DateTime.now().add(max); + + while (DateTime.now().isBefore(endTime)) { + await _pumpStep(const Duration(milliseconds: 100)); + + if (!tester.binding.hasScheduledFrame) { + return; + } + } + } + + Future isSpecificPage({Duration timeout = const Duration(seconds: 30)}) async { + await pumpUntilFound(find.byType(T), timeout: timeout); + + await settle(); + + hasType(); + } + + bool isKeyPresent(String key) => tester.any(find.byKey(ValueKey(key))); + + String describeScreen() { + final names = tester.allWidgets + .map((widget) => widget.runtimeType.toString()) + .where((name) => name.endsWith("Page") || name.endsWith("Sheet") || name.endsWith("Screen")) + .toSet() + .toList() + ..sort(); + + return names.join(", "); + } + + void hasType() { + expect(find.byType(T), findsOneWidget); + } + + void hasText(String text) { + expect(find.text(text), findsOneWidget); + } + + void hasTextAtLeastOnce(String text) { + expect(find.text(text), findsAny); + } + + String? textByKey(String key) { + final finder = find.byKey(ValueKey(key)); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).data; + } + + static bool _surfaceConverted = false; + + Future takeScreenshot(String name) async { + try { + final binding = tester.binding; + + if (binding is! IntegrationTestWidgetsFlutterBinding) { + return; + } + + if (!_surfaceConverted) { + await binding.convertFlutterSurfaceToImage(); + _surfaceConverted = true; + } + + var finished = false; + + unawaited( + binding.takeScreenshot("${name}_${DateTime.now().millisecondsSinceEpoch}").then( + (_) => finished = true, + onError: (Object error) { + finished = true; + tester.printToConsole("Screenshot $name failed: $error"); + }, + ), + ); + + final deadline = DateTime.now().add(const Duration(seconds: 10)); + + while (!finished && DateTime.now().isBefore(deadline)) { + await tester.pump(const Duration(milliseconds: 100)); + } + + if (!finished) { + tester.printToConsole("Screenshot $name gave up after 10s"); + } + } catch (e) { + tester.printToConsole("Screenshot $name failed: $e"); + } + } +} diff --git a/integration_test/core/benign_errors.dart b/integration_test/core/benign_errors.dart new file mode 100644 index 000000000..3e2248438 --- /dev/null +++ b/integration_test/core/benign_errors.dart @@ -0,0 +1,51 @@ +String? benignErrorKind(String message) { + if (_toleratedAssertions.any(message.contains)) { + return "assertion"; + } + + if (_toleratedAssets.any(message.contains)) { + return "asset"; + } + + if (_toleratedNetworkNoise.any(message.contains)) { + return "network"; + } + + return null; +} + +// Debug build assertions. The semantics ones are real accessibility bugs that already +// exist on dev, take them off this list once they are fixed. +const List _toleratedAssertions = [ + "overflowed by", + "minValue, and maxValue must be valid numbers", + "node.parent?._dirty", + "RenderBox was not laid out", +]; + +// CakeImageWidget reports these before falling back to the svg +const List _toleratedAssets = [ + "Unable to load asset", + "NetworkImageLoadException", + "Failed to load network image", +]; + +// The same failures exception_handler.dart ignores +const List _toleratedNetworkNoise = [ + "SocketException", + "HttpException", + "ClientException", + "HandshakeException", + "TimeoutException", + "Failed host lookup", + "Connection closed", + "Connection reset by peer", + "Connection refused", + "Connection timed out", + "Operation timed out", + "Network is unreachable", + "No route to host", + "Broken pipe", + "Software caused connection abort", + "Connection closed before full header", +]; diff --git a/integration_test/core/funded_wallet.dart b/integration_test/core/funded_wallet.dart new file mode 100644 index 000000000..fdaf6871e --- /dev/null +++ b/integration_test/core/funded_wallet.dart @@ -0,0 +1,6 @@ +class FundedWallet { + const FundedWallet({required this.seed, this.passphrase = ""}); + + final String seed; + final String passphrase; +} diff --git a/integration_test/core/funded_wallets.dart b/integration_test/core/funded_wallets.dart new file mode 100644 index 000000000..6076fcec6 --- /dev/null +++ b/integration_test/core/funded_wallets.dart @@ -0,0 +1,5 @@ +import "funded_wallet.dart"; + +// Empty on purpose and it has to stay empty in git. The manual funds workflow overwrites +// this file from the FUNDS_SECRETS_FILE secret, keyed by WalletType name. +const Map> fundedWallets = {}; diff --git a/integration_test/core/funds_outcome.dart b/integration_test/core/funds_outcome.dart new file mode 100644 index 000000000..b8900fcd2 --- /dev/null +++ b/integration_test/core/funds_outcome.dart @@ -0,0 +1,46 @@ +import "package:flutter_test/flutter_test.dart"; + +class FundsOutcome { + static const marker = "FUNDS_RESULT"; + + static void ok(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "ok", detail); + + static void empty(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "empty", detail); + + static void waiting(WidgetTester tester, String chain, String detail) => + _write(tester, chain, "waiting", detail); + + static void failed(WidgetTester tester, String chain, String detail) => + _write(tester, chain, statusFor(detail), detail); + + static String statusFor(String detail) { + if (_isWalletBalanceLow(detail)) { + return "empty"; + } + + return _isChainTooSlowToSync(detail) ? "waiting" : "failed"; + } + + static bool needsFurtherReview(String detail) => statusFor(detail) == "failed"; + + static bool _isChainTooSlowToSync(String detail) => + detail.toLowerCase().contains("never became ready to send"); + + // Low wallet balance is not a failure, we just need to add fundsss + static bool _isWalletBalanceLow(String detail) { + final wording = detail.toLowerCase(); + + return wording.contains("not enough") || + wording.contains("wrong balance") || + wording.contains("insufficient") || + wording.contains("no spendable"); + } + + static void _write(WidgetTester tester, String chain, String status, String detail) { + final clean = detail.replaceAll("\n", " ").replaceAll("|", "/").trim(); + + tester.printToConsole("$marker|$chain|$status|$clean"); + } +} diff --git a/integration_test/core/test_config.dart b/integration_test/core/test_config.dart new file mode 100644 index 000000000..fb6404b7f --- /dev/null +++ b/integration_test/core/test_config.dart @@ -0,0 +1,112 @@ +import "package:cake_wallet/wallet_types.g.dart"; +import "package:cw_core/wallet_type.dart"; + +import "test_wallets.dart"; + +class TestConfig { + static final List pin = [0, 8, 0, 1]; + + static const bool isCIBuild = bool.fromEnvironment("CI_BUILD"); + + static const String _walletTypesOverride = String.fromEnvironment("TEST_WALLET_TYPES"); + + // Solana and ethereum cover the cheap key-derivation chains, + // bitcoin covers the electrum family and monero covers the native-library chains. + static final List _representativeWalletTypes = [ + WalletType.solana, + WalletType.ethereum, + WalletType.bitcoin, + WalletType.monero, + ]; + + static List get walletTypesUnderTest { + if (_walletTypesOverride == "all") { + return availableWalletTypes; + } + + if (_walletTypesOverride.isNotEmpty) { + return _walletTypesOverride + .split(",") + .map((name) => WalletType.values.byName(name.trim())) + .where(availableWalletTypes.contains) + .toList(); + } + + return _representativeWalletTypes.where(availableWalletTypes.contains).toList(); + } + + static const String _fundsFlows = String.fromEnvironment("FLOWS", defaultValue: "all"); + + static const String _spend = String.fromEnvironment("SPEND", defaultValue: "false"); + + static const String _swapFrom = String.fromEnvironment("SWAP_FROM"); + + static const String swapReceive = String.fromEnvironment("SWAP_RECEIVE"); + + static WalletType swapDepositType(List funded) => + _swapFrom.isEmpty ? funded.first : _walletTypeByName(_swapFrom, "SWAP_FROM"); + + static bool shouldRunFundsFlow(String flow) => _fundsFlows == "all" || _fundsFlows == flow; + + static bool shouldDryRun(String flow) => shouldRunFundsFlow(flow); + + static bool shouldSpend(String flow) => _spend == "true" && shouldRunFundsFlow(flow); + + static const String _fundedChainsOverride = String.fromEnvironment( + "CHAINS", + defaultValue: "auto", + ); + + static List get fundedWalletTypesUnderTest { + if (_fundedChainsOverride == "auto") { + return TestWallets.fundedWalletTypes; + } + + return _fundedChainsOverride + .split(",") + .map((name) => _walletTypeByName(name, "CHAINS")) + .where((type) => TestWallets.fundedWalletsFor(type).isNotEmpty) + .toList(); + } + + static const Map _fundsSendAmounts = { + "solana": "0.0001", + "ethereum": "0.00001", + "polygon": "0.01", + "base": "0.00001", + "arbitrum": "0.00001", + "bsc": "0.000001", + "tron": "1", + "bitcoin": "0.00001", + "litecoin": "0.001", + "bitcoinCash": "0.0002", + "dogecoin": "2", + "monero": "0.0001", + "wownero": "0.1", + "zcash": "0.0001", + "nano": "0.000001", + "decred": "0.001", + "zano": "0.001", + }; + + static String fundsSendAmountFor(WalletType type) => _fundsSendAmounts[type.name] ?? "0.0001"; + + // Making this two hours to accommodate monero that takes a longgg time syncing + // also, this would only affect the funded tests which need actual funds to test + static const Duration walletSyncBudget = Duration(hours: 2); + + static WalletType _walletTypeByName(String name, String input) { + final trimmed = name.trim(); + + final type = WalletType.values.where((value) => value.name == trimmed).toList(); + + if (type.isEmpty) { + throw ArgumentError( + 'Unknown wallet type "$trimmed" in $input, ' + "valid names: ${WalletType.values.map((value) => value.name).join(", ")}", + ); + } + + return type.first; + } +} diff --git a/integration_test/core/test_wallets.dart b/integration_test/core/test_wallets.dart new file mode 100644 index 000000000..68565c763 --- /dev/null +++ b/integration_test/core/test_wallets.dart @@ -0,0 +1,102 @@ +import "package:cake_wallet/.secrets.g.dart" as secrets; +import "package:cake_wallet/wallet_types.g.dart"; +import "package:cw_core/wallet_type.dart"; + +import "funded_wallet.dart"; +import "funded_wallets.dart"; + +class TestWallets { + static List get fundedWalletTypes => + availableWalletTypes.where((type) => fundedWalletsFor(type).isNotEmpty).toList(); + + static List fundedWalletsFor(WalletType type) => + (fundedWallets[type.name] ?? []).where((wallet) => wallet.seed.trim().isNotEmpty).toList(); + + static String seedFor(WalletType type) { + switch (type) { + case WalletType.monero: + return secrets.moneroTestWalletSeeds; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletSeeds; + case WalletType.ethereum: + return secrets.ethereumTestWalletSeeds; + case WalletType.litecoin: + return secrets.litecoinTestWalletSeeds; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletSeeds; + case WalletType.polygon: + return secrets.polygonTestWalletSeeds; + case WalletType.solana: + return secrets.solanaTestWalletSeeds; + case WalletType.base: + return secrets.baseTestWalletSeeds; + case WalletType.arbitrum: + return secrets.arbitrumTestWalletSeeds; + case WalletType.bsc: + return secrets.bscTestWalletSeeds; + case WalletType.tron: + return secrets.tronTestWalletSeeds; + case WalletType.nano: + return secrets.nanoTestWalletSeeds; + case WalletType.wownero: + return secrets.wowneroTestWalletSeeds; + case WalletType.zano: + return secrets.zanoTestWalletSeeds; + case WalletType.decred: + return secrets.decredTestWalletSeeds; + case WalletType.dogecoin: + return secrets.dogeTestWalletSeeds; + case WalletType.zcash: + return secrets.zcashTestWalletSeeds; + case WalletType.none: + case WalletType.haven: + case WalletType.banano: + throw Exception("No test wallet seed available for ${type.name}"); + } + } + + static String receiveAddressFor(WalletType type) { + switch (type) { + case WalletType.monero: + return secrets.moneroTestWalletReceiveAddress; + case WalletType.bitcoin: + return secrets.bitcoinTestWalletReceiveAddress; + case WalletType.ethereum: + return secrets.ethereumTestWalletReceiveAddress; + case WalletType.litecoin: + return secrets.litecoinTestWalletReceiveAddress; + case WalletType.bitcoinCash: + return secrets.bitcoinCashTestWalletReceiveAddress; + case WalletType.polygon: + return secrets.polygonTestWalletReceiveAddress; + case WalletType.base: + return secrets.baseTestWalletReceiveAddress; + case WalletType.arbitrum: + return secrets.arbitrumTestWalletReceiveAddress; + case WalletType.bsc: + return secrets.bscTestWalletReceiveAddress; + case WalletType.solana: + return secrets.solanaTestWalletReceiveAddress; + case WalletType.tron: + return secrets.tronTestWalletReceiveAddress; + case WalletType.nano: + return secrets.nanoTestWalletReceiveAddress; + case WalletType.wownero: + return secrets.wowneroTestWalletReceiveAddress; + case WalletType.zano: + case WalletType.decred: + case WalletType.dogecoin: + case WalletType.zcash: + case WalletType.none: + case WalletType.haven: + case WalletType.banano: + return ""; + } + } + + static String get moneroLegacySeed => secrets.moneroLegacyTestWalletSeeds; + + static String get moneroRestoreBlockHeight => secrets.moneroTestWalletBlockHeight; + + static String get zcashRestoreHeight => secrets.zcashTestWalletRestoreHeight; +} diff --git a/integration_test/flows/auth_flows.dart b/integration_test/flows/auth_flows.dart new file mode 100644 index 000000000..259906870 --- /dev/null +++ b/integration_test/flows/auth_flows.dart @@ -0,0 +1,41 @@ +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../robots/auth_page_robot.dart"; + +class AuthFlows { + AuthFlows(this.tester) : _authPageRobot = AuthPageRobot(tester); + + final WidgetTester tester; + final AuthPageRobot _authPageRobot; + + Future authenticateWithPin({List? pin, bool required = true}) async { + final prompted = await _waitForPinWidget( + timeout: required ? const Duration(seconds: 30) : const Duration(seconds: 5), + ); + + if (!prompted) { + expect(required, false, reason: "The pin was never asked for"); + + return; + } + + await _authPageRobot.enterPinCode(pin ?? TestConfig.pin); + } + + bool isPinWidgetShown() => _authPageRobot.onAuthPage(); + + Future _waitForPinWidget({Duration timeout = const Duration(seconds: 30)}) async { + final endTime = DateTime.now().add(timeout); + + while (DateTime.now().isBefore(endTime)) { + await tester.pump(const Duration(milliseconds: 100)); + + if (_authPageRobot.onAuthPage()) { + return true; + } + } + + return false; + } +} diff --git a/integration_test/flows/funds_flows.dart b/integration_test/flows/funds_flows.dart new file mode 100644 index 000000000..f33d2ee2d --- /dev/null +++ b/integration_test/flows/funds_flows.dart @@ -0,0 +1,155 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/sync_status.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_dashboard_robot.dart"; +import "../robots/new_send_page_robot.dart"; +import "onboarding_flows.dart"; + +class FundsFlows { + FundsFlows(this.tester) + : _onboardingFlows = OnboardingFlows(tester), + _dashboardRobot = NewDashboardRobot(tester), + _homePageRobot = HomePageRobot(tester), + _sendRobot = NewSendPageRobot(tester); + + final WidgetTester tester; + final OnboardingFlows _onboardingFlows; + final NewDashboardRobot _dashboardRobot; + final HomePageRobot _homePageRobot; + final NewSendPageRobot _sendRobot; + + bool _hasRestoredAnyWallet = false; + + Future openFundedWallet(WalletType type) async { + final wallets = TestWallets.fundedWalletsFor(type); + + for (int index = 0; index < wallets.length; index++) { + final wallet = wallets[index]; + + tester.printToConsole("Trying funded wallet ${index + 1}/${wallets.length} for ${type.name}"); + + if (_hasRestoredAnyWallet) { + await _dashboardRobot.openWalletsTab(); + + final isWalletListReady = await _dashboardRobot.pumpUntil( + () => + tester.any(find.byKey(const ValueKey("wallet_list_page_restore_wallet_button_key"))), + timeout: const Duration(seconds: 60), + ); + + expect( + isWalletListReady, + true, + reason: "The wallet list restore button didn't show before ${type.name}, " + "Currently have: ${_dashboardRobot.describeScreen()} on screen", + ); + + await _onboardingFlows.restoreAdditionalWalletFromWalletList( + type, + seed: wallet.seed, + passphrase: wallet.passphrase, + ); + } else { + await _onboardingFlows.restoreFirstWalletFromSeed( + type, + seed: wallet.seed, + passphrase: wallet.passphrase, + ); + } + + _hasRestoredAnyWallet = true; + + await _waitForDashboard(type); + await _homePageRobot.isDisplayed(); + + if (await _hasSpendableBalance()) { + return true; + } + + tester.printToConsole("Wallet ${index + 1} for ${type.name} has no spendable balance"); + } + + return false; + } + + Future returnToHome({int maxPops = 6}) async { + for (int attempt = 0; attempt <= maxPops; attempt++) { + if (await _homePageRobot.pumpUntil( + () => _isHomeInFront, + timeout: const Duration(seconds: 3), + )) { + return true; + } + + try { + await _homePageRobot.dismissModal(); + } catch (e) { + tester.printToConsole("Nothing left to pop while returning home: $e"); + break; + } + } + + return _isHomeInFront; + } + + Future _waitForDashboard(WalletType type) async { + final reached = await _dashboardRobot.pumpUntil( + () => tester.any(find.byKey(const ValueKey("new_dashboard_page_key"))), + timeout: TestConfig.walletSyncBudget, + ); + + if (reached) { + return; + } + + final status = getIt.get().wallet?.syncStatus; + final stillSyncing = status is SyncingSyncStatus || status is SyncronizingSyncStatus; + + throw TestFailure( + stillSyncing + ? "${type.name} never became ready to send, it is still syncing (${status.runtimeType})" + : "${type.name} never reached the dashboard, its status was ${status.runtimeType}", + ); + } + + bool get _isHomeInFront => _homePageRobot.isInFront && !_sendRobot.isSendFlowOpen; + + Future _hasSpendableBalance({Duration timeout = TestConfig.walletSyncBudget}) async { + final appStore = getIt.get(); + final endTime = DateTime.now().add(timeout); + DateTime? syncedSince; + + while (DateTime.now().isBefore(endTime)) { + await tester.pump(const Duration(milliseconds: 250)); + + final wallet = appStore.wallet; + + if (wallet == null) { + continue; + } + + if (wallet.balance.values.any((balance) => balance.available.sign > 0)) { + return true; + } + + if (wallet.syncStatus is SyncedSyncStatus) { + syncedSince ??= DateTime.now(); + + if (DateTime.now().difference(syncedSince) > const Duration(seconds: 15)) { + return false; + } + } else { + syncedSince = null; + } + } + + return false; + } +} diff --git a/integration_test/flows/onboarding_flows.dart b/integration_test/flows/onboarding_flows.dart new file mode 100644 index 000000000..6216c68b0 --- /dev/null +++ b/integration_test/flows/onboarding_flows.dart @@ -0,0 +1,267 @@ +import "dart:io"; + +import "package:cake_wallet/entities/seed_type.dart"; +import "package:cake_wallet/reactions/wallet_utils.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../robots/create_pin_welcome_page_robot.dart"; +import "../robots/lightning_username_page_robot.dart"; +import "../robots/new_wallet_page_robot.dart"; +import "../robots/new_wallet_type_page_robot.dart"; +import "../robots/pre_seed_page_robot.dart"; +import "../robots/restore_from_seed_or_key_robot.dart"; +import "../robots/restore_options_page_robot.dart"; +import "../robots/seed_verification_page_robot.dart"; +import "../robots/setup_pin_code_robot.dart"; +import "../robots/wallet_group_description_page_robot.dart"; +import "../robots/wallet_group_existing_seed_page_robot.dart"; +import "../robots/wallet_groups_display_page_robot.dart"; +import "../robots/wallet_list_page_robot.dart"; +import "../robots/wallet_seed_page_robot.dart"; +import "../robots/welcome_page_robot.dart"; + +class OnboardingFlows { + OnboardingFlows(this.tester) + : _welcomePageRobot = WelcomePageRobot(tester), + _preSeedPageRobot = PreSeedPageRobot(tester), + _setupPinCodeRobot = SetupPinCodeRobot(tester), + _newWalletPageRobot = NewWalletPageRobot(tester), + _walletSeedPageRobot = WalletSeedPageRobot(tester), + _walletListPageRobot = WalletListPageRobot(tester), + _newWalletTypePageRobot = NewWalletTypePageRobot(tester), + _seedVerificationPageRobot = SeedVerificationPageRobot(tester), + _createPinWelcomePageRobot = CreatePinWelcomePageRobot(tester), + _restoreOptionsPageRobot = RestoreOptionsPageRobot(tester), + _restoreFromSeedOrKeysPageRobot = RestoreFromSeedOrKeysPageRobot(tester), + _lightningUsernamePageRobot = LightningUsernamePageRobot(tester), + _walletGroupDescriptionPageRobot = WalletGroupDescriptionPageRobot(tester), + _walletGroupsDisplayPageRobot = WalletGroupsDisplayPageRobot(tester), + _walletGroupExistingSeedPageRobot = WalletGroupExistingSeedPageRobot(tester); + + final WidgetTester tester; + + final WelcomePageRobot _welcomePageRobot; + final PreSeedPageRobot _preSeedPageRobot; + final SetupPinCodeRobot _setupPinCodeRobot; + final NewWalletPageRobot _newWalletPageRobot; + final WalletSeedPageRobot _walletSeedPageRobot; + final WalletListPageRobot _walletListPageRobot; + final NewWalletTypePageRobot _newWalletTypePageRobot; + final SeedVerificationPageRobot _seedVerificationPageRobot; + final CreatePinWelcomePageRobot _createPinWelcomePageRobot; + final RestoreOptionsPageRobot _restoreOptionsPageRobot; + final RestoreFromSeedOrKeysPageRobot _restoreFromSeedOrKeysPageRobot; + final LightningUsernamePageRobot _lightningUsernamePageRobot; + final WalletGroupDescriptionPageRobot _walletGroupDescriptionPageRobot; + final WalletGroupsDisplayPageRobot _walletGroupsDisplayPageRobot; + final WalletGroupExistingSeedPageRobot _walletGroupExistingSeedPageRobot; + + Future createFirstWallet(WalletType type, {List? pin}) async { + await _createPinWelcomePageRobot.tapSetAPinButton(); + + await setupPinCode(pin ?? TestConfig.pin); + + await _welcomePageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + if (_welcomePageRobot.hasNewSingleSeedButton()) { + await _welcomePageRobot.tapNewSingleSeed(); + } + + await _completeWalletCreationSteps(type); + } + + Future createAdditionalWalletFromWalletList(WalletType type) async { + tester.printToConsole("Creating ${type.name} wallet"); + + await startCreatingWalletFromWalletList(type); + + await _completeWalletCreationSteps(type); + } + + Future createWalletInGroupOf(WalletType type, String existingWalletName) async { + tester.printToConsole("Adding a ${type.name} wallet to the seed of $existingWalletName"); + + await _walletListPageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + await _walletGroupDescriptionPageRobot.isDisplayed(); + await _walletGroupDescriptionPageRobot.navigateToChooseWalletGroup(); + + await _walletGroupsDisplayPageRobot.isDisplayed(); + await _walletGroupsDisplayPageRobot.selectWallet(existingWalletName); + await _walletGroupsDisplayPageRobot.continueWithSelectedSeed(); + + await _newWalletPageRobot.isDisplayed(); + await _newWalletPageRobot.generateWalletName(); + await _newWalletPageRobot.onNextButtonPressed(); + + await _walletGroupExistingSeedPageRobot.isDisplayed(); + await _walletGroupExistingSeedPageRobot.openWallet(); + } + + Future startCreatingWalletFromWalletList(WalletType type) async { + await _walletListPageRobot.navigateToCreateNewWalletPage(); + + await _selectWalletType(type); + + if (isBIP39Wallet(type)) { + await _walletGroupDescriptionPageRobot.isDisplayed(); + await _walletGroupDescriptionPageRobot.navigateToCreateNewSeedPage(); + } + + await _newWalletPageRobot.isDisplayed(); + } + + Future restoreFirstWalletFromSeed( + WalletType type, { + String? seed, + String passphrase = "", + List? pin, + }) async { + await startRestoringFirstWallet(type, pin: pin); + + await _restoreFromSeed(type, seed ?? TestWallets.seedFor(type), passphrase); + } + + Future startRestoringFirstWallet(WalletType type, {List? pin}) async { + await _createPinWelcomePageRobot.tapSetAPinButton(); + + await setupPinCode(pin ?? TestConfig.pin); + + await _welcomePageRobot.navigateToRestoreWalletPage(); + + // Desktop skips the restore options page and goes straight to the type list. + if (!Platform.isLinux) { + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + } + + await _selectWalletType(type); + + await _restoreFromSeedOrKeysPageRobot.isDisplayed(); + } + + Future restoreAdditionalWalletFromWalletList( + WalletType type, { + String? seed, + String passphrase = "", + }) async { + tester.printToConsole("Restoring ${type.name} wallet"); + + await _walletListPageRobot.navigateToRestoreWalletOptionsPage(); + + if (!Platform.isLinux) { + await _restoreOptionsPageRobot.navigateToRestoreFromSeedsOrKeysPage(); + } + + await _selectWalletType(type); + + await _restoreFromSeed(type, seed ?? TestWallets.seedFor(type), passphrase); + } + + Future _restoreFromSeed(WalletType type, String seed, String passphrase) async { + await _restoreFromSeedOrKeysPageRobot.selectWalletNameFromAvailableOptions(); + await _restoreFromSeedOrKeysPageRobot.enterSeedPhraseForWalletRestore(seed); + + if (passphrase.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot.enablePassphrase(); + } + + // 25 word monero seeds are legacy seeds and need their restore block height entered. + if (seed.split(" ").length == 25 && type == WalletType.monero) { + await _restoreFromSeedOrKeysPageRobot + .chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType.legacy); + await _restoreFromSeedOrKeysPageRobot + .enterBlockHeightForWalletRestore(TestWallets.moneroRestoreBlockHeight); + } + + if (type == WalletType.zcash && TestWallets.zcashRestoreHeight.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot + .enterBlockHeightForWalletRestore(TestWallets.zcashRestoreHeight); + } + + if (Platform.isLinux) { + final password = TestConfig.pin.join(""); + await _restoreFromSeedOrKeysPageRobot.enterPasswordForWalletRestore(password); + await _restoreFromSeedOrKeysPageRobot.enterPasswordRepeatForWalletRestore(password); + } + + await _restoreFromSeedOrKeysPageRobot.onRestoreWalletButtonPressed(); + + if (passphrase.isNotEmpty) { + await _restoreFromSeedOrKeysPageRobot.enterPassphrase(passphrase); + } + } + + Future setupPinCode(List pin) async { + // Desktop builds take a wallet password on the new wallet form instead of a pin. + if (Platform.isLinux) { + return; + } + + await _setupPinCodeRobot.isDisplayed(); + + await _setupPinCodeRobot.enterPinCode(pin); + await _setupPinCodeRobot.enterPinCode(pin); + await _setupPinCodeRobot.tapSuccessButton(); + } + + Future _selectWalletType(WalletType type) async { + await _newWalletTypePageRobot.isDisplayed(); + + await _newWalletTypePageRobot.findParticularWalletTypeInScrollableList(type); + await _newWalletTypePageRobot.selectWalletType(type); + } + + Future _completeWalletCreationSteps(WalletType type) async { + await _generateNewWalletDetails(); + + await _preSeedPageRobot.isDisplayed(); + await _preSeedPageRobot.onConfirmButtonPressed(); + + await _confirmWalletSeedDetails(); + + await _verifyWalletSeed(); + + if (type == WalletType.bitcoin) { + await _lightningUsernamePageRobot.isDisplayed(); + await _lightningUsernamePageRobot.tapSkip(); + } + } + + Future _generateNewWalletDetails() async { + await _newWalletPageRobot.isDisplayed(); + + await _newWalletPageRobot.generateWalletName(); + + if (Platform.isLinux) { + final password = TestConfig.pin.join(""); + await _restoreFromSeedOrKeysPageRobot.enterPasswordForWalletRestore(password); + await _restoreFromSeedOrKeysPageRobot.enterPasswordRepeatForWalletRestore(password); + } + + await _newWalletPageRobot.onNextButtonPressed(); + } + + Future _confirmWalletSeedDetails() async { + await _walletSeedPageRobot.isDisplayed(); + + _walletSeedPageRobot.confirmWalletDetailsDisplayCorrectly(); + _walletSeedPageRobot.confirmWalletSeedReminderDisplays(); + + await _walletSeedPageRobot.onVerifySeedButtonPressed(); + } + + Future _verifyWalletSeed() async { + await _seedVerificationPageRobot.isDisplayed(); + + _seedVerificationPageRobot.hasTitle(); + + await _seedVerificationPageRobot.verifyWalletSeeds(); + } +} diff --git a/integration_test/flows/wallet_flows.dart b/integration_test/flows/wallet_flows.dart new file mode 100644 index 000000000..953c802c4 --- /dev/null +++ b/integration_test/flows/wallet_flows.dart @@ -0,0 +1,59 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../robots/new_dashboard_robot.dart"; +import "auth_flows.dart"; + +class WalletFlows { + WalletFlows(this.tester) + : _authFlows = AuthFlows(tester), + _dashboardRobot = NewDashboardRobot(tester); + + final WidgetTester tester; + final AuthFlows _authFlows; + final NewDashboardRobot _dashboardRobot; + + Future _expandWalletGroups({int maxGroups = 10}) async { + for (int index = 0; index < maxGroups; index++) { + final tile = find.byKey(ValueKey("group_wallets_expansion_tile_widget_$index")); + + if (!tester.any(tile)) { + return; + } + + await tester.tap(tile, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 300)); + } + } + + Future switchToWallet(String name) async { + await _dashboardRobot.openWalletsTab(); + + final walletFinder = find.text(name); + + // Wallets sharing a seed are listed under their group's name, so the wallet itself is + // not on screen until that group is opened. + if (!tester.any(walletFinder)) { + await _expandWalletGroups(); + } + + await _dashboardRobot.pumpUntilFound(walletFinder); + + await tester.tap(walletFinder.first, warnIfMissed: false); + await tester.pump(const Duration(milliseconds: 500)); + + await _authFlows.authenticateWithPin(required: false); + + // The row does nothing at all when that wallet is already the open one, and the load + // itself runs behind a progress overlay, so the store is the only thing that tells us + // the switch happened. + final switched = await _dashboardRobot.pumpUntil( + () => getIt.get().wallet?.name == name, + timeout: const Duration(minutes: 2), + ); + + expect(switched, true, reason: "Wallet never became $name after tapping its row"); + } +} diff --git a/integration_test/funds_related_tests.dart b/integration_test/funds_related_tests.dart deleted file mode 100644 index 4638af80b..000000000 --- a/integration_test/funds_related_tests.dart +++ /dev/null @@ -1,96 +0,0 @@ -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import 'components/common_test_constants.dart'; -import 'components/common_test_flows.dart'; -import 'robots/auth_page_robot.dart'; -import 'robots/dashboard_page_robot.dart'; -import 'robots/exchange_confirm_page_robot.dart'; -import 'robots/exchange_page_robot.dart'; -import 'robots/exchange_trade_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - DashboardPageRobot dashboardPageRobot; - ExchangePageRobot exchangePageRobot; - ExchangeConfirmPageRobot exchangeConfirmPageRobot; - AuthPageRobot authPageRobot; - ExchangeTradePageRobot exchangeTradePageRobot; - CommonTestFlows commonTestFlows; - - group('Startup Test', () { - testWidgets('Test for Exchange flow using Restore Wallet - Exchanging USDT(Sol) to SOL', - (tester) async { - authPageRobot = AuthPageRobot(tester); - exchangePageRobot = ExchangePageRobot(tester); - dashboardPageRobot = DashboardPageRobot(tester); - exchangeTradePageRobot = ExchangeTradePageRobot(tester); - exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); - commonTestFlows = CommonTestFlows(tester); - - await commonTestFlows.startAppFlow(ValueKey('funds_exchange_test_app_key')); - - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - CommonTestConstants.testWalletType, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - // ----------- RestoreFromSeedOrKeys Page ------------- - await dashboardPageRobot.navigateToExchangePage(); - - // ----------- Exchange Page ------------- - await exchangePageRobot.isExchangePage(); - exchangePageRobot.hasResetButton(); - await exchangePageRobot.displayBothExchangeCards(); - exchangePageRobot.confirmRightComponentsDisplayOnDepositExchangeCards(); - exchangePageRobot.confirmRightComponentsDisplayOnReceiveExchangeCards(); - - await exchangePageRobot - .selectDepositCurrency(CommonTestConstants.exchangeTestDepositCurrency); - await exchangePageRobot - .selectReceiveCurrency(CommonTestConstants.exchangeTestReceiveCurrency); - - await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); - await exchangePageRobot.enterDepositRefundAddress( - depositAddress: CommonTestConstants.testWalletAddress); - - await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - - await exchangePageRobot.onExchangeButtonPressed(); - - await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); - - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - - // ----------- Exchange Confirm Page ------------- - await exchangeConfirmPageRobot.isExchangeConfirmPage(); - - exchangeConfirmPageRobot.confirmComponentsOfTradeDisplayProperly(); - await exchangeConfirmPageRobot.confirmCopyTradeIdToClipBoardWorksProperly(); - await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); - - // ----------- Exchange Trade Page ------------- - await exchangeTradePageRobot.isExchangeTradePage(); - exchangeTradePageRobot.hasInformationDialog(); - await exchangeTradePageRobot.onGotItButtonPressed(); - - await exchangeTradePageRobot.onSendFromCakeButtonPressed(); - - await exchangeTradePageRobot.handleConfirmSendResult(); - - await exchangeTradePageRobot.onSendButtonOnConfirmSendingDialogPressed(); - }); - }); -} diff --git a/integration_test/funds_suites/send_dry_run_test.dart b/integration_test/funds_suites/send_dry_run_test.dart new file mode 100644 index 000000000..4935fe9af --- /dev/null +++ b/integration_test/funds_suites/send_dry_run_test.dart @@ -0,0 +1,107 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Every funded chain can build a transaction without sending it", (tester) async { + final appLauncher = AppLauncher(tester); + final authFlows = AuthFlows(tester); + final fundsFlows = FundsFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + if (!TestConfig.shouldDryRun("send")) { + tester.printToConsole("FLOWS excludes send, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "send_dry_run_test_app_key"); + + final appStore = getIt.get(); + final failures = []; + + for (final type in walletTypes) { + tester.printToConsole("Send dry run starting for ${type.name}"); + + try { + final opened = await fundsFlows.openFundedWallet(type); + + if (!opened) { + tester.printToConsole("${type.name} has nothing spendable, top it up"); + FundsOutcome.empty(tester, type.name, "every funded wallet is empty"); + continue; + } + + expect(appStore.wallet?.type, type); + + final ownAddress = appStore.wallet!.walletAddresses.address; + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.enterAddress(ownAddress); + await sendRobot.enterAmount(TestConfig.fundsSendAmountFor(type)); + await sendRobot.tapSendButtonWhenReady(); + + await authFlows.authenticateWithPin(required: false); + + await sendRobot.confirmTransactionBuilt(); + + await sendRobot.confirmSendButtonReplacesSwiper(); + + tester.printToConsole("${type.name} built a transaction, leaving it unsent"); + FundsOutcome.ok(tester, type.name, "built and signed, not broadcast"); + + expect( + await fundsFlows.returnToHome(), + true, + reason: "${type.name} finished but the send flow would not close", + ); + } catch (e) { + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: $e"); + } else { + tester.printToConsole("${type.name} needs attention rather than a fix: $e"); + } + + FundsOutcome.failed(tester, type.name, e.toString()); + + if (!await fundsFlows.returnToHome()) { + // The app is hanging or found itself somewhere we don't know, + // no need to continue running anything else + for (final skipped in walletTypes.skip(walletTypes.indexOf(type) + 1)) { + FundsOutcome.waiting( + tester, + skipped.name, + "never ran, ${type.name} left the app somewhere it could not come back from", + ); + } + + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: could not get back to the home page, " + "the chains after this one never ran"); + } + + break; + } + } + } + + expect(failures, isEmpty, reason: "Failed chains:\n${failures.join("\n")}"); + }); +} diff --git a/integration_test/funds_suites/send_funds_test.dart b/integration_test/funds_suites/send_funds_test.dart new file mode 100644 index 000000000..47ff44db5 --- /dev/null +++ b/integration_test/funds_suites/send_funds_test.dart @@ -0,0 +1,109 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // tests everything up to the point of swiping + integrationTest("Send a real self transaction on every funded chain", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final authFlows = AuthFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + if (!TestConfig.shouldSpend("send")) { + tester.printToConsole("FLOWS excludes send, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "send_funds_test_app_key"); + + final appStore = getIt.get(); + final failures = []; + + for (final type in walletTypes) { + tester.printToConsole("Funds send starting for ${type.name}"); + + try { + final opened = await fundsFlows.openFundedWallet(type); + + if (!opened) { + tester.printToConsole("${type.name} has nothing spendable, top it up"); + FundsOutcome.empty(tester, type.name, "every funded wallet is empty"); + continue; + } + + expect(appStore.wallet?.type, type); + + // Making it a self send so the funds remain inside the funded wallet, only fees are spent. + final ownAddress = appStore.wallet!.walletAddresses.address; + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.enterAddress(ownAddress); + await sendRobot.enterAmount(TestConfig.fundsSendAmountFor(type)); + await sendRobot.tapSendButtonWhenReady(); + + await authFlows.authenticateWithPin(required: false); + + await sendRobot.swipeToConfirm(); + await sendRobot.confirmTransactionCommitted(); + + // The send page sits under the confirm sheet, so one pop leaves it up and the next + // chain would try to restore from behind it. + expect( + await fundsFlows.returnToHome(), + true, + reason: "${type.name} broadcast but the send flow would not close", + ); + + tester.printToConsole("FUNDS_SEND_OK: ${type.name}"); + FundsOutcome.ok(tester, type.name, "self send broadcast"); + } catch (e) { + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: $e"); + } else { + tester.printToConsole("${type.name} needs attention rather than a fix: $e"); + } + + FundsOutcome.failed(tester, type.name, e.toString()); + tester.printToConsole("FUNDS_SEND_FAIL: ${type.name}: $e"); + + if (!await fundsFlows.returnToHome()) { + for (final skipped in walletTypes.skip(walletTypes.indexOf(type) + 1)) { + FundsOutcome.waiting( + tester, + skipped.name, + "never ran, ${type.name} left the app somewhere it could not come back from", + ); + } + + if (FundsOutcome.needsFurtherReview(e.toString())) { + failures.add("${type.name}: could not get back to the home page, " + "the chains after this one never ran"); + } + + break; + } + } + } + + expect(failures, isEmpty, reason: "Failed chains:\n${failures.join("\n")}"); + }); +} diff --git a/integration_test/funds_suites/swap_dry_run_test.dart b/integration_test/funds_suites/swap_dry_run_test.dart new file mode 100644 index 000000000..2e31ad57f --- /dev/null +++ b/integration_test/funds_suites/swap_dry_run_test.dart @@ -0,0 +1,69 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + // tests everything up to the point of creating the trade + integrationTest("Swap prices a real deposit without creating a trade", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final homePageRobot = HomePageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + if (!TestConfig.shouldDryRun("swap")) { + tester.printToConsole("FLOWS excludes swap, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + await appLauncher.launchApp(testKey: "swap_dry_run_test_app_key"); + + final appStore = getIt.get(); + + final type = TestConfig.swapDepositType(walletTypes); + + expect( + walletTypes, + contains(type), + reason: "SWAP_FROM=${type.name} is not among the funded wallets under test", + ); + + final opened = await fundsFlows.openFundedWallet(type); + + expect(opened, true, reason: "Every funded wallet for ${type.name} came up empty"); + expect(appStore.wallet?.type, type); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + if (TestConfig.swapReceive.isNotEmpty) { + await swapRobot.chooseReceiveCurrency(CryptoCurrency.fromString(TestConfig.swapReceive)); + } + + await swapRobot.enterMinimumViableDepositAmount(); + await swapRobot.enterTestReceiveAddress(); + + await swapRobot.confirmQuoteReceived(); + await swapRobot.confirmSwapButtonEnabled(); + + tester.printToConsole("${type.name} got a quote and the swap button enabled, no trade created"); + FundsOutcome.ok(tester, type.name, "quoted, swap button enabled, no trade created"); + + await swapRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/funds_suites/swap_funds_test.dart b/integration_test/funds_suites/swap_funds_test.dart new file mode 100644 index 000000000..56e02f5c1 --- /dev/null +++ b/integration_test/funds_suites/swap_funds_test.dart @@ -0,0 +1,86 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../core/app_launcher.dart"; +import "../core/funds_outcome.dart"; +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; +import "../flows/auth_flows.dart"; +import "../flows/funds_flows.dart"; +import "../robots/home_page_robot.dart"; +import "../robots/new_send_page_robot.dart"; +import "../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Create a real swap and broadcast its deposit", (tester) async { + final appLauncher = AppLauncher(tester); + final fundsFlows = FundsFlows(tester); + final authFlows = AuthFlows(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + if (!TestConfig.shouldSpend("swap")) { + tester.printToConsole("FLOWS excludes swap, skipping"); + return; + } + + final walletTypes = TestConfig.fundedWalletTypesUnderTest; + + expect(walletTypes.isNotEmpty, true, reason: "No funded wallet seeds configured"); + + final type = TestConfig.swapDepositType(walletTypes); + + expect( + walletTypes, + contains(type), + reason: "SWAP_FROM=${type.name} is not among the funded wallets under test", + ); + + await appLauncher.launchApp(testKey: "swap_funds_test_app_key"); + + final opened = await fundsFlows.openFundedWallet(type); + + expect( + opened, + true, + reason: "${type.name}: none of the ${TestWallets.fundedWalletsFor(type).length} funded " + "wallets has a spendable balance, top them up before the next run", + ); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, type); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + if (TestConfig.swapReceive.isNotEmpty) { + await swapRobot.chooseReceiveCurrency(CryptoCurrency.fromString(TestConfig.swapReceive)); + } + + await swapRobot.enterMinimumViableDepositAmount(); + final payoutAddress = await swapRobot.enterTestReceiveAddress(); + await swapRobot.confirmQuoteReceived(); + await swapRobot.confirmSwapButtonEnabled(); + + await swapRobot.tapSwapButton(); + + await authFlows.authenticateWithPin(required: false); + + await swapRobot.confirmTradeCreated(); + swapRobot.confirmPayoutAddress(payoutAddress); + + await sendRobot.swipeToConfirm(); + await swapRobot.confirmDepositCommitted(); + + await homePageRobot.isDisplayed(); + + tester.printToConsole("FUNDS_SWAP_OK: ${type.name}"); + FundsOutcome.ok(tester, type.name, "trade created and deposit broadcast"); + }); +} diff --git a/integration_test/helpers/mocks.dart b/integration_test/helpers/mocks.dart deleted file mode 100644 index b36ff314f..000000000 --- a/integration_test/helpers/mocks.dart +++ /dev/null @@ -1,27 +0,0 @@ -import 'package:cake_wallet/core/auth_service.dart'; -import 'package:cake_wallet/core/secure_storage.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/store/authentication_store.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/store/wallet_list_store.dart'; -import 'package:cake_wallet/view_model/link_view_model.dart'; -import 'package:hive/hive.dart'; -import 'package:mocktail/mocktail.dart'; - -class MockAppStore extends Mock implements AppStore {} - -class MockAuthService extends Mock implements AuthService {} - -class MockSettingsStore extends Mock implements SettingsStore {} - -class MockAuthenticationStore extends Mock implements AuthenticationStore {} - -class MockWalletListStore extends Mock implements WalletListStore {} - -class MockLinkViewModel extends Mock implements LinkViewModel {} - -class MockHiveInterface extends Mock implements HiveInterface {} - -class MockHiveBox extends Mock implements Box {} - -class MockSecureStorage extends Mock implements SecureStorage {} diff --git a/integration_test/helpers/test_helpers.dart b/integration_test/helpers/test_helpers.dart deleted file mode 100644 index caa4ebd27..000000000 --- a/integration_test/helpers/test_helpers.dart +++ /dev/null @@ -1,99 +0,0 @@ -import 'package:cake_wallet/core/auth_service.dart'; -import 'package:cake_wallet/core/secure_storage.dart'; -import 'package:cake_wallet/di.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cake_wallet/store/authentication_store.dart'; -import 'package:cake_wallet/store/settings_store.dart'; -import 'package:cake_wallet/store/wallet_list_store.dart'; -import 'package:cake_wallet/view_model/link_view_model.dart'; -import 'package:hive/hive.dart'; -import 'package:mocktail/mocktail.dart'; - -import 'mocks.dart'; - -class TestHelpers { - static void setup() { - // Fallback values can also be declared here - registerDependencies(); - } - - static void registerDependencies() { - getAndRegisterAppStore(); - getAndRegisterAuthService(); - getAndRegisterSettingsStore(); - getAndRegisterAuthenticationStore(); - getAndRegisterWalletListStore(); - - getAndRegisterLinkViewModel(); - getAndRegisterSecureStorage(); - getAndRegisterHiveInterface(); - } - - static MockSettingsStore getAndRegisterSettingsStore() { - _removeRegistrationIfExists(); - final service = MockSettingsStore(); - getIt.registerSingleton(service); - return service; - } - - static MockAppStore getAndRegisterAppStore() { - _removeRegistrationIfExists(); - final service = MockAppStore(); - final settingsStore = getAndRegisterSettingsStore(); - - when(() => service.settingsStore).thenAnswer((invocation) => settingsStore); - getIt.registerSingleton(service); - return service; - } - - static MockAuthService getAndRegisterAuthService() { - _removeRegistrationIfExists(); - final service = MockAuthService(); - getIt.registerSingleton(service); - return service; - } - - static MockAuthenticationStore getAndRegisterAuthenticationStore() { - _removeRegistrationIfExists(); - final service = MockAuthenticationStore(); - when(() => service.state).thenReturn(AuthenticationState.uninitialized); - getIt.registerSingleton(service); - return service; - } - - static MockWalletListStore getAndRegisterWalletListStore() { - _removeRegistrationIfExists(); - final service = MockWalletListStore(); - getIt.registerSingleton(service); - return service; - } - - static MockLinkViewModel getAndRegisterLinkViewModel() { - _removeRegistrationIfExists(); - final service = MockLinkViewModel(); - getIt.registerSingleton(service); - return service; - } - - static MockHiveInterface getAndRegisterHiveInterface() { - _removeRegistrationIfExists(); - final service = MockHiveInterface(); - getIt.registerSingleton(service); - return service; - } - - static MockSecureStorage getAndRegisterSecureStorage() { - _removeRegistrationIfExists(); - final service = MockSecureStorage(); - getIt.registerSingleton(service); - return service; - } - - static void _removeRegistrationIfExists() { - if (getIt.isRegistered()) { - getIt.unregister(); - } - } - - static void tearDown() => getIt.reset(); -} diff --git a/integration_test/integration_response_data.json b/integration_test/integration_response_data.json deleted file mode 100644 index ec747fa47..000000000 --- a/integration_test/integration_response_data.json +++ /dev/null @@ -1 +0,0 @@ -null \ No newline at end of file diff --git a/integration_test/robots/auth_page_robot.dart b/integration_test/robots/auth_page_robot.dart index eac028e4b..8160e6da2 100644 --- a/integration_test/robots/auth_page_robot.dart +++ b/integration_test/robots/auth_page_robot.dart @@ -1,36 +1,20 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/auth/auth_page.dart'; -import 'package:flutter/widgets.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/auth/auth_page.dart"; +import "package:flutter/widgets.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; -import 'pin_code_widget_robot.dart'; +import "pin_code_widget_robot.dart"; class AuthPageRobot extends PinCodeWidgetRobot { - AuthPageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - super(tester); + AuthPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async { + await isSpecificPage(); + } bool onAuthPage() { - final hasPinButtons = find.byKey(ValueKey('pin_code_button_3_key')); + final hasPinButtons = find.byKey(const ValueKey("pin_code_button_3_key")); final hasPin = hasPinButtons.tryEvaluate(); return hasPin; } - - bool onAuthPageDesktop() { - final hasWalletPasswordInput = find.byKey(ValueKey('enter_wallet_password')); - return hasWalletPasswordInput.tryEvaluate(); - } - - Future isAuthPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('auth_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.setup_pin); - } } diff --git a/integration_test/robots/connection_sync_page_robot.dart b/integration_test/robots/connection_sync_page_robot.dart new file mode 100644 index 000000000..a5a9e00d5 --- /dev/null +++ b/integration_test/robots/connection_sync_page_robot.dart @@ -0,0 +1,40 @@ +import "package:cake_wallet/entities/fiat_api_mode.dart"; +import "package:cake_wallet/src/screens/settings/connection_sync_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ConnectionSyncPageRobot extends BaseRobot { + ConnectionSyncPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future setFiatApiMode(FiatApiMode mode) async { + await tapByKey("fiat_api"); + + final unselected = find.byKey(ValueKey("picker_items_index_${mode.title}_button_key")); + final selected = + find.byKey(ValueKey("picker_items_index_${mode.title}_selected_item_button_key")); + + await pumpUntil(() => tester.any(unselected) || tester.any(selected)); + + await tester.tap(tester.any(unselected) ? unselected : selected); + + await settle(); + } + + void hasFiatApiMode(FiatApiMode mode) { + expect( + find.descendant( + of: find.byKey(const ValueKey("fiat_api")), + matching: find.text(mode.title), + ), + findsOneWidget, + reason: "The fiat api row does not show ${mode.title}", + ); + } +} diff --git a/integration_test/robots/contact_robot.dart b/integration_test/robots/contact_robot.dart new file mode 100644 index 000000000..57354f8cd --- /dev/null +++ b/integration_test/robots/contact_robot.dart @@ -0,0 +1,66 @@ +import "package:cake_wallet/src/screens/contact/contact_list_page.dart"; +import "package:cake_wallet/src/screens/contact/contact_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ContactRobot extends BaseRobot { + ContactRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(ContactListPage)); + } + + Future openAddContact() async { + await tapByKey("contact_list_page_add_contact_button_key"); + + await pumpUntilFound(find.byType(ContactPage)); + } + + Future enterName(String name) async { + await enterTextByKey("contact_page_name_textfield_key", name); + } + + Future chooseCurrency(String currencyName) async { + await tapByKey("contact_page_currency_picker_button_key"); + + await tapByKey("picker_items_index_${currencyName}_button_key"); + + await settle(); + } + + Future enterAddress(String address) async { + final input = find.byKey(const ValueKey("contact_page_address_input_key")); + + await pumpUntilFound(input); + + final field = find.descendant(of: input, matching: find.byType(EditableText)); + + await tester.enterText(field.first, address); + await tester.pump(const Duration(milliseconds: 300)); + } + + Future save() async { + await tapByKey("contact_page_save_button_key"); + + await pumpUntilGone(find.byType(ContactPage)); + + await settle(); + } + + void expectContactSaved(String name, String address) { + final page = tester.widget(find.byType(ContactListPage)); + + final saved = page.contactListViewModel.contacts.where((c) => c.name == name).toList(); + + expect(saved, isNotEmpty, reason: "The contact $name was never saved"); + + expect( + saved.first.address, + address, + reason: "The address book stored a different address than it was given", + ); + } +} diff --git a/integration_test/robots/create_pin_welcome_page_robot.dart b/integration_test/robots/create_pin_welcome_page_robot.dart index 74b268e94..af0537696 100644 --- a/integration_test/robots/create_pin_welcome_page_robot.dart +++ b/integration_test/robots/create_pin_welcome_page_robot.dart @@ -1,46 +1,16 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/welcome/create_pin_welcome_page.dart'; -import 'package:cake_wallet/wallet_type_utils.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/welcome/create_pin_welcome_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class CreatePinWelcomePageRobot { - CreatePinWelcomePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class CreatePinWelcomePageRobot extends BaseRobot { + CreatePinWelcomePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isCreatePinWelcomePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('create_pin_welcome_page'); - } - - void hasTitle() { - String title; - if (isMoneroOnly) { - title = S.current.monero_com; - } - - title = S.current.cake_wallet; - - commonTestCases.hasText(title); - } - - void hasDescription() { - String description; - if (isMoneroOnly) { - description = S.current.monero_com_wallet_text; - } - - description = S.current.new_first_wallet_text; - - commonTestCases.hasText(description); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future tapSetAPinButton() async { - await commonTestCases.tapItemByKey('create_pin_welcome_page_create_a_pin_button_key'); - - await commonTestCases.defaultSleepTime(); + await tapByKey("create_pin_welcome_page_create_a_pin_button_key"); } } diff --git a/integration_test/robots/dashboard_menu_widget_robot.dart b/integration_test/robots/dashboard_menu_widget_robot.dart deleted file mode 100644 index 34c76aa7d..000000000 --- a/integration_test/robots/dashboard_menu_widget_robot.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:cake_wallet/src/screens/dashboard/widgets/menu_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class DashboardMenuWidgetRobot { - DashboardMenuWidgetRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future hasMenuWidget() async { - await commonTestCases.takeScreenshots('menu_widget_page'); - commonTestCases.hasType(); - } - - void displaysTheCorrectWalletNameAndSubName() { - final menuWidgetState = tester.state(find.byType(MenuWidget)); - - final walletName = menuWidgetState.widget.dashboardViewModel.name; - commonTestCases.hasText(walletName); - - final walletSubName = menuWidgetState.widget.dashboardViewModel.subname; - if (walletSubName.isNotEmpty) { - commonTestCases.hasText(walletSubName); - } - } - - Future navigateToWalletMenu() async { - await commonTestCases.tapItemByKey('dashboard_page_menu_widget_wallet_menu_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToSecurityAndBackupPage() async { - await commonTestCases.tapItemByKey( - 'dashboard_page_menu_widget_security_and_backup_button_key', - ); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/dashboard_page_robot.dart b/integration_test/robots/dashboard_page_robot.dart deleted file mode 100644 index 915faa25c..000000000 --- a/integration_test/robots/dashboard_page_robot.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart'; -import 'package:cake_wallet/src/screens/dashboard/pages/balance/crypto_balance_widget.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import 'dashboard_menu_widget_robot.dart'; - -class DashboardPageRobot { - DashboardPageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - dashboardMenuWidgetRobot = DashboardMenuWidgetRobot(tester); - - final WidgetTester tester; - final DashboardMenuWidgetRobot dashboardMenuWidgetRobot; - late CommonTestCases commonTestCases; - - Future isDashboardPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('dashboard_page'); - } - - Future confirmWalletTypeIsDisplayedCorrectly( - WalletType type, { - bool isHaven = false, - }) async { - final cryptoBalanceWidget = - tester.widget(find.byType(CryptoBalanceWidget)); - final hasAccounts = cryptoBalanceWidget.dashboardViewModel.balanceViewModel.hasAccounts; - - if (hasAccounts) { - final walletName = cryptoBalanceWidget.dashboardViewModel.name; - commonTestCases.hasText(walletName); - } else { - final walletName = walletTypeToString(type); - final assetName = isHaven ? '$walletName Assets' : walletName; - commonTestCases.hasText(assetName); - } - await commonTestCases.defaultSleepTime(seconds: 5); - } - - void confirmServiceUpdateButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_services_update_button_key'); - } - - void confirmSyncIndicatorButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_sync_indicator_button_key'); - } - - void confirmMenuButtonDisplays() { - commonTestCases.hasValueKey('dashboard_page_wallet_menu_button_key'); - } - - Future confirmRightCryptoAssetTitleDisplaysPerPageView( - WalletType type, { - bool isHaven = false, - }) async { - //Balance Page - await confirmWalletTypeIsDisplayedCorrectly(type, isHaven: isHaven); - - // Swipe to Cake features Page - await swipeDashboardTab(false); - commonTestCases.hasText('Cake ${S.current.features}'); - - // Swipe back to balance - await swipeDashboardTab(true); - - // Swipe to Transactions Page - await swipeDashboardTab(true); - commonTestCases.hasText(S.current.transactions); - - // Swipe back to balance - await swipeDashboardTab(false); - await commonTestCases.defaultSleepTime(seconds: 3); - } - - Future swipeDashboardTab(bool swipeRight) async { - await commonTestCases.swipeByPageKey( - key: 'dashboard_page_view_key', - swipeRight: swipeRight, - ); - await commonTestCases.defaultSleepTime(); - } - - Future openDrawerMenu() async { - await commonTestCases.tapItemByKey('dashboard_page_wallet_menu_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToBuyPage() async { - await commonTestCases.tapItemByKey('dashboard_page_buy_action_button_key'); - } - - Future navigateToWalletsListPage({bool isDrawer = false}) async { - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.tapItemByKey( - isDrawer - ? 'dashboard_page_menu_widget_wallet_menu_button_key' - : 'dashboard_page__wallet_list_button_key', - ); - - await tester.pumpAndSettle(Duration(milliseconds: 2000)); - } - - Future navigateToSendPage() async { - await commonTestCases.tapItemByKey('dashboard_page_send_action_button_key'); - } - - Future navigateToSellPage() async { - await commonTestCases.tapItemByKey('dashboard_page_sell_action_button_key'); - } - - Future navigateToReceivePage() async { - await commonTestCases.tapItemByKey('dashboard_page_receive_action_button_key'); - } - - Future navigateToExchangePage() async { - await commonTestCases.tapItemByKey('dashboard_page_swap_action_button_key'); - } -} diff --git a/integration_test/robots/disclaimer_page_robot.dart b/integration_test/robots/disclaimer_page_robot.dart deleted file mode 100644 index 282169a9c..000000000 --- a/integration_test/robots/disclaimer_page_robot.dart +++ /dev/null @@ -1,40 +0,0 @@ -import 'package:cake_wallet/src/screens/disclaimer/disclaimer_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class DisclaimerPageRobot { - DisclaimerPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isDisclaimerPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('disclaimer_page'); - } - - void hasCheckIcon(bool hasBeenTapped) { - // The checked Icon should not be available initially, until user taps the checkbox - final checkIcon = find.byKey(ValueKey('disclaimer_check_icon_key')); - expect(checkIcon, hasBeenTapped ? findsOneWidget : findsNothing); - } - - void hasDisclaimerCheckbox() { - final checkBox = find.byKey(ValueKey('disclaimer_check_key')); - expect(checkBox, findsOneWidget); - } - - Future tapDisclaimerCheckbox() async { - await commonTestCases.tapItemByKey('disclaimer_check_key'); - - await commonTestCases.defaultSleepTime(); - } - - Future tapAcceptButton() async { - await commonTestCases.tapItemByKey('disclaimer_accept_button_key'); - - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/display_settings_page_robot.dart b/integration_test/robots/display_settings_page_robot.dart new file mode 100644 index 000000000..d9615be74 --- /dev/null +++ b/integration_test/robots/display_settings_page_robot.dart @@ -0,0 +1,78 @@ +import "package:cake_wallet/entities/fiat_currency.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/fiat_currency_picker_sheet.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/fiat_currency_row.dart"; +import "package:cake_wallet/src/screens/settings/display_settings_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class DisplaySettingsPageRobot extends BaseRobot { + DisplaySettingsPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openLanguagePicker() async { + await tapByKey("display_settings_language"); + } + + Future chooseLanguage(String code) async { + await tapByKey("picker_items_index_${code}_button_key"); + + await settle(); + } + + bool hasFiatCurrencyRow() => + tester.any(find.byKey(const ValueKey("display_settings_fiat_currency"))); + + Future openFiatCurrencyPicker() async { + await tapByKey("display_settings_fiat_currency"); + + await pumpUntilFound(find.byType(FiatCurrencyPickerSheet)); + } + + Future chooseFiatCurrency(FiatCurrency currency) async { + final searchField = find.descendant( + of: find.byType(CurrencyPickerSearchField), + matching: find.byType(TextFormField), + ); + + await tester.enterText(searchField, currency.fullName); + + final row = find.byWidgetPredicate( + (widget) => widget is FiatCurrencyRow && widget.currency == currency, + ); + + await pumpUntilFound(row); + + await tester.tap(row.first); + + await pumpUntilGone(find.byType(FiatCurrencyPickerSheet)); + } + + void expectFiatCurrencyShown(FiatCurrency currency) { + expect( + find.descendant( + of: find.byKey(const ValueKey("display_settings_fiat_currency")), + matching: find.text(currency.title), + ), + findsOneWidget, + reason: "The currency row still does not show ${currency.title}", + ); + } + + void expectLanguageShown(String displayName) { + expect( + find.descendant( + of: find.byKey(const ValueKey("display_settings_language")), + matching: find.text(displayName), + ), + findsOneWidget, + reason: "The language row still does not show $displayName", + ); + } +} diff --git a/integration_test/robots/exchange_confirm_page_robot.dart b/integration_test/robots/exchange_confirm_page_robot.dart deleted file mode 100644 index e71979393..000000000 --- a/integration_test/robots/exchange_confirm_page_robot.dart +++ /dev/null @@ -1,46 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/exchange_trade/exchange_confirm_page.dart'; -import 'package:flutter/services.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeConfirmPageRobot { - ExchangeConfirmPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeConfirmPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_confirm_page'); - } - - void confirmComponentsOfTradeDisplayProperly() { - final ExchangeConfirmPage exchangeConfirmPage = tester.widget(find.byType(ExchangeConfirmPage)); - final trade = exchangeConfirmPage.trade; - - commonTestCases.hasText(trade.id); - commonTestCases.hasText('${trade.provider.title} ${S.current.trade_id}'); - - commonTestCases.hasValueKey('exchange_confirm_page_saved_id_button_key'); - commonTestCases.hasValueKey('exchange_confirm_page_copy_to_clipboard_button_key'); - } - - Future confirmCopyTradeIdToClipBoardWorksProperly() async { - final ExchangeConfirmPage exchangeConfirmPage = tester.widget(find.byType(ExchangeConfirmPage)); - final trade = exchangeConfirmPage.trade; - - await commonTestCases.tapItemByKey('exchange_confirm_page_copy_to_clipboard_button_key'); - - ClipboardData? clipboardData = await Clipboard.getData('text/plain'); - - expect(clipboardData?.text, trade.id); - } - - Future onSavedTradeIdButtonPressed() async { - await tester.pumpAndSettle(); - await commonTestCases.defaultSleepTime(); - await commonTestCases.tapItemByKey('exchange_confirm_page_saved_id_button_key'); - } -} diff --git a/integration_test/robots/exchange_page_robot.dart b/integration_test/robots/exchange_page_robot.dart deleted file mode 100644 index b0c9523cc..000000000 --- a/integration_test/robots/exchange_page_robot.dart +++ /dev/null @@ -1,338 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/exchange/exchange_page.dart'; -import 'package:cake_wallet/src/screens/exchange/widgets/present_provider_picker.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import '../components/common_test_constants.dart'; -import 'auth_page_robot.dart'; - -class ExchangePageRobot { - ExchangePageRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - authPageRobot = AuthPageRobot(tester); - - WidgetTester tester; - AuthPageRobot authPageRobot; - CommonTestCases commonTestCases; - - Future isExchangePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_page'); - await commonTestCases.defaultSleepTime(); - } - - void hasResetButton() { - commonTestCases.hasText(S.current.reset); - } - - void displaysPresentProviderPicker() { - commonTestCases.hasType(); - } - - Future displayBothExchangeCards() async { - final ExchangePage exchangeCard = tester.widget( - find.byType(ExchangePage), - ); - - final depositKey = exchangeCard.depositKey; - final receiveKey = exchangeCard.receiveKey; - - final depositExchangeCard = find.byKey(depositKey); - expect(depositExchangeCard, findsOneWidget); - - final receiveExchangeCard = find.byKey(receiveKey); - expect(receiveExchangeCard, findsOneWidget); - } - - void confirmRightComponentsDisplayOnDepositExchangeCards() { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - final depositCardPrefix = 'deposit_exchange_card'; - - commonTestCases.hasValueKey('${depositCardPrefix}_title_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_currency_picker_button_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_selected_currency_text_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_amount_textfield_key'); - - exchangePage.depositKey.currentState!.changeLimits(min: '0.1'); - - commonTestCases.hasValueKey('${depositCardPrefix}_min_limit_text_key'); - - final initialCurrency = exchangeViewModel.depositCurrency; - if (initialCurrency.tag != null) { - commonTestCases.hasValueKey('${depositCardPrefix}_selected_currency_tag_text_key'); - } - - if (exchangeViewModel.hasAllAmount) { - commonTestCases.hasValueKey('${depositCardPrefix}_send_all_button_key'); - } - - if (exchangeViewModel.isMoneroWallet) { - commonTestCases.hasValueKey('${depositCardPrefix}_address_book_button_key'); - } - - if (exchangeViewModel.isDepositAddressEnabled) { - commonTestCases.hasValueKey('${depositCardPrefix}_editable_address_textfield_key'); - } else { - commonTestCases.hasValueKey('${depositCardPrefix}_non_editable_address_textfield_key'); - commonTestCases.hasValueKey('${depositCardPrefix}_copy_refund_address_button_key'); - } - - // commonTestCases.hasValueKey('${depositCardPrefix}_max_limit_text_key'); - } - - void confirmRightComponentsDisplayOnReceiveExchangeCards() { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - final receiveCardPrefix = 'receive_exchange_card'; - - commonTestCases.hasValueKey('${receiveCardPrefix}_title_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_currency_picker_button_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_selected_currency_text_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_amount_textfield_key'); - commonTestCases.hasValueKey('${receiveCardPrefix}_min_limit_text_key'); - - final initialCurrency = exchangeViewModel.receiveCurrency; - if (initialCurrency.tag != null) { - commonTestCases.hasValueKey('${receiveCardPrefix}_selected_currency_tag_text_key'); - } - - if (exchangeViewModel.hasAllAmount) { - commonTestCases.hasValueKey('${receiveCardPrefix}_send_all_button_key'); - } - - if (exchangeViewModel.isMoneroWallet) { - commonTestCases.hasValueKey('${receiveCardPrefix}_address_book_button_key'); - } - - commonTestCases.hasValueKey('${receiveCardPrefix}_editable_address_textfield_key'); - } - - Future selectDepositCurrency(CryptoCurrency depositCurrency) async { - final depositPrefix = 'deposit_exchange_card'; - final currencyPickerKey = '${depositPrefix}_currency_picker_button_key'; - final currencyPickerDialogKey = '${depositPrefix}_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - commonTestCases.hasValueKey(currencyPickerDialogKey); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (depositCurrency == exchangeViewModel.depositCurrency) { - await commonTestCases.defaultSleepTime(); - await commonTestCases - .tapItemByKey('picker_items_index_${depositCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(depositCurrency.name, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${depositCurrency.name}_button_key'); - } - - Future selectReceiveCurrency(CryptoCurrency receiveCurrency) async { - final receivePrefix = 'receive_exchange_card'; - final currencyPickerKey = '${receivePrefix}_currency_picker_button_key'; - final currencyPickerDialogKey = '${receivePrefix}_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - commonTestCases.hasValueKey(currencyPickerDialogKey); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (receiveCurrency == exchangeViewModel.receiveCurrency) { - await commonTestCases - .tapItemByKey('picker_items_index_${receiveCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(receiveCurrency.name, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${receiveCurrency.name}_button_key'); - } - - Future enterDepositAmount(String amount) async { - await commonTestCases.enterText(amount, 'deposit_exchange_card_amount_textfield_key'); - } - - Future enterDepositRefundAddress({String? depositAddress}) async { - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - if (exchangeViewModel.isDepositAddressEnabled && depositAddress != null) { - await commonTestCases.enterText( - depositAddress, 'deposit_exchange_card_editable_address_textfield_key'); - } - } - - Future enterReceiveAddress(String receiveAddress) async { - await commonTestCases.enterText( - receiveAddress, - 'receive_exchange_card_editable_address_textfield_key', - ); - await commonTestCases.defaultSleepTime(); - } - - Future onExchangeButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_page_exchange_button_key'); - await commonTestCases.defaultSleepTime(); - } - - bool hasMaxLimitError() { - final maxErrorText = find.text(S.current.error_text_input_above_maximum_limit); - - bool hasMaxError = maxErrorText.tryEvaluate(); - - return hasMaxError; - } - - bool hasMinLimitError() { - final minErrorText = find.text(S.current.error_text_input_below_minimum_limit); - - bool hasMinError = minErrorText.tryEvaluate(); - - return hasMinError; - } - - bool hasTradeCreationFailureError() { - final tradeCreationFailureDialogButton = - find.byKey(ValueKey('exchange_page_trade_creation_failure_dialog_button_key')); - - bool hasTradeCreationFailure = tradeCreationFailureDialogButton.tryEvaluate(); - tester.printToConsole('Trade not created error: $hasTradeCreationFailure'); - return hasTradeCreationFailure; - } - - Future onTradeCreationFailureDialogButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_page_trade_creation_failure_dialog_button_key'); - } - - /// Handling Trade Failure Errors or errors shown through the Failure Dialog. - /// - /// Simulating the user's flow and response when this error comes up. - /// Examples are: - /// - No provider can handle this trade error, - /// - Trade amount below limit error. - Future _handleTradeCreationFailureErrors() async { - tester.printToConsole('Inside trade creation failure handle'); - bool isTradeCreationFailure = false; - - isTradeCreationFailure = hasTradeCreationFailureError(); - - int maxRetries = 3; - int retries = 0; - - while (isTradeCreationFailure && retries < maxRetries) { - await tester.pumpAndSettle(); - - await onTradeCreationFailureDialogButtonPressed(); - - await commonTestCases.defaultSleepTime(seconds: 5); - - await onExchangeButtonPressed(); - - await _handleAuthPage(); - - isTradeCreationFailure = hasTradeCreationFailureError(); - retries++; - } - } - - /// Handles the min limit error. - /// - /// Simulates the user's flow and response when it comes up. - /// - /// Has a max retry of 20 times. - Future _handleMinLimitError(String initialAmount) async { - double amount; - - amount = double.parse(initialAmount); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - amount = (exchangeViewModel.limits.min ?? 0.0) + 1.0; - - await enterDepositAmount(amount.toString()); - - await tester.pumpAndSettle(); - - await onExchangeButtonPressed(); - - await tester.pumpAndSettle(); - } - - /// Handles the max limit error. - /// - /// Simulates the user's flow and response when it comes up. - /// - /// Has a max retry of 20 times. - Future _handleMaxLimitError(String initialAmount) async { - double amount; - - amount = double.parse(initialAmount); - - ExchangePage exchangePage = tester.widget(find.byType(ExchangePage)); - final exchangeViewModel = exchangePage.exchangeViewModel; - - amount = (exchangeViewModel.limits.max ?? 0.0) - 1.0; - - await enterDepositAmount(amount.toString()); - - await tester.pumpAndSettle(); - - await onExchangeButtonPressed(); - - await tester.pumpAndSettle(); - } - - Future handleErrors(String initialAmount) async { - await tester.pumpAndSettle(); - - bool isMinLimitError = hasMinLimitError(); - - if (isMinLimitError) { - tester.printToConsole('Has min limit error'); - await _handleMinLimitError(initialAmount); - } - - bool isMaxLimitError = hasMaxLimitError(); - - if (isMaxLimitError) { - await _handleMaxLimitError(initialAmount); - } - - tester.printToConsole('No limits error, proceeding with flow'); - - await _handleAuthPage(); - - await tester.pumpAndSettle(); - - await _handleTradeCreationFailureErrors(); - - await tester.pumpAndSettle(); - - await commonTestCases.defaultSleepTime(); - } - - Future _handleAuthPage() async { - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - } -} diff --git a/integration_test/robots/exchange_trade_external_send_page_robot.dart b/integration_test/robots/exchange_trade_external_send_page_robot.dart deleted file mode 100644 index 8ddfe4915..000000000 --- a/integration_test/robots/exchange_trade_external_send_page_robot.dart +++ /dev/null @@ -1,35 +0,0 @@ -import 'package:flutter_test/flutter_test.dart'; - -import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_external_send_page.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeTradeExternalSendPageRobot { - ExchangeTradeExternalSendPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeTradeExternalSendPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_trade_external_send_page'); - } - - Future verifySendDetailsItemsDisplayProperly() async { - final widget = - tester.widget(find.byType(ExchangeTradeExternalSendPage)); - final exchangeTradeViewModel = widget.exchangeTradeViewModel; - final items = exchangeTradeViewModel.items.where((item) => item.isExternalSendDetail).toList(); - - for (var item in items) { - commonTestCases.hasValueKey('exchange_trade_external_send_page_send_item_${item.title}_key'); - tester.printToConsole('${item.title} present on screen'); - } - - commonTestCases.defaultSleepTime(); - } - - Future onContinueButtonPressed() async { - await commonTestCases.tapItemByKey('exchange_trade_external_send_page_continue_button_key'); - } -} diff --git a/integration_test/robots/exchange_trade_page_robot.dart b/integration_test/robots/exchange_trade_page_robot.dart deleted file mode 100644 index d8c5b2278..000000000 --- a/integration_test/robots/exchange_trade_page_robot.dart +++ /dev/null @@ -1,161 +0,0 @@ -import 'dart:async'; - -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/exchange_trade/exchange_trade_page.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class ExchangeTradePageRobot { - ExchangeTradePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isExchangeTradePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('exchange_trade_page'); - } - - void hasInformationDialog() { - commonTestCases.hasValueKey('information_page_dialog_key'); - } - - Future onGotItButtonPressed() async { - await commonTestCases.tapItemByKey('information_page_got_it_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onSendFromExternalButtonPressed() async { - tester.printToConsole('Routing to send from external details page'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_send_from_external_button_key', - ); - } - - Future onSendFromCakeButtonPressed() async { - tester.printToConsole('Now sending from cake'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_send_from_cake_button_key', - shouldPumpAndSettle: false, - ); - - final Completer completer = Completer(); - - // Loop to wait for the async operation to complete - while (true) { - await Future.delayed(Duration(seconds: 1)); - - final ExchangeTradeState state = tester.state(find.byType(ExchangeTradeForm)); - final execState = state.widget.exchangeTradeViewModel.sendViewModel.state; - - bool isDone = execState is ExecutedSuccessfullyState; - bool isFailed = execState is FailureState; - - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone || isFailed) { - tester.printToConsole( - isDone ? 'Completer is done' : 'Completer is done though operation failed'); - completer.complete(); - await tester.pump(); - break; - } else { - tester.printToConsole('Completer is not done'); - await tester.pump(); - } - } - - await expectLater(completer.future, completes); - - tester.printToConsole('Done confirming sending'); - - await commonTestCases.defaultSleepTime(seconds: 4); - } - - Future onSendButtonOnConfirmSendingDialogPressed() async { - tester.printToConsole('Send Button on Confirm Dialog Triggered'); - await commonTestCases.defaultSleepTime(seconds: 4); - - final sendText = find.text(S.current.send); - bool hasText = sendText.tryEvaluate(); - - if (hasText) { - await commonTestCases.tapItemByFinder(sendText); - - await commonTestCases.defaultSleepTime(seconds: 4); - } - } - - Future onCancelButtonOnConfirmSendingDialogPressed() async { - tester.printToConsole('Cancel Button on Confirm Dialog Triggered'); - - await commonTestCases.tapItemByKey( - 'exchange_trade_page_confirm_sending_dialog_cancel_button_key', - ); - - await commonTestCases.defaultSleepTime(); - } - - Future onSendFailureDialogButtonPressed() async { - await commonTestCases.defaultSleepTime(seconds: 6); - - tester.printToConsole('Send Button Failure Dialog Triggered'); - - await commonTestCases.tapItemByKey('exchange_trade_page_send_failure_dialog_button_key'); - } - - Future hasErrorWhileSending() async { - await tester.pump(); - - tester.printToConsole('Checking if there is an error'); - - final errorDialog = find.byKey( - ValueKey('exchange_trade_page_send_failure_dialog_button_key'), - ); - - bool hasError = errorDialog.tryEvaluate(); - - tester.printToConsole('Has error: $hasError'); - - return hasError; - } - - Future handleConfirmSendResult() async { - bool hasError = false; - - hasError = await hasErrorWhileSending(); - - int maxRetries = 20; - int retries = 0; - - while (hasError && retries < maxRetries) { - tester.printToConsole('hasErrorInLoop: $hasError'); - await tester.pump(); - - await onSendFailureDialogButtonPressed(); - tester.printToConsole('Failure button tapped'); - - await commonTestCases.defaultSleepTime(); - - await onSendFromCakeButtonPressed(); - tester.printToConsole('Confirm sending button tapped'); - - hasError = await hasErrorWhileSending(); - - retries++; - } - - if (!hasError) { - tester.printToConsole('No error, proceeding with flow'); - await tester.pump(); - } - - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/home_page_robot.dart b/integration_test/robots/home_page_robot.dart new file mode 100644 index 000000000..00d2155d3 --- /dev/null +++ b/integration_test/robots/home_page_robot.dart @@ -0,0 +1,227 @@ +import "package:cake_wallet/new-ui/pages/home_page.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/assets_history/history_tile.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/assets_history/transaction_details_modal.dart"; +import "package:cake_wallet/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart"; +import "package:cw_core/sync_status.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class HomePageRobot extends BaseRobot { + HomePageRobot(super.tester); + + @override + Future isDisplayed() async { + final shown = await pumpUntil(() => isInFront); + + expect(shown, true, reason: "The home tab never came to the front"); + } + + bool get isInFront { + final stackFinder = find.byType(IndexedStack); + + if (!tester.any(stackFinder)) { + return false; + } + + final stack = tester.widget(stackFinder.first); + final index = stack.children.indexWhere((child) => child is NewHomePage); + + return index >= 0 && stack.index == index; + } + + Future hasWalletName(String name) async { + final nameFinder = find.byKey(const ValueKey("home_page_wallet_name_text_key")); + final syncBarFinder = find.byType(SyncBar); + + final shown = await pumpUntil(() => tester.any(nameFinder) || tester.any(syncBarFinder)); + + expect(shown, true, reason: "Top bar showed neither the wallet name nor the sync bar"); + + if (!tester.any(nameFinder)) { + tester.printToConsole("Sync bar is in place of the wallet name, not checking it on screen"); + return; + } + + expect(textByKey("home_page_wallet_name_text_key"), name); + } + + Future openSendSheet() async { + await tapByKey("home_page_send_button_key"); + } + + Future openReceiveSheet() async { + await tapByKey("home_page_receive_button_key"); + } + + Future openSwapSheet() async { + await tapByKey("home_page_swap_button_key"); + } + + Future openSettingsSheet() async { + await tapByKey("home_page_settings_button_key"); + } + + Future openHistoryTab() async { + final tabFinder = find.byKey(const ValueKey("line_tab_switcher_1_key")); + + if (!tester.any(tabFinder)) { + return; + } + + await tapWhenVisible(tabFinder); + + await settle(); + } + + Future confirmTransactionHistoryVisible({ + Duration timeout = const Duration(minutes: 3), + }) async { + final hasTransactions = await pumpUntil( + () => _dashboardTransactionCount() > 0, + timeout: timeout, + ); + + expect( + hasTransactions, + true, + reason: "No transactions arrived within ${timeout.inSeconds}s", + ); + + await openHistoryTab(); + + await pumpUntilFound(find.byType(HistoryTile)); + + expect( + tester.widgetList(find.byType(HistoryTile)).length, + lessThanOrEqualTo(3), + reason: "The home page preview should never render more than the short history", + ); + } + + Future openAllTransactions() async { + final actionButton = find.byKey(const ValueKey("assets_history_action_button_key")); + final historyBar = find.byKey(const ValueKey("history_top_bar_key")); + + await tapWhenVisible(tester.any(actionButton) ? actionButton : historyBar); + + await pumpUntilFound(find.byKey(const ValueKey("history_modal_key"))); + } + + Future confirmAllTransactionsVisible() async { + await pumpUntilFound(_allViewTiles); + + final rendered = tester.widgetList(_allViewTiles).length; + + if (_dashboardTransactionCount() > 3) { + expect( + rendered, + greaterThan(3), + reason: "The All view rendered $rendered tiles, no more than the home preview does", + ); + + return; + } + + expect(_allViewTiles, findsWidgets); + } + + String firstTransactionIdInAllView() { + final tile = tester.widgetList(_allViewTiles).first; + final key = tile.key! as ValueKey; + + return key.value.substring("home_page_transaction_".length, key.value.length - "_key".length); + } + + Future openFirstTransactionDetails() async { + await pumpUntilFound(_allViewTiles); + + await tester.tap(_allViewTiles.first); + + await pumpUntilFound(find.byType(TransactionDetailsModal)); + + await settle(); + } + + String openedTransactionId() { + final modal = tester.widget(find.byType(TransactionDetailsModal)); + + return modal.transactionDetailsViewModel.transactionInfo.id; + } + + void hasTransactionIdRow() { + expect( + find.byKey(const ValueKey("standard_list_item_transaction_details_id_key")), + findsOneWidget, + reason: "The details opened without showing the transaction id", + ); + } + + // The home preview stays mounted underneath the modal, so its tiles have to be left out. + Finder get _allViewTiles => find.descendant( + of: find.byKey(const ValueKey("history_modal_key")), + matching: find.byType(HistoryTile), + ); + + Future confirmSyncIndicatorShown({Duration timeout = const Duration(minutes: 3)}) async { + final syncBar = find.byType(SyncBar); + final tick = find.descendant(of: syncBar, matching: find.byIcon(Icons.check)); + + SyncStatus? lastStatus; + var sawIndicator = false; + + final synced = await pumpUntil( + () { + lastStatus = _dashboardStatus(); + + if (lastStatus == null) { + return false; + } + + final keyed = find.descendant( + of: syncBar, + matching: find.byKey(ValueKey(lastStatus.runtimeType)), + ); + + sawIndicator = sawIndicator || tester.any(keyed) || tester.any(tick); + + return lastStatus is SyncedSyncStatus; + }, + timeout: timeout, + ); + + expect( + synced, + true, + reason: "The wallet never synced within ${timeout.inMinutes}m, " + "it reported ${lastStatus.runtimeType}", + ); + + expect( + sawIndicator, + true, + reason: "The sync bar never showed a status while the wallet synced", + ); + } + + SyncStatus? _dashboardStatus() { + final finder = find.byType(NewHomePage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).dashboardViewModel.status; + } + + int _dashboardTransactionCount() { + final finder = find.byType(NewHomePage); + + if (!tester.any(finder)) { + return 0; + } + + return tester.widget(finder.first).dashboardViewModel.transactions.length; + } +} diff --git a/integration_test/robots/lightning_username_page_robot.dart b/integration_test/robots/lightning_username_page_robot.dart new file mode 100644 index 000000000..69ffc3d0e --- /dev/null +++ b/integration_test/robots/lightning_username_page_robot.dart @@ -0,0 +1,17 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class LightningUsernamePageRobot extends BaseRobot { + LightningUsernamePageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byKey(const ValueKey("lightning_username_page_skip_button_key"))); + } + + Future tapSkip() async { + await tapByKey("lightning_username_page_skip_button_key"); + } +} diff --git a/integration_test/robots/manage_nodes_page_robot.dart b/integration_test/robots/manage_nodes_page_robot.dart new file mode 100644 index 000000000..cbc328229 --- /dev/null +++ b/integration_test/robots/manage_nodes_page_robot.dart @@ -0,0 +1,39 @@ +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/nodes/widgets/node_list_row.dart"; +import "package:cake_wallet/src/screens/settings/manage_nodes_page.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class ManageNodesPageRobot extends BaseRobot { + ManageNodesPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + String currentNodeUri() { + final page = tester.widget(find.byType(ManageNodesPage)); + + return page.nodeListViewModel.currentNode.uriRaw; + } + + Future switchToAnotherNode() async { + final rows = find.byType(NodeListRow); + + await pumpUntilFound(rows.first); + + expect( + tester.widgetList(rows).length, + greaterThan(1), + reason: "Only the current node is listed, there is nothing to switch to", + ); + + await tapWhenVisible(rows.at(1)); + + await tapWhenVisible(find.text(S.current.change)); + + await settle(); + } +} diff --git a/integration_test/robots/new_dashboard_robot.dart b/integration_test/robots/new_dashboard_robot.dart new file mode 100644 index 000000000..1aa332d17 --- /dev/null +++ b/integration_test/robots/new_dashboard_robot.dart @@ -0,0 +1,25 @@ +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewDashboardRobot extends BaseRobot { + NewDashboardRobot(super.tester); + + @override + Future isDisplayed({Duration timeout = const Duration(seconds: 30)}) async { + await pumpUntilFound(find.byKey(const ValueKey("new_dashboard_page_key")), timeout: timeout); + } + + Future openHomeTab() async { + await tapByKey("dashboard_page_home_action_button_key"); + } + + Future openWalletsTab() async { + await tapByKey("dashboard_page_wallets_action_button_key"); + } + + Future openContactsTab() async { + await tapByKey("dashboard_page_contacts_action_button_key"); + } +} diff --git a/integration_test/robots/new_receive_page_robot.dart b/integration_test/robots/new_receive_page_robot.dart new file mode 100644 index 000000000..542acceb0 --- /dev/null +++ b/integration_test/robots/new_receive_page_robot.dart @@ -0,0 +1,57 @@ +import "package:cake_wallet/new-ui/pages/receive_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewReceivePageRobot extends BaseRobot { + NewReceivePageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewReceivePage)); + } + + Future confirmAddressMatches(String expected) async { + final wanted = _normalise(expected); + + await pumpUntilFound(find.byKey(const ValueKey("receive_page_address_key"))); + + final matched = await pumpUntil(() => _renderedAddress() == wanted); + + expect( + matched, + true, + reason: "Receive page should show $wanted but showed ${_renderedAddress()}", + ); + } + + String _normalise(String address) => + address.replaceAll("bitcoincash:", "").replaceAll(RegExp(r"\s"), ""); + + String? _renderedAddress() { + final root = find.byKey(const ValueKey("receive_page_address_key")); + + if (!tester.any(root)) { + return null; + } + + final rich = find.descendant(of: root, matching: find.byType(RichText)); + + if (tester.any(rich)) { + final text = _normalise(tester.widget(rich.first).text.toPlainText()); + + if (text.isNotEmpty) { + return text; + } + } + + final plain = find.descendant(of: root, matching: find.byType(Text)); + + if (tester.any(plain)) { + return _normalise(tester.widget(plain.first).data ?? ""); + } + + return null; + } +} diff --git a/integration_test/robots/new_send_page_robot.dart b/integration_test/robots/new_send_page_robot.dart new file mode 100644 index 000000000..3fef1a057 --- /dev/null +++ b/integration_test/robots/new_send_page_robot.dart @@ -0,0 +1,205 @@ +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/new-ui/pages/send_page.dart"; +import "package:cake_wallet/new-ui/widgets/confirm_swiper.dart"; +import "package:cake_wallet/new-ui/widgets/new_primary_button.dart"; +import "package:cake_wallet/new-ui/widgets/send_page/send_confirm_bottom_widget.dart"; +import "package:cake_wallet/new-ui/widgets/send_page/send_confirm_sheet.dart"; +import "package:cake_wallet/view_model/send/send_view_model.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; +import "../core/test_config.dart"; + +class NewSendPageRobot extends BaseRobot { + NewSendPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewSendPage)); + } + + Future enterAddress(String address) async { + await _enterTextInInput("send_page_address_input_key", address); + } + + Future enterAmount(String amount) async { + await _enterTextInInput("send_page_amount_input_key", amount); + } + + String enteredAddress() => _textInInput("send_page_address_input_key"); + + Future pickContactFromAddressBook(String contactName) async { + await tapByKey("send_page_address_book_button_key"); + + final tabs = find.byType(Tab); + + await pumpUntilFound(tabs); + + await tester.tap(tabs.at(1)); + + await settle(); + + final contactFinder = find.byKey(ValueKey(contactName)); + + await pumpUntilFound(contactFinder); + + await tester.tap(contactFinder.first); + + await pumpUntilGone(contactFinder); + } + + Future tapSendButton() async { + await tapByKey("send_page_send_button_key"); + } + + Future tapSendButtonWhenReady({Duration timeout = TestConfig.walletSyncBudget}) async { + final ready = await pumpUntil(() => _model()?.isReadyForSend ?? false, timeout: timeout); + + expect( + ready, + true, + reason: "The wallet never became ready to send within ${timeout.inMinutes}m, " + "so the send button stayed disabled", + ); + + await tapSendButton(); + } + + bool get isSendFlowOpen => + tester.any(find.byType(NewSendPage)) || tester.any(find.byType(SendConfirmSheet)); + + Future swipeToConfirm({Duration timeout = const Duration(seconds: 90)}) async { + final finder = find.byKey(const ValueKey("send_page_confirm_swiper_key")); + + await pumpUntilFound(finder, timeout: timeout); + + await settle(max: const Duration(seconds: 2)); + + final width = tester.view.physicalSize.width / tester.view.devicePixelRatio; + await tester.drag(finder.first, Offset(width, 0)); + + await tester.pump(const Duration(milliseconds: 500)); + } + + Future confirmSendButtonReplacesSwiper() async { + final swiper = find.byKey(const ValueKey("send_page_confirm_swiper_key")); + + await pumpUntilFound(swiper); + + tester.binding.platformDispatcher.accessibilityFeaturesTestValue = + const FakeAccessibilityFeatures(accessibleNavigation: true); + addTearDown(tester.binding.platformDispatcher.clearAccessibilityFeaturesTestValue); + + await settle(max: const Duration(seconds: 2)); + + final button = find.descendant(of: swiper, matching: find.byType(NewPrimaryButton)); + + expect( + tester.any(button), + true, + reason: "Accessible navigation is on but the sheet still only offers the swiper", + ); + + expect( + tester.widget(button.first).onPressed, + isNotNull, + reason: "The send button a screen reader gets is disabled", + ); + } + + Future confirmTransactionBuilt({Duration timeout = const Duration(minutes: 3)}) async { + await pumpUntil( + () => tester.any(find.byType(ConfirmSwiper)) || _model()?.state is FailureState, + timeout: timeout, + ); + + if (tester.any(find.byType(ConfirmSwiper))) { + return; + } + + fail("No confirm sheet after ${timeout.inSeconds}s. ${_describeSendState()}"); + } + + Future confirmTransactionCommitted({ + Duration timeout = const Duration(minutes: 3), + }) async { + await pumpUntilFound(find.byType(TransactionCommittedScreenActionButton), timeout: timeout); + } + + Future expectNoTransactionBuilt({ + Duration window = const Duration(seconds: 15), + }) async { + final offered = await pumpUntil( + () => tester.any(find.byType(ConfirmSwiper)), + timeout: window, + ); + + expect( + offered, + false, + reason: + "The send screen showed the swiper, wanting to broadcast a transaction it should have refused", + ); + } + + SendViewModel? _model() { + final sheet = find.byType(SendConfirmSheet); + + if (tester.any(sheet)) { + return tester.widget(sheet.first).sendViewModel; + } + + final page = find.byType(NewSendPage); + + if (tester.any(page)) { + return tester.widget(page.first).sendViewModel; + } + + return null; + } + + String _describeSendState() { + final model = _model(); + + final available = model?.wallet.balance.values + .map((balance) => balance.available) + .where((amount) => amount.sign > 0) + .join(", "); + + final errorFinder = find.byType(TransactionErrorActions); + final error = tester.any(errorFinder) + ? tester.widget(errorFinder.first).errorText + : model?.state is FailureState + ? (model!.state as FailureState).error + : null; + + return "state=${model?.state.runtimeType}, " + "wallet holds ${available == null || available.isEmpty ? "nothing" : available}" + "${error == null ? "" : ", the chain said: $error"}"; + } + + String _textInInput(String inputKey) { + final inputFinder = find.byKey(ValueKey(inputKey)); + + if (!tester.any(inputFinder)) { + return "unknown"; + } + + final field = find.descendant(of: inputFinder, matching: find.byType(EditableText)); + + return tester.widget(field.first).controller.text; + } + + Future _enterTextInInput(String inputKey, String text) async { + final inputFinder = find.byKey(ValueKey(inputKey)); + + await pumpUntilFound(inputFinder); + + final fieldFinder = find.descendant(of: inputFinder, matching: find.byType(EditableText)); + + await tester.enterText(fieldFinder.first, text); + + await tester.pump(const Duration(milliseconds: 300)); + } +} diff --git a/integration_test/robots/new_settings_page_robot.dart b/integration_test/robots/new_settings_page_robot.dart new file mode 100644 index 000000000..8b604db77 --- /dev/null +++ b/integration_test/robots/new_settings_page_robot.dart @@ -0,0 +1,45 @@ +import "package:cake_wallet/new-ui/pages/settings_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class NewSettingsPageRobot extends BaseRobot { + NewSettingsPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(SettingsMainPage)); + } + + Future openRow(String route) async { + await scrollUntilRowVisible(route); + await tapByKey(route); + } + + Future scrollUntilRowVisible(String route) async { + final rowFinder = find.byKey(ValueKey(route)); + + if (tester.any(rowFinder)) { + return; + } + + final scrollableFinder = find.descendant( + of: find.byType(SettingsMainPage), + matching: find.byType(Scrollable), + ); + + await tester.scrollUntilVisible( + rowFinder, + 200, + scrollable: scrollableFinder.first, + maxScrolls: 30, + ); + + await tester.pump(const Duration(milliseconds: 300)); + } + + Future confirmLeafPageDisplayed(Type pageType) async { + await pumpUntilFound(find.byType(pageType)); + } +} diff --git a/integration_test/robots/new_swap_page_robot.dart b/integration_test/robots/new_swap_page_robot.dart new file mode 100644 index 000000000..bc58812d2 --- /dev/null +++ b/integration_test/robots/new_swap_page_robot.dart @@ -0,0 +1,211 @@ +import "package:cake_wallet/core/execution_state.dart"; +import "package:cake_wallet/exchange/limits_state.dart"; +import "package:cake_wallet/new-ui/pages/swap_page.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_row.dart"; +import "package:cake_wallet/new-ui/widgets/currency_picker/currency_picker_search_field.dart"; +import "package:cake_wallet/new-ui/widgets/swap_page/swap_amount_box.dart"; +import "package:cake_wallet/new-ui/widgets/swap_page/swap_confirm_sheet.dart"; +import "package:cake_wallet/src/widgets/primary_button.dart"; +import "package:cake_wallet/view_model/send/send_view_model_state.dart"; +import "package:cw_core/crypto_currency.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; +import "../core/test_config.dart"; +import "../core/test_wallets.dart"; + +class NewSwapPageRobot extends BaseRobot { + NewSwapPageRobot(super.tester); + + @override + Future isDisplayed() async { + await pumpUntilFound(find.byType(NewSwapPage)); + } + + Future enterDepositAmount(String amount) async { + await enterTextByKey("swap_page_deposit_amount_field_key", amount); + } + + Future chooseReceiveCurrency(CryptoCurrency currency) async { + await tapByKey("swap_amount_box_receive_currency_button_key"); + + final search = find.descendant( + of: find.byType(CurrencyPickerSearchField), + matching: find.byType(EditableText), + ); + + await pumpUntilFound(search); + + await tester.enterText(search.first, currency.title); + await settle(max: const Duration(seconds: 2)); + + await tapWhenVisible( + find.byWidgetPredicate( + (widget) => widget is CurrencyPickerRow && _isSameCurrency(widget.currency, currency), + ), + ); + + final chosen = await pumpUntil( + () => _isSameCurrency( + tester.widget(find.byType(NewSwapPage)).exchangeViewModel.receiveCurrency, + currency, + ), + ); + + expect( + chosen, + true, + reason: "The picker did not switch the receive currency to ${currency.title}", + ); + } + + static bool _isSameCurrency(CryptoCurrency one, CryptoCurrency other) => + one.title.toLowerCase() == other.title.toLowerCase() && + (one.tag ?? "").toLowerCase() == (other.tag ?? "").toLowerCase(); + + Future confirmQuoteReceived({Duration timeout = const Duration(seconds: 90)}) async { + final received = await pumpUntil(() => _bestRate() > 0, timeout: timeout); + + expect(received, true, reason: "No provider quote arrived within ${timeout.inSeconds}s"); + } + + Future enterMinimumViableDepositAmount() async { + final limitsLoaded = await pumpUntil( + () => _limitsState() is LimitsLoadedSuccessfully, + timeout: const Duration(seconds: 90), + ); + + expect(limitsLoaded, true, reason: "Provider limits never loaded"); + + final min = _limitsMin() ?? 0; + + // 5 percent over the minimum, so a small rate move does not invalidate the trade. + final amount = min > 0 ? (min * 1.05).toStringAsFixed(8) : "0.01"; + await enterDepositAmount(amount); + } + + Future enterTestReceiveAddress() async { + final exchangeViewModel = + tester.widget(find.byType(NewSwapPage)).exchangeViewModel; + final receiveType = cryptoCurrencyOrTokenToWalletType(exchangeViewModel.receiveCurrency); + final address = receiveType == null ? "" : TestWallets.receiveAddressFor(receiveType); + + expect( + address.isNotEmpty, + true, + reason: "No test receive address for ${exchangeViewModel.receiveCurrency.title}", + ); + + final receiveCard = find.byWidgetPredicate( + (widget) => widget is SwapAmountBox && widget.isReceiverCard, + ); + + tester.state(receiveCard).addressController.text = address; + await settle(); + + return address; + } + + Future confirmSwapButtonEnabled({Duration timeout = TestConfig.walletSyncBudget}) async { + final button = find.byKey(const ValueKey("exchange_page_exchange_button_key")); + + final enabled = await pumpUntil( + () => tester.any(button) && !tester.widget(button).isDisabled, + timeout: timeout, + ); + + expect( + enabled, + true, + reason: "The swap button stayed disabled with an amount, a quote and a receive address", + ); + } + + Future tapSwapButton() async { + await tapByKey("exchange_page_exchange_button_key"); + } + + Future confirmTradeCreated({Duration timeout = const Duration(minutes: 2)}) async { + await pumpUntilFound(find.byType(SwapConfirmSheet), timeout: timeout); + } + + void confirmPayoutAddress(String expected) { + final sheet = tester.widget(find.byType(SwapConfirmSheet)); + final payout = sheet.exchangeTradeViewModel.trade.payoutAddress ?? ""; + final isHex = expected.startsWith("0x"); + + expect( + isHex ? payout.toLowerCase() : payout, + isHex ? expected.toLowerCase() : expected, + reason: "The trade pays out somewhere other than the test wallet", + ); + } + + Future confirmDepositCommitted({Duration timeout = const Duration(minutes: 3)}) async { + ExecutionState? lastState; + + final committed = await pumpUntil( + () { + final sheet = find.byType(SwapConfirmSheet); + + if (!tester.any(sheet)) { + return true; + } + + lastState = + tester.widget(sheet).exchangeTradeViewModel.sendViewModel.state; + + return lastState is TransactionCommitted; + }, + timeout: timeout, + ); + + expect( + committed, + true, + reason: "The deposit never committed within ${timeout.inMinutes}m, " + "the sheet reported ${lastState.runtimeType}", + ); + + expect( + lastState, + isA(), + reason: "The confirm sheet closed before the deposit committed, " + "it last reported ${lastState.runtimeType}", + ); + + await pumpUntilGone(find.byType(NewSwapPage)); + } + + double? _limitsMin() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).exchangeViewModel.limits.min; + } + + LimitsState? _limitsState() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return null; + } + + return tester.widget(finder.first).exchangeViewModel.limitsState; + } + + double _bestRate() { + final finder = find.byType(NewSwapPage); + + if (!tester.any(finder)) { + return 0; + } + + return tester.widget(finder.first).exchangeViewModel.bestRate; + } +} diff --git a/integration_test/robots/new_wallet_page_robot.dart b/integration_test/robots/new_wallet_page_robot.dart index dda468398..f39bec017 100644 --- a/integration_test/robots/new_wallet_page_robot.dart +++ b/integration_test/robots/new_wallet_page_robot.dart @@ -1,36 +1,32 @@ -import 'package:cake_wallet/src/screens/new_wallet/new_wallet_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/new_wallet/new_wallet_page.dart"; +import "package:cake_wallet/src/widgets/alert_with_one_action.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class NewWalletPageRobot { - NewWalletPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class NewWalletPageRobot extends BaseRobot { + NewWalletPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isNewWalletPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('new_wallet_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future enterWalletName(String walletName) async { - await commonTestCases.enterText( - walletName, - 'new_wallet_page_wallet_name_textformfield_key', - ); - await commonTestCases.defaultSleepTime(); + await enterTextByKey("new_wallet_page_wallet_name_textformfield_key", walletName); } Future generateWalletName() async { - await commonTestCases.tapItemByKey( - 'new_wallet_page_wallet_name_textformfield_generate_name_button_key', - ); - await commonTestCases.defaultSleepTime(); + await tapByKey("new_wallet_page_wallet_name_textformfield_generate_name_button_key"); + } + + Future expectNameRejected() async { + await pumpUntilFound(find.byType(AlertWithOneAction)); + + hasType(); } Future onNextButtonPressed() async { - await commonTestCases.tapItemByKey('new_wallet_page_confirm_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("new_wallet_page_confirm_button_key"); } } diff --git a/integration_test/robots/new_wallet_type_page_robot.dart b/integration_test/robots/new_wallet_type_page_robot.dart index 11785b67d..0fbb7f4f4 100644 --- a/integration_test/robots/new_wallet_type_page_robot.dart +++ b/integration_test/robots/new_wallet_type_page_robot.dart @@ -1,55 +1,29 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/new_wallet/new_wallet_type_page.dart'; -import 'package:cake_wallet/themes/core/material_base_theme.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/new_wallet/new_wallet_type_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class NewWalletTypePageRobot { - NewWalletTypePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class NewWalletTypePageRobot extends BaseRobot { + NewWalletTypePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isNewWalletTypePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('new_wallet_type_page'); - } - - void displaysCorrectTitle(bool isCreate) { - commonTestCases.hasText( - isCreate ? S.current.wallet_list_create_new_wallet : S.current.wallet_list_restore_wallet, - ); - } - - void hasWalletTypeForm() { - commonTestCases.hasType(); - } - - void displaysCorrectImage(ThemeType type) { - final walletTypeImage = Image.asset('assets/images/wallet_type.png').image; - final walletTypeLightImage = Image.asset('assets/images/wallet_type_light.png').image; - - find.image( - type == ThemeType.dark ? walletTypeImage : walletTypeLightImage, - ); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future findParticularWalletTypeInScrollableList(WalletType type) async { - await tester.pump(Duration(milliseconds: 1000)); - final scrollableWidget = find.descendant( - of: find.byKey(Key('new_wallet_type_scrollable_key')), + of: find.byKey(const Key("new_wallet_type_scrollable_key")), matching: find.byType(Scrollable), ); - final targetWidget = find.byKey(ValueKey('new_wallet_type_${type.name}_button_key')); + final targetWidget = find.byKey(ValueKey("new_wallet_type_${type.name}_button_key")); - tester.printToConsole('Attempting to scroll to wallet type ${type.name}'); + tester.printToConsole("Attempting to scroll to wallet type ${type.name}"); - await tester.pump(Duration(milliseconds: 500)); + await pumpUntilFound(scrollableWidget.first); await tester.scrollUntilVisible( targetWidget, @@ -58,12 +32,14 @@ class NewWalletTypePageRobot { maxScrolls: 20, ); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); + await settle(); - expect(tester.any(targetWidget), true, - reason: 'Wallet type ${type.name} should be visible after scrolling'); + expect( + tester.any(targetWidget), + true, + reason: "Wallet type ${type.name} should be visible after scrolling", + ); - // Additional check to ensure the widget is actually on screen final widgetRect = tester.getRect(targetWidget); final screenSize = tester.view.physicalSize; final screenRect = Rect.fromLTWH(0, 0, screenSize.width, screenSize.height); @@ -71,15 +47,13 @@ class NewWalletTypePageRobot { expect( screenRect.overlaps(widgetRect), true, - reason: 'Wallet type ${type.name} should be within screen bounds', + reason: "Wallet type ${type.name} should be within screen bounds", ); - tester.printToConsole('Wallet type ${type.name} is now visible and tappable'); + tester.printToConsole("Wallet type ${type.name} is now visible and tappable"); } - // Tapping a wallet type now navigates directly (the page no longer has a - // separate "Next" button โ€” selection happens on tap). Future selectWalletType(WalletType type) async { - await commonTestCases.tapItemByKey('new_wallet_type_${type.name}_button_key'); + await tapByKey("new_wallet_type_${type.name}_button_key"); } } diff --git a/integration_test/robots/pin_code_widget_robot.dart b/integration_test/robots/pin_code_widget_robot.dart index 35b9e200e..72a9d1d09 100644 --- a/integration_test/robots/pin_code_widget_robot.dart +++ b/integration_test/robots/pin_code_widget_robot.dart @@ -1,52 +1,24 @@ -import 'package:cake_wallet/src/screens/pin_code/pin_code_widget.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/pin_code/pin_code_widget.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class PinCodeWidgetRobot { - PinCodeWidgetRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class PinCodeWidgetRobot extends BaseRobot { + PinCodeWidgetRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async => hasPinCodeWidget(); void hasPinCodeWidget() { final pinCodeWidget = find.bySubtype(); expect(pinCodeWidget, findsOneWidget); } - void hasNumberButtonsVisible() { - // Confirmation for buttons 1-9 - for (var i = 1; i < 10; i++) { - commonTestCases.hasValueKey('pin_code_button_${i}_key'); + Future enterPinCode(List pinCode) async { + for (final pin in pinCode) { + await tapByKey("pin_code_button_${pin}_key"); } - // Confirmation for 0 button - commonTestCases.hasValueKey('pin_code_button_0_key'); - } - - Future enterPassword(String password, {int pumpDuration = 100}) async { - await commonTestCases.enterText( - password, - 'enter_wallet_password', - ); - await tester.pumpAndSettle(); - await commonTestCases.tapItemByKey( - 'unlock', - ); - await tester.pumpAndSettle(); - - await commonTestCases.defaultSleepTime(); - } - - Future enterPinCode(List pinCode, {int pumpDuration = 100}) async { - for (int pin in pinCode) { - await commonTestCases.tapItemByKey( - 'pin_code_button_${pin}_key', - pumpDuration: pumpDuration, - ); - } - - await commonTestCases.takeScreenshots('pin_code_widget'); - await commonTestCases.defaultSleepTime(); + await settle(); } } diff --git a/integration_test/robots/pre_seed_page_robot.dart b/integration_test/robots/pre_seed_page_robot.dart index 0e0eab4a1..7227d3bfc 100644 --- a/integration_test/robots/pre_seed_page_robot.dart +++ b/integration_test/robots/pre_seed_page_robot.dart @@ -1,24 +1,20 @@ import "package:cake_wallet/new-ui/pages/seed/pre_seed_page.dart"; -import 'package:flutter_test/flutter_test.dart'; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class PreSeedPageRobot { - PreSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class PreSeedPageRobot extends BaseRobot { + PreSeedPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isPreSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('pre_seed_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } + // The button is only built once all three boxes are ticked Future onConfirmButtonPressed() async { - await commonTestCases.tapItemByKey("pre_seed_page_only_way_checkbox_key"); - await commonTestCases.tapItemByKey("pre_seed_page_write_down_checkbox_key"); - await commonTestCases.tapItemByKey("pre_seed_page_never_share_checkbox_key"); - await commonTestCases.tapItemByKey('pre_seed_page_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("pre_seed_page_only_way_checkbox_key"); + await tapByKey("pre_seed_page_write_down_checkbox_key"); + await tapByKey("pre_seed_page_never_share_checkbox_key"); + await tapByKey("pre_seed_page_button_key"); } } diff --git a/integration_test/robots/restore_from_seed_or_key_robot.dart b/integration_test/robots/restore_from_seed_or_key_robot.dart index 5b35329e1..0ade26c64 100644 --- a/integration_test/robots/restore_from_seed_or_key_robot.dart +++ b/integration_test/robots/restore_from_seed_or_key_robot.dart @@ -1,123 +1,101 @@ -import 'package:cake_wallet/entities/seed_type.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/restore/wallet_restore_page.dart'; -import 'package:cake_wallet/src/widgets/validable_annotated_editable_text.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/entities/seed_type.dart"; +import "package:cake_wallet/src/screens/restore/wallet_restore_page.dart"; +import "package:cake_wallet/src/widgets/primary_button.dart"; +import "package:cake_wallet/src/widgets/validable_annotated_editable_text.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class RestoreFromSeedOrKeysPageRobot { - RestoreFromSeedOrKeysPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class RestoreFromSeedOrKeysPageRobot extends BaseRobot { + RestoreFromSeedOrKeysPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isRestoreFromSeedKeyPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_restore_page'); - } - - Future confirmViewComponentsDisplayProperlyPerPageView() async { - commonTestCases.hasText(S.current.wallet_name); - commonTestCases.hasText(S.current.enter_seed_phrase); - commonTestCases.hasText(S.current.restore_title_from_seed); - - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_name_textfield_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_name_refresh_button_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_seeds_paste_button_key'); - commonTestCases.hasValueKey('wallet_restore_from_seed_wallet_seeds_textfield_key'); - - commonTestCases.hasText(S.current.private_key, hasWidget: false); - commonTestCases.hasText(S.current.restore_title_from_keys, hasWidget: false); - - await commonTestCases.swipePage(); - await commonTestCases.defaultSleepTime(); - - commonTestCases.hasText(S.current.wallet_name); - commonTestCases.hasText(S.current.private_key); - commonTestCases.hasText(S.current.restore_title_from_keys); - - commonTestCases.hasText(S.current.enter_seed_phrase, hasWidget: false); - commonTestCases.hasText(S.current.restore_title_from_seed, hasWidget: false); - - await commonTestCases.swipePage(swipeRight: false); - } - - void confirmRestoreButtonDisplays() { - commonTestCases.hasValueKey('wallet_restore_seed_or_key_restore_button_key'); - } - - void confirmAdvancedSettingButtonDisplays() { - commonTestCases.hasValueKey('wallet_restore_advanced_settings_button_key'); - } - - Future enterWalletNameText(String walletName, {bool isSeedFormEntry = true}) async { - await commonTestCases.enterText( - walletName, - 'wallet_restore_from_${isSeedFormEntry ? 'seed' : 'keys'}_wallet_name_textfield_key', - ); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future selectWalletNameFromAvailableOptions({bool isSeedFormEntry = true}) async { - await commonTestCases.tapItemByKey( + await tapByKey( 'wallet_restore_from_${isSeedFormEntry ? 'seed' : 'keys'}_wallet_name_refresh_button_key', ); } Future enterSeedPhraseForWalletRestore(String text) async { - ValidatableAnnotatedEditableTextState seedTextState = - await tester.state(find.byType(ValidatableAnnotatedEditableText)); + final ValidatableAnnotatedEditableTextState seedTextState = + tester.state(find.byType(ValidatableAnnotatedEditableText)); seedTextState.widget.controller.text = text; - await tester.pumpAndSettle(); + await settle(); } Future enterPasswordForWalletRestore(String text) async { - await commonTestCases.enterText( - text, - 'password', - ); - await tester.pumpAndSettle(); + await enterTextByKey("password", text); + await settle(); } Future enterPasswordRepeatForWalletRestore(String text) async { - await commonTestCases.enterText( - text, - 'repeat_wallet_password', - ); - await tester.pumpAndSettle(); + await enterTextByKey("repeat_wallet_password", text); + await settle(); } Future enterBlockHeightForWalletRestore(String blockHeight) async { - await commonTestCases.enterText( - blockHeight, - 'wallet_restore_from_seed_blockheight_textfield_key', - ); - await tester.pumpAndSettle(); + await enterTextByKey("wallet_restore_from_seed_blockheight_textfield_key", blockHeight); + await settle(); } Future chooseSeedTypeForMoneroOrWowneroWallets(MoneroSeedType selectedType) async { - await commonTestCases.tapItemByKey('wallet_restore_from_seed_seedtype_picker_button_key'); + await tapByKey("wallet_restore_from_seed_seedtype_picker_button_key"); - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${selectedType.title}_button_key'); + await tapByKey("picker_items_index_${selectedType.title}_button_key"); } - Future onPasteSeedPhraseButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_restore_from_seed_wallet_seeds_paste_button_key'); - } - - Future enterPrivateKeyForWalletRestore(String privateKey) async { - await commonTestCases.enterText( - privateKey, - 'wallet_restore_from_key_private_key_textfield_key', + Future expectRestoreRefused({Duration window = const Duration(seconds: 10)}) async { + final button = tester.widget( + find.byKey(const ValueKey("wallet_restore_seed_or_key_restore_button_key")), ); - await tester.pumpAndSettle(); + + expect( + button.isDisabled, + true, + reason: "The restore button accepted a seed the wallet cannot parse", + ); + + final left = await pumpUntil( + () => !tester.any(find.byType(WalletRestorePage)), + timeout: window, + ); + + expect(left, false, reason: "A seed the wallet cannot parse still restored something"); } Future onRestoreWalletButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_restore_seed_or_key_restore_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_restore_seed_or_key_restore_button_key"); + } + + Future enablePassphrase() async { + await tapByKey("wallet_restore_has_passphrase_checkbox_key"); + await settle(); + + final page = tester.widget(find.byType(WalletRestorePage)); + + expect( + page.walletRestoreViewModel.hasPassphrase, + true, + reason: "The passphrase box did not tick, the wallet would restore without it", + ); + } + + Future enterPassphrase(String passphrase) async { + await enterTextByKey( + "add_passphrase_bottom_sheet_widget_passphrase_textfield_key", + passphrase, + ); + await enterTextByKey( + "add_passphrase_bottom_sheet_widget_confirm_passphrase_textfield_key", + passphrase, + ); + + await tapByKey("add_passphrase_bottom_sheet_widget_restore_button_key"); } } diff --git a/integration_test/robots/restore_options_page_robot.dart b/integration_test/robots/restore_options_page_robot.dart index c269d7343..43448de41 100644 --- a/integration_test/robots/restore_options_page_robot.dart +++ b/integration_test/robots/restore_options_page_robot.dart @@ -1,43 +1,16 @@ -import 'package:cake_wallet/src/screens/restore/restore_options_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/restore/restore_options_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class RestoreOptionsPageRobot { - RestoreOptionsPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class RestoreOptionsPageRobot extends BaseRobot { + RestoreOptionsPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isRestoreOptionsPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('restore_options'); - } - - void hasRestoreOptionsButton() { - commonTestCases.hasValueKey('restore_options_from_seeds_or_keys_button_key'); - commonTestCases.hasValueKey('restore_options_from_backup_button_key'); - commonTestCases.hasValueKey('restore_options_from_hardware_wallet_button_key'); - commonTestCases.hasValueKey('restore_options_from_qr_button_key'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future navigateToRestoreFromSeedsOrKeysPage() async { - await commonTestCases.tapItemByKey('restore_options_from_seeds_or_keys_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToRestoreFromBackupPage() async { - await commonTestCases.tapItemByKey('restore_options_from_backup_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future navigateToRestoreFromHardwareWalletPage() async { - await commonTestCases.tapItemByKey('restore_options_from_hardware_wallet_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future backAndVerify() async { - await commonTestCases.goBack(); - await isRestoreOptionsPage(); + await tapByKey("restore_options_from_seeds_or_keys_button_key"); } } diff --git a/integration_test/robots/security_and_backup_page_robot.dart b/integration_test/robots/security_and_backup_page_robot.dart deleted file mode 100644 index 9bb4c7d44..000000000 --- a/integration_test/robots/security_and_backup_page_robot.dart +++ /dev/null @@ -1,28 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/settings/security_backup_page.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class SecurityAndBackupPageRobot { - SecurityAndBackupPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isSecurityAndBackupPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('security_backup_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.security_and_backup); - } - - Future navigateToShowKeysPage() async { - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.tapItemByKey('security_backup_page_show_keys_button_key'); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - } -} diff --git a/integration_test/robots/security_backup_page_robot.dart b/integration_test/robots/security_backup_page_robot.dart new file mode 100644 index 000000000..2980b0e69 --- /dev/null +++ b/integration_test/robots/security_backup_page_robot.dart @@ -0,0 +1,16 @@ +import "package:cake_wallet/src/screens/settings/security_backup_page.dart"; + +import "../core/base_robot.dart"; + +class SecurityBackupPageRobot extends BaseRobot { + SecurityBackupPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openChangePin() async { + await tapByKey("security_backup_page_change_pin_button_key"); + } +} diff --git a/integration_test/robots/seed_verification_page_robot.dart b/integration_test/robots/seed_verification_page_robot.dart index a222c2756..e8580bed7 100644 --- a/integration_test/robots/seed_verification_page_robot.dart +++ b/integration_test/robots/seed_verification_page_robot.dart @@ -1,22 +1,19 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/seed/seed_verification/seed_verification_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class SeedVerificationPageRobot { - SeedVerificationPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class SeedVerificationPageRobot extends BaseRobot { + SeedVerificationPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isSeedVerificationPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('seed_verification_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } void hasTitle() { - commonTestCases.hasText(S.current.verify_seed); + hasText(S.current.verify_seed); } Future verifyWalletSeeds() async { @@ -28,18 +25,23 @@ class SeedVerificationPageRobot { while (!walletSeedViewModel.isVerificationComplete && walletSeedViewModel.verificationWordCount != 0) { final currentCorrectWord = walletSeedViewModel.currentCorrectWord; + final currentStep = walletSeedViewModel.currentStepIndex; - commonTestCases.hasTextAtLestOnce(currentCorrectWord); + hasTextAtLeastOnce(currentCorrectWord); - await commonTestCases.tapItemByKey( - 'seed_verification_option_${currentCorrectWord}_button_key', + await tapByKey("seed_verification_option_${currentCorrectWord}_button_key"); + + final hasMovedOn = await pumpUntil( + () => + walletSeedViewModel.isVerificationComplete || + walletSeedViewModel.currentStepIndex != currentStep, ); - await commonTestCases.defaultSleepTime(seconds: 1); + if (!hasMovedOn) { + throw TestFailure("Seed verification never moved past step $currentStep"); + } } - await commonTestCases.tapItemByKey('wallet_seed_page_open_wallet_button_key'); - - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_seed_page_open_wallet_button_key"); } } diff --git a/integration_test/robots/send_page_robot.dart b/integration_test/robots/send_page_robot.dart deleted file mode 100644 index 84d0156ea..000000000 --- a/integration_test/robots/send_page_robot.dart +++ /dev/null @@ -1,716 +0,0 @@ -import 'dart:async'; - -import 'package:cake_wallet/core/execution_state.dart'; -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/send/send_page.dart'; -import 'package:cake_wallet/src/widgets/base_text_form_field.dart'; -import 'package:cake_wallet/src/widgets/primary_button.dart'; -import 'package:cake_wallet/view_model/send/send_view_model_state.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/transaction_priority.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; -import '../components/common_test_constants.dart'; -import 'auth_page_robot.dart'; -import 'package:cake_wallet/src/widgets/standard_slide_button_widget.dart'; -import 'package:cake_wallet/src/screens/dashboard/dashboard_page.dart'; - -class SendPageRobot { - SendPageRobot({required this.tester}) - : commonTestCases = CommonTestCases(tester), - authPageRobot = AuthPageRobot(tester); - - WidgetTester tester; - CommonTestCases commonTestCases; - AuthPageRobot authPageRobot; - - Future isSendPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('send_page'); - } - - Future waitForSendPage() async { - tester.printToConsole('Waiting for SendPage to be available'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 30); - - while (stopwatch.elapsed < maxDuration) { - await tester.pump(Duration(milliseconds: 500)); - - // Check if we're still on auth page - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Still on auth page, waiting...'); - continue; - } - - // Check if SendPage is available - final sendPageFinder = find.byType(SendPage); - if (sendPageFinder.tryEvaluate()) { - tester.printToConsole('SendPage found!'); - return; - } - - tester.printToConsole('SendPage not found yet, waiting...'); - } - - throw Exception('SendPage not found after ${maxDuration.inSeconds} seconds'); - } - - Future checkIfSendPageIsVisible() async { - tester.printToConsole('Confirming SendPage is visible'); - - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Auth page is currently visible'); - await _handleAuthPage(); - await commonTestCases.defaultSleepTime(); - } - - await waitForSendPage(); - } - - void hasTitle() { - commonTestCases.hasText(S.current.send); - } - - void confirmViewComponentsDisplayProperly() { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - commonTestCases.hasValueKey('send_page_address_textfield_key'); - commonTestCases.hasValueKey('send_page_note_textfield_key'); - commonTestCases.hasValueKey('send_page_amount_textfield_key'); - commonTestCases.hasValueKey('send_page_add_template_button_key'); - - if (sendViewModel.hasMultipleTokens) { - commonTestCases.hasValueKey('send_page_currency_picker_button_key'); - } - - if (!sendViewModel.isBatchSending) { - commonTestCases.hasValueKey('send_page_send_all_button_key'); - } - - if (!sendViewModel.isFiatDisabled) { - commonTestCases.hasValueKey('send_page_fiat_amount_textfield_key'); - } - - if (sendViewModel.feesViewModel.hasFees) { - commonTestCases.hasValueKey('send_page_select_fee_priority_button_key'); - } - - if (sendViewModel.hasCoinControl) { - commonTestCases.hasValueKey('send_page_unspent_coin_button_key'); - } - - if (sendViewModel.sendTemplateViewModel.hasMultiRecipient) { - commonTestCases.hasValueKey('send_page_add_receiver_button_key'); - } - } - - Future selectReceiveCurrency(CryptoCurrency receiveCurrency) async { - final currencyPickerKey = 'send_page_currency_picker_button_key'; - final currencyPickerDialogKey = 'send_page_currency_picker_dialog_button_key'; - - await commonTestCases.tapItemByKey(currencyPickerKey); - await commonTestCases.defaultSleepTime(); - - // Check if picker dialog is present - if (!commonTestCases.isKeyPresent(currencyPickerDialogKey)) { - tester.printToConsole('Currency picker dialog not found, may already be selected'); - return; - } - - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - if (receiveCurrency == sendViewModel.selectedCryptoCurrency) { - await commonTestCases - .tapItemByKey('picker_items_index_${receiveCurrency.name}_selected_item_button_key'); - return; - } - - await commonTestCases.enterText(receiveCurrency.title, 'search_bar_widget_key'); - - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${receiveCurrency.fullName}_button_key'); - } - - Future enterReceiveAddress(String receiveAddress) async { - await commonTestCases.enterText(receiveAddress, 'send_page_address_textfield_key'); - await commonTestCases.defaultSleepTime(); - } - - Future enterSendAmount(String amount, {bool isFiat = false}) async { - await commonTestCases.enterText( - amount, - isFiat ? 'send_page_fiat_amount_textfield_key' : 'send_page_amount_textfield_key', - ); - } - - String _getTextFromField(ValueKey key) { - final field = find.byKey(key); - if (!field.tryEvaluate()) return ''; - final baseTextFormField = tester.widget(field); - return baseTextFormField.controller?.text ?? ''; - } - - /// Validates wallet balance for $1 send by checking against the converted crypto amount - /// Returns true if wallet has sufficient balance, false otherwise - Future validateWalletBalanceForOneDollarSend() async { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - final balance = await sendViewModel.sendingBalance; - - await setupOneDollarSend(); - - // Get the crypto amount that was set for the $1 send - String cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - if (cryptoAmount.isEmpty || cryptoAmount == '0' || cryptoAmount == '0.0') { - cryptoAmount = '0.001'; // fallback amount - } - - final amount = double.tryParse(cryptoAmount) ?? 0.0; - - tester.printToConsole( - 'Wallet balance: $balance, sending amount for \$${CommonTestConstants.sendTestFiatAmount}: $cryptoAmount', - ); - - if (balance.isEmpty || double.tryParse(balance) == null) { - tester.printToConsole('Invalid wallet balance: $balance'); - return false; - } - - final balanceValue = double.parse(balance); - if (balanceValue < amount) { - tester.printToConsole( - 'Insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send: $balanceValue < $amount', - ); - return false; - } - - tester.printToConsole( - 'Wallet has sufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - return true; - } - - Future selectTransactionPriority({TransactionPriority? priority}) async { - SendPage sendPage = tester.widget(find.byType(SendPage)); - final sendViewModel = sendPage.sendViewModel; - - if (!sendViewModel.feesViewModel.hasFees || priority == null) return; - - final transactionPriorityPickerKey = 'send_page_select_fee_priority_button_key'; - await commonTestCases.tapItemByKey(transactionPriorityPickerKey); - - if (priority == sendViewModel.feesViewModel.transactionPriority) { - await commonTestCases - .tapItemByKey('picker_items_index_${priority.title}_selected_item_button_key'); - return; - } - - await commonTestCases.dragUntilVisible( - 'picker_items_index_${priority.title}_button_key', - 'picker_scrollbar_key', - ); - await commonTestCases.defaultSleepTime(); - - await commonTestCases.tapItemByKey('picker_items_index_${priority.title}_button_key'); - } - - Future onSendButtonPressed() async { - tester.printToConsole('Pressing send'); - - await checkIfSendPageIsVisible(); - - await tester.pumpAndSettle(); - final sendPage = tester.widget(find.byType(SendPage)); - - while (true) { - bool isReadyForSend = sendPage.sendViewModel.isReadyForSend; - await tester.pump(); - if (isReadyForSend) { - tester.printToConsole('Is ready for send'); - break; - } else { - await commonTestCases.defaultSleepTime(); - await tester.pumpAndSettle(); - tester.printToConsole('not yet ready for send'); - } - } - await commonTestCases.tapItemByKey( - 'send_page_send_button_key', - shouldPumpAndSettle: false, - ); - - await _waitForSendTransactionCompletion(); - - await commonTestCases.defaultSleepTime(); - } - - Future _waitForSendTransactionCompletion() async { - await tester.pump(); - final Completer completer = Completer(); - - // Loop to wait for the async operation to complete - while (true) { - await Future.delayed(Duration(seconds: 1)); - - tester.printToConsole('Before _handleAuth'); - - await _handleAuthPage(); - - await commonTestCases.defaultSleepTime(); - - tester.printToConsole('After _handleAuth'); - - await tester.pump(); - - if (authPageRobot.onAuthPage()) { - tester.printToConsole('Still on auth page, continuing to wait'); - continue; - } - - final sendPageFinder = find.byType(SendPage); - if (!sendPageFinder.tryEvaluate()) { - tester.printToConsole('SendPage not found yet, continuing to wait'); - continue; - } - - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - await tester.pump(); - - bool isDone = state is ExecutedSuccessfullyState || state is TransactionCommitted; - bool isFailed = state is FailureState; - - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone || isFailed) { - tester.printToConsole( - isDone ? 'Completer is done' : 'Completer is done though operation failed', - ); - completer.complete(); - await tester.pump(); - break; - } else { - tester.printToConsole('Completer is not done'); - await tester.pump(); - } - } - - await expectLater(completer.future, completes); - - tester.printToConsole('Done confirming sending operation'); - } - - Future _handleAuthPage() async { - tester.printToConsole('Inside _handleAuth'); - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - await commonTestCases.defaultSleepTime(); - return; - } - - await tester.pump(); - tester.printToConsole('starting auth checks'); - - final authPage = authPageRobot.onAuthPage(); - - tester.printToConsole('hasAuth:$authPage'); - - if (authPage) { - await tester.pump(); - tester.printToConsole('Starting inner _handleAuth loop checks'); - - try { - await authPageRobot.enterPinCode(CommonTestConstants.pin, pumpDuration: 500); - tester.printToConsole('Auth done'); - - await tester.pump(Duration(seconds: 3)); - - tester.printToConsole('Auth pump done'); - - await commonTestCases.defaultSleepTime(); - } catch (e) { - tester.printToConsole('Auth failed, retrying: $e'); - await tester.pump(); - - await commonTestCases.defaultSleepTime(seconds: 1); - await _handleAuthPage(); - } - } else { - tester.printToConsole('No auth page detected, proceeding'); - } - await tester.pump(); - } - - //* ------ On Sending Failure ------------ - Future hasErrorWhileSending() async { - await tester.pump(); - - tester.printToConsole('Checking if there is an error'); - - final errorDialog = find.byKey(ValueKey('send_page_send_failure_dialog_button_key')); - - bool hasError = errorDialog.tryEvaluate(); - - tester.printToConsole('Has error: $hasError'); - - return hasError; - } - - Future onSendFailureDialogButtonPressed() async { - await commonTestCases.defaultSleepTime(); - - tester.printToConsole('Send Button Failure Dialog Triggered'); - - await commonTestCases.tapItemByKey('send_page_send_failure_dialog_button_key'); - } - - //* ------ On Sending Success ------------ - Future onSendSliderOnConfirmSendingBottomSheetDragged() async { - await commonTestCases.defaultSleepTime(); - await tester.pump(); - - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Found confirm sending bottom sheet, starting slider drag'); - - final accessibleButton = find.byType(PrimaryButton); - if (accessibleButton.tryEvaluate()) { - tester.printToConsole('Found accessible navigation button, tapping it'); - await tester.tap(accessibleButton); - await tester.pumpAndSettle(); - } else { - await _performSendSliderDrag(); - } - - tester.printToConsole('Slider/button action completed, waiting for transaction completion'); - - // Check if bottom sheet is dismissed - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet still present, waiting a bit more for dismissal'); - await commonTestCases.defaultSleepTime(seconds: 3); - - // If still present, try one more manual dismissal attempt - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet still present, attempting final manual dismissal'); - await tester.tapAt(Offset(200, 200)); // Tap outside - await tester.pumpAndSettle(); - } - } - - // Wait for transaction completion - await _waitForCommitTransactionCompletion(); - await commonTestCases.defaultSleepTime(seconds: 2); - } else { - tester.printToConsole('Confirm sending bottom sheet not found, waiting and retrying'); - await commonTestCases.defaultSleepTime(); - await tester.pump(); - await onSendSliderOnConfirmSendingBottomSheetDragged(); - } - } - - Future _performSendSliderDrag() async { - final sliderFinder = find.byKey(const ValueKey('standard_slide_button_widget_slider_key')); - expect(sliderFinder, findsOneWidget, reason: 'Slider should be found'); - - // Find the StandardSlideButton widget to get the main container - final slideButtonFinder = find.byType(StandardSlideButton); - expect(slideButtonFinder, findsOneWidget, reason: 'StandardSlideButton should be found'); - - // Get the main container bounds (the outer container, not the slider container) - final mainContainerRect = tester.getRect(slideButtonFinder); - final containerWidth = mainContainerRect.width; - - final sideMargin = 4.0; - final sliderWidth = 42.0; - final effectiveMaxWidth = containerWidth - 2 * sideMargin; - final threshold = effectiveMaxWidth - sliderWidth - 10; - - // Add a small buffer to ensure we exceed the threshold - final dragDistance = threshold + 20; - - tester.printToConsole( - 'Main container width: $containerWidth, Threshold: $threshold, Drag distance: $dragDistance', - ); - - // Start the drag - await tester.drag(sliderFinder, Offset(dragDistance, 0)); - - // Wait for the drag to complete and trigger onHorizontalDragEnd - await tester.pump(Duration(milliseconds: 100)); - - // Release the drag (this should trigger onHorizontalDragEnd) - await tester.pump(Duration(seconds: 2)); - - tester.printToConsole('Drag completed, waiting for callback'); - - // Wait for the slide completion callback to trigger and bottom sheet to dismiss - await _waitForBottomSheetDismissal(); - } - - Future _waitForBottomSheetDismissal() async { - tester.printToConsole('Waiting for bottom sheet dismissal'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 10); - - while (stopwatch.elapsed < maxDuration) { - await tester.pump(Duration(milliseconds: 500)); - - // Check if the confirm bottom sheet is still present - if (!commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Bottom sheet dismissed successfully!'); - return; - } - - // Check if transaction has started (this indicates slider was successful) - if (_isTransactionStarted()) { - tester.printToConsole( - 'Transaction started, slider was successful even if bottom sheet is still visible'); - return; - } - - tester.printToConsole('Bottom sheet still present, waiting'); - } - - tester.printToConsole('Bottom sheet dismissal timeout reached, trying manual dismissal'); - - // If the bottom sheet is still present after timeout, try to manually dismiss it - // This might happen if the slider drag didn't trigger the onSlideComplete properly - if (commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Attempting manual bottom sheet dismissal'); - - // Try to find and tap a close button or back button - final closeButton = find.byIcon(Icons.close); - if (closeButton.tryEvaluate()) { - await tester.tap(closeButton); - await tester.pumpAndSettle(); - tester.printToConsole('Manual close button tapped'); - } else { - // Try to pop the bottom sheet by tapping outside or using back gesture - await tester.tapAt(Offset(100, 100)); // Tap outside the bottom sheet - await tester.pumpAndSettle(); - tester.printToConsole('Tapped outside bottom sheet'); - } - } - } - - bool _isTransactionStarted() { - try { - // Check if SendPage is available and transaction state has changed - final sendPageFinder = find.byType(SendPage); - if (!sendPageFinder.tryEvaluate()) { - return false; - } - - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - // Check if we're in a transaction-related state - return state is TransactionCommitting || - state is IsExecutingState || - state is TransactionCommitted || - state is ExecutedSuccessfullyState; - } catch (e) { - return false; - } - } - - Future _waitForCommitTransactionCompletion() async { - tester.printToConsole('Starting to wait for transaction completion'); - - final stopwatch = Stopwatch()..start(); - final maxDuration = Duration(seconds: 60); - - while (stopwatch.elapsed < maxDuration) { - await Future.delayed(Duration(seconds: 1)); - - // Check if the confirm bottom sheet is gone (transaction started) - if (!commonTestCases.isKeyPresent('send_page_confirm_sending_bottom_sheet_key')) { - tester.printToConsole('Confirm bottom sheet disappeared, transaction may be processing'); - } - - // Check if we've navigated back to the dashboard (for cases of successful transaction) - final dashboardFinder = find.byType(DashboardPage); - if (dashboardFinder.tryEvaluate()) { - tester.printToConsole('Dashboard detected, transaction completed successfully!'); - await tester.pump(); - return; - } - - // Check if SendPage is still available (transaction still processing) - final sendPageFinder = find.byType(SendPage); - if (sendPageFinder.tryEvaluate()) { - final sendPage = tester.widget(sendPageFinder); - final state = sendPage.sendViewModel.state; - - bool isDone = state is ExecutedSuccessfullyState || state is TransactionCommitted; - bool isFailed = state is FailureState; - - tester.printToConsole('Transaction state: $state'); - tester.printToConsole('isDone: $isDone'); - tester.printToConsole('isFailed: $isFailed'); - - if (isDone) { - tester.printToConsole('Transaction committed successfully!'); - await tester.pump(); - return; - } else if (isFailed) { - tester.printToConsole('Transaction failed: $state'); - await tester.pump(); - return; - } else { - tester.printToConsole('Transaction still processing'); - await tester.pump(); - } - } else { - // SendPage not found, check if we're on dashboard or still processing - if (dashboardFinder.tryEvaluate()) { - tester.printToConsole('Dashboard detected - transaction completed successfully!'); - await tester.pump(); - return; - } else { - tester.printToConsole( - 'SendPage not found, but dashboard not yet visible - continuing to wait...'); - } - } - } - - if (stopwatch.elapsed >= maxDuration) { - tester.printToConsole('Transaction completion timeout reached'); - } - - tester.printToConsole('Done waiting for transaction completion'); - } - - //* ---- Handle Transaction Success Flow ----- - Future handleTransactionSuccessFlow() async { - await commonTestCases.defaultSleepTime(); - - // Wait for any success dialogs to appear - await tester.pump(Duration(seconds: 2)); - - // Check for contact addition dialog first (if new contact address exists) - final contactDialog = find.byKey(ValueKey('send_page_add_contact_bottom_sheet_yes_button_key')); - if (contactDialog.tryEvaluate()) { - tester.printToConsole('Found contact addition dialog, selecting Yes'); - - // Check if the button is actually visible and tappable - final buttonRect = tester.getRect(contactDialog); - final screenSize = tester.view.physicalSize / tester.view.devicePixelRatio; - - if (buttonRect.bottom <= screenSize.height && buttonRect.top >= 0) { - await commonTestCases.tapItemByKey('send_page_add_contact_bottom_sheet_yes_button_key'); - await commonTestCases.defaultSleepTime(); - } else { - tester.printToConsole('Contact dialog button is off-screen, skipping'); - } - } - - // Check for the main success dialog - final successDialog = find.byKey(ValueKey('send_page_sent_dialog_ok_button_key')); - if (successDialog.tryEvaluate()) { - tester.printToConsole('Found transaction success dialog, closing it'); - await commonTestCases.tapItemByKey('send_page_sent_dialog_ok_button_key'); - await commonTestCases.defaultSleepTime(); - } - } - - //* ---- Fiat/Crypto Amount Validation ----- - Future testFiatAmountEntry() async { - tester.printToConsole('Testing fiat amount entry...'); - - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - await enterSendAmount(CommonTestConstants.sendTestFiatAmount, isFiat: true); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Next we get the crypto amount value and validate it's not 0 - final cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - - tester.printToConsole( - 'Crypto amount after entering \$${CommonTestConstants.sendTestFiatAmount}: $cryptoAmount', - ); - - if (cryptoAmount.isNotEmpty && cryptoAmount != '0' && cryptoAmount != '0.0') { - tester.printToConsole('Fiat to crypto conversion working - crypto amount: $cryptoAmount'); - } else { - tester.printToConsole( - 'Fiat to crypto conversion may not be working - crypto amount: $cryptoAmount', - ); - } - } - - Future testCryptoAmountEntry() async { - tester.printToConsole('Testing crypto amount entry...'); - - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - String cryptoAmount = '0.001'; - - await enterSendAmount(cryptoAmount); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Get the fiat amount value and validate it's not 0 - final fiatAmount = _getTextFromField(ValueKey('send_page_fiat_amount_textfield_key')); - - tester.printToConsole('Fiat amount after entering $cryptoAmount: $fiatAmount'); - - if (fiatAmount.isNotEmpty && fiatAmount != '0' && fiatAmount != '0.0') { - tester.printToConsole('Crypto to fiat conversion working, fiat amount: $fiatAmount'); - } else { - tester.printToConsole( - 'Crypto to fiat conversion may not be working - fiat amount: $fiatAmount'); - } - } - - Future setupOneDollarSend() async { - // Clear existing amounts - await enterSendAmount(''); - await enterSendAmount('', isFiat: true); - await commonTestCases.defaultSleepTime(); - - await enterSendAmount(CommonTestConstants.sendTestFiatAmount, isFiat: true); - await commonTestCases.defaultSleepTime(); - - // Wait for conversion to complete - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - // Get the converted crypto amount - String cryptoAmount = _getTextFromField(ValueKey('send_page_amount_textfield_key')); - if (cryptoAmount.isEmpty || cryptoAmount == '0' || cryptoAmount == '0.0') { - cryptoAmount = '0.001'; // fallback amount - } - - tester.printToConsole( - 'Sending $cryptoAmount (equivalent to \$${CommonTestConstants.sendTestFiatAmount}) to test wallet', - ); - - // Update the amount field with the converted value - await enterSendAmount(cryptoAmount); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/setup_pin_code_robot.dart b/integration_test/robots/setup_pin_code_robot.dart index aa6dde070..1cb2318e1 100644 --- a/integration_test/robots/setup_pin_code_robot.dart +++ b/integration_test/robots/setup_pin_code_robot.dart @@ -1,29 +1,16 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/setup_pin_code/setup_pin_code.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/setup_pin_code/setup_pin_code.dart"; -import '../components/common_test_cases.dart'; -import 'pin_code_widget_robot.dart'; +import "pin_code_widget_robot.dart"; class SetupPinCodeRobot extends PinCodeWidgetRobot { - SetupPinCodeRobot(this.tester) - : commonTestCases = CommonTestCases(tester), - super(tester); + SetupPinCodeRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isSetupPinCodePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('setup_pin_code_page'); - } - - void hasTitle() { - commonTestCases.hasText(S.current.setup_pin); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future tapSuccessButton() async { - await commonTestCases.tapItemByKey('setup_pin_code_success_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("setup_pin_code_success_button_key"); } } diff --git a/integration_test/robots/show_keys_disclaimer_page_robot.dart b/integration_test/robots/show_keys_disclaimer_page_robot.dart new file mode 100644 index 000000000..8d0caec5c --- /dev/null +++ b/integration_test/robots/show_keys_disclaimer_page_robot.dart @@ -0,0 +1,16 @@ +import "package:cake_wallet/new-ui/pages/seed/show_keys_disclaimer_page.dart"; + +import "../core/base_robot.dart"; + +class ShowKeysDisclaimerPageRobot extends BaseRobot { + ShowKeysDisclaimerPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future tapShowKeys() async { + await tapByKey("show_keys_disclaimer_page_button_key"); + } +} diff --git a/integration_test/robots/transaction_success_info_robot.dart b/integration_test/robots/transaction_success_info_robot.dart deleted file mode 100644 index 4be484ac5..000000000 --- a/integration_test/robots/transaction_success_info_robot.dart +++ /dev/null @@ -1,21 +0,0 @@ -import 'package:cake_wallet/src/screens/send/transaction_success_info_page.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import '../components/common_test_cases.dart'; - -class TransactionSuccessInfoRobot { - TransactionSuccessInfoRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isTransactionSuccessInfoPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('transaction_success_info_page'); - } - - Future onConfirmButtonPressed() async { - await commonTestCases.tapItemByKey('transaction_success_info_page_button_key'); - await commonTestCases.defaultSleepTime(); - } -} diff --git a/integration_test/robots/transactions_page_robot.dart b/integration_test/robots/transactions_page_robot.dart deleted file mode 100644 index 1365c54c2..000000000 --- a/integration_test/robots/transactions_page_robot.dart +++ /dev/null @@ -1,439 +0,0 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/dashboard/pages/transactions_page.dart'; -import 'package:cake_wallet/view_model/dashboard/anonpay_transaction_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/dashboard_view_model.dart'; -import 'package:cake_wallet/view_model/dashboard/date_section_item.dart'; -import 'package:cake_wallet/view_model/dashboard/order_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/trade_list_item.dart'; -import 'package:cake_wallet/view_model/dashboard/transaction_list_item.dart'; -import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/sync_status.dart'; -import 'package:cw_core/transaction_direction.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:intl/intl.dart'; - -import '../components/common_test_cases.dart'; - -class TransactionsPageRobot { - TransactionsPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); - - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isTransactionsPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('transactions_page'); - } - - Future confirmTransactionsPageConstantsDisplayProperly() async { - await commonTestCases.defaultSleepTime(); - - final transactionsPage = tester.widget(find.byType(TransactionsPage)); - final dashboardViewModel = transactionsPage.dashboardViewModel; - if (dashboardViewModel.status is SyncingSyncStatus) { - commonTestCases.hasValueKey('transactions_page_syncing_alert_card_key'); - commonTestCases.hasText(S.current.syncing_wallet_alert_title); - commonTestCases.hasText(S.current.syncing_wallet_alert_content); - } - - commonTestCases.hasValueKey('transactions_page_header_row_key'); - commonTestCases.hasText(S.current.transactions); - commonTestCases.hasValueKey('transactions_page_header_row_transaction_filter_button_key'); - } - - Future confirmTransactionHistoryListDisplaysCorrectly(bool hasTxHistoryWhileSyncing) async { - try { - final transactionsPage = tester.widget(find.byType(TransactionsPage)); - final dashboardViewModel = transactionsPage.dashboardViewModel; - - await _waitForListToBeReady(); - - if (!hasTxHistoryWhileSyncing) { - await _waitForSyncToComplete(dashboardViewModel); - } else { - await tester.pump(Duration(seconds: 2)); - await tester.pumpAndSettle(); - } - - await _performComprehensiveItemCheck(dashboardViewModel); - } catch (e) { - tester.printToConsole('Error in transaction history check: $e'); - } - } - - Future _waitForSyncToComplete(DashboardViewModel dashboardViewModel) async { - const maxWaitTime = Duration(minutes: 2); - final endTime = DateTime.now().add(maxWaitTime); - - tester.printToConsole('Waiting for wallet to sync...'); - - while (DateTime.now().isBefore(endTime)) { - await tester.pump(Duration(seconds: 2)); - await tester.pumpAndSettle(); - - if (dashboardViewModel.status is SyncedSyncStatus) { - tester.printToConsole('Wallet synced successfully'); - return; - } - - // Check if we have now have transaction items available - if (dashboardViewModel.items.isNotEmpty) { - tester.printToConsole('Items available while syncing, proceeding with test'); - return; - } - - tester.printToConsole('Sync status: ${dashboardViewModel.status.runtimeType}'); - } - - tester.printToConsole('Warning: Wallet did not sync within expected time, proceeding anyway'); - } - - Future _performComprehensiveItemCheck(DashboardViewModel dashboardViewModel) async { - try { - await _waitForItemsToLoad(dashboardViewModel); - - final itemsToProcess = dashboardViewModel.items.where((item) { - if (item is DateSectionItem) return false; - if (item is TransactionListItem) { - return !(item.hasTokens && item.assetOfTransaction == null); - } - return true; - }).toList(); - - if (itemsToProcess.isEmpty) { - tester.printToConsole('No transaction items to process - checking for placeholder'); - _verifyPlaceholder(); - return; - } - - // This is a temporary limit to prevent the test from taking too long - final maxItemsToProcess = 100; - final itemsToCheck = itemsToProcess.take(maxItemsToProcess).toList(); - - tester.printToConsole( - 'Processing ${itemsToCheck.length} items out of ${itemsToProcess.length} total items', - ); - - await _processVisibleItems(itemsToCheck, dashboardViewModel); - - // Try to scroll and process more items if needed - await _processItemsWithScrolling(itemsToCheck, dashboardViewModel); - } catch (e) { - tester.printToConsole('Error in comprehensive item check: $e'); - try { - _verifyPlaceholder(); - } catch (placeholderError) { - tester.printToConsole('Could not verify placeholder either: $placeholderError'); - } - } - } - - Future _waitForItemsToLoad(DashboardViewModel dashboardViewModel) async { - const maxWaitTime = Duration(seconds: 30); - final endTime = DateTime.now().add(maxWaitTime); - - while (DateTime.now().isBefore(endTime)) { - if (dashboardViewModel.items.isNotEmpty) { - tester.printToConsole('Items loaded: ${dashboardViewModel.items.length}'); - return; - } - - // Check if placeholder is shown - if (tester.any(find.byKey(ValueKey('transactions_page_placeholder_transactions_text_key')))) { - tester.printToConsole('Placeholder is shown - no items to load'); - return; - } - - await tester.pump(Duration(seconds: 1)); - await tester.pumpAndSettle(); - } - - tester.printToConsole('Warning: No items loaded and no placeholder shown within expected time'); - } - - Future _processVisibleItems( - List items, - DashboardViewModel dashboardViewModel, - ) async { - try { - int processedCount = 0; - - for (var item in items) { - try { - final keyId = (item.key as ValueKey).value; - - // Check if item is already visible - if (tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole('Processing visible item: $keyId'); - await _verifyItemDisplay(item, dashboardViewModel); - processedCount++; - } - } catch (itemError) { - tester.printToConsole('Error processing item: $itemError'); - continue; - } - } - - tester.printToConsole('Processed $processedCount visible items\n'); - } catch (e) { - tester.printToConsole('Error in visible items processing: $e'); - } - } - - Future _processItemsWithScrolling( - List items, - DashboardViewModel dashboardViewModel, - ) async { - try { - final scrollableFinder = find.descendant( - of: find.byKey(ValueKey('transactions_page_list_view_builder_key')), - matching: find.byType(Scrollable), - ); - - if (!tester.any(scrollableFinder)) { - tester.printToConsole('No scrollable found, skipping scroll processing'); - return; - } - - int processedCount = 0; - const maxScrollAttempts = 10; - - for (var item in items) { - try { - final keyId = (item.key as ValueKey).value; - - // Skip if already processed - if (tester.any(find.byKey(ValueKey(keyId)))) { - continue; - } - - // Try to scroll to the item - bool found = false; - for (int attempt = 0; attempt < maxScrollAttempts; attempt++) { - await tester.pumpAndSettle(Duration(milliseconds: 200)); - - if (tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole('Found item after scrolling: $keyId'); - await _verifyItemDisplay(item, dashboardViewModel); - processedCount++; - found = true; - break; - } - - // Perform scroll - try { - await tester.drag(scrollableFinder, Offset(0, -100)); - await tester.pumpAndSettle(Duration(milliseconds: 300)); - } catch (scrollError) { - tester.printToConsole('Scroll failed: $scrollError'); - break; - } - } - - if (!found) { - tester.printToConsole('Could not find item after scrolling: $keyId'); - } - } catch (itemError) { - tester.printToConsole('Error processing item with scrolling: $itemError'); - continue; - } - } - - tester.printToConsole('Processed $processedCount items with scrolling'); - } catch (e) { - tester.printToConsole('Error in scroll processing: $e'); - } - } - - void _verifyPlaceholder() { - commonTestCases.hasValueKey('transactions_page_placeholder_transactions_text_key'); - commonTestCases.hasText(S.current.placeholder_transactions); - } - - Future _waitForListToBeReady() async { - const maxWaitAttempts = 10; - int attempts = 0; - - while (attempts < maxWaitAttempts) { - await tester.pumpAndSettle(Duration(milliseconds: 500)); - - // Check if the list view is present - final listViewFinder = find.byKey(ValueKey('transactions_page_list_view_builder_key')); - if (tester.any(listViewFinder)) { - tester.printToConsole('List view is ready'); - return; - } - - attempts++; - tester.printToConsole('Waiting for list view to be ready, attempt $attempts\n'); - } - - tester.printToConsole('List view did not become ready within expected time'); - } - - Future _verifyItemDisplay(dynamic item, DashboardViewModel dashboardViewModel) async { - // Execute the proper check depending on item type. - switch (item.runtimeType) { - case TransactionListItem: - final transactionItem = item as TransactionListItem; - tester.printToConsole(transactionItem.formattedTitle); - tester.printToConsole(transactionItem.formattedFiatAmount); - tester.printToConsole('\n'); - await _verifyTransactionListItemDisplay(transactionItem, dashboardViewModel); - break; - - case AnonpayTransactionListItem: - await _verifyAnonpayTransactionListItemDisplay(item as AnonpayTransactionListItem); - break; - - case TradeListItem: - await _verifyTradeListItemDisplay(item as TradeListItem); - break; - - case OrderListItem: - await _verifyOrderListItemDisplay(item as OrderListItem); - break; - - default: - tester.printToConsole('Unhandled item type: ${item.runtimeType}'); - } - } - - Future _verifyTransactionListItemDisplay( - TransactionListItem item, - DashboardViewModel dashboardViewModel, - ) async { - final keyId = - '${dashboardViewModel.type.name}_transaction_history_item_${item.transaction.id}_key'; - - if (!tester.any(find.byKey(ValueKey(keyId)))) { - tester.printToConsole( - 'Could not find transaction item with key: $keyId for transaction ${item.transaction.id}', - ); - return; - } - - if (item.hasTokens && item.assetOfTransaction == null) return; - - try { - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ======Confirm it displays the properly formatted amount========== - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(item.formattedCryptoAmount), - ); - - //* ======Confirm it displays the properly formatted date============ - final formattedDate = DateFormat('HH:mm').format(item.transaction.date); - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(formattedDate), - ); - - //* ======Confirm it displays the properly formatted fiat amount===== - final formattedFiatAmount = - dashboardViewModel.balanceViewModel.isFiatDisabled ? '' : item.formattedFiatAmount; - if (formattedFiatAmount.isNotEmpty) { - commonTestCases.findWidgetViaDescendant( - of: find.byKey(ValueKey(keyId)), - matching: find.text(formattedFiatAmount), - ); - } - - //* ======Confirm it displays the right image based on the transaction direction===== - final imageToUse = item.transaction.direction == TransactionDirection.incoming - ? 'assets/images/down_arrow.png' - : 'assets/images/up_arrow.png'; - - find.widgetWithImage(Container, AssetImage(imageToUse)); - } catch (e) { - tester.printToConsole('Error verifying transaction item ${item.transaction.id}: $e'); - } - } - - Future _verifyAnonpayTransactionListItemDisplay(AnonpayTransactionListItem item) async { - final keyId = 'anonpay_invoice_transaction_list_item_${item.transaction.invoiceId}_key'; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - commonTestCases.hasText(item.transaction.provider); - - //* ===========Confirm it displays the properly formatted amount with currency ======== - final currency = item.transaction.fiatAmount != null - ? item.transaction.fiatEquiv ?? '' - : CryptoCurrency.fromFullName(item.transaction.coinTo).name.toUpperCase(); - - final amount = - item.transaction.fiatAmount?.toString() ?? (item.transaction.amountTo?.toString() ?? ''); - - final amountCurrencyText = amount + ' ' + currency; - - commonTestCases.hasText(amountCurrencyText); - - //* ======Confirm it displays the properly formatted date================= - final formattedDate = DateFormat('HH:mm').format(item.transaction.createdAt); - commonTestCases.hasText(formattedDate); - - //* ===============Confirm it displays the right image==================== - find.widgetWithImage(ClipRRect, AssetImage('assets/images/trocador.png')); - } - - Future _verifyTradeListItemDisplay(TradeListItem item) async { - final keyId = 'trade_list_item_${item.trade.id}_key'; - final from = item.trade.from?.toString() ?? ''; - final to = item.trade.to?.toString() ?? ''; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - final conversionFlow = '$from โ†’ $to'; - - commonTestCases.hasText(conversionFlow); - - //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== - - final amountCryptoText = item.tradeFormattedAmount + ' ' + from; - - commonTestCases.hasText(amountCryptoText); - - //* ======Confirm it displays the properly formatted date================= - final createdAtFormattedDate = - item.trade.createdAt != null ? DateFormat('HH:mm').format(item.trade.createdAt!) : null; - - if (createdAtFormattedDate != null) { - commonTestCases.hasText(createdAtFormattedDate); - } - - //* ===============Confirm it displays the right image==================== - commonTestCases.hasValueKey(item.trade.provider.image); - } - - Future _verifyOrderListItemDisplay(OrderListItem item) async { - final keyId = 'order_list_item_${item.order.id}_key'; - - //* ==============Confirm it has the right key for this item ======== - commonTestCases.hasValueKey(keyId); - - //* ==============Confirm it displays the correct provider ========================= - final orderFlow = '${item.order.from!} โ†’ ${item.order.to}'; - - commonTestCases.hasText(orderFlow); - - //* ===========Confirm it displays the properly formatted amount with its crypto tag ======== - - final amountCryptoText = item.orderFormattedAmount + ' ' + item.order.to!; - - commonTestCases.hasText(amountCryptoText); - - //* ======Confirm it displays the properly formatted date================= - final createdAtFormattedDate = DateFormat('HH:mm').format(item.order.createdAt); - - commonTestCases.hasText(createdAtFormattedDate); - } -} diff --git a/integration_test/robots/wallet_edit_page_robot.dart b/integration_test/robots/wallet_edit_page_robot.dart new file mode 100644 index 000000000..4faaaabe3 --- /dev/null +++ b/integration_test/robots/wallet_edit_page_robot.dart @@ -0,0 +1,50 @@ +import "package:cake_wallet/src/screens/wallet/wallet_edit_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class WalletEditPageRobot extends BaseRobot { + WalletEditPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future enterName(String name) async { + final input = find.byKey(const ValueKey("wallet_edit_page_name_input_key")); + + await pumpUntilFound(input); + + final field = find.descendant(of: input, matching: find.byType(EditableText)); + + await tester.enterText(field.first, name); + + await tester.pump(const Duration(milliseconds: 300)); + } + + Future save() async { + await tapByKey("wallet_edit_page_save_button_key"); + } + + Future tapDelete() async { + await tapByKey("wallet_edit_page_delete_button_key"); + } + + Future confirmDelete() async { + await tapByKey("wallet_edit_page_confirm_delete_button_key"); + } + + Future expectNameTakenRefusal() async { + await pumpUntilFound(find.byKey(const ValueKey("wallet_edit_page_name_taken_ok_button_key"))); + + hasType(); + } + + Future dismissNameTakenRefusal() async { + await tapByKey("wallet_edit_page_name_taken_ok_button_key"); + + await settle(); + } +} diff --git a/integration_test/robots/wallet_group_description_page_robot.dart b/integration_test/robots/wallet_group_description_page_robot.dart index 79c228f94..eff7e158d 100644 --- a/integration_test/robots/wallet_group_description_page_robot.dart +++ b/integration_test/robots/wallet_group_description_page_robot.dart @@ -1,39 +1,25 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/new_wallet/wallet_group_description_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/new_wallet/wallet_group_description_page.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletGroupDescriptionPageRobot { - WalletGroupDescriptionPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletGroupDescriptionPageRobot extends BaseRobot { + WalletGroupDescriptionPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isWalletGroupDescriptionPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_group_description_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } - void hasTitle() { - commonTestCases.hasText(S.current.wallet_group); - } - - bool hasNewSingleSeedButton() { - return commonTestCases.isKeyPresent('wallet_group_description_page_create_new_seed_button_key'); - } + bool hasNewSingleSeedButton() => + isKeyPresent("wallet_group_description_page_create_new_seed_button_key"); Future navigateToCreateNewSeedPage() async { if (hasNewSingleSeedButton()) { - await commonTestCases.tapItemByKey( - 'wallet_group_description_page_create_new_seed_button_key', - ); + await tapByKey("wallet_group_description_page_create_new_seed_button_key"); } } Future navigateToChooseWalletGroup() async { - await commonTestCases.tapItemByKey( - 'wallet_group_description_page_choose_wallet_group_button_key', - ); + await tapByKey("wallet_group_description_page_choose_wallet_group_button_key"); } } diff --git a/integration_test/robots/wallet_group_existing_seed_page_robot.dart b/integration_test/robots/wallet_group_existing_seed_page_robot.dart new file mode 100644 index 000000000..4d28edfb4 --- /dev/null +++ b/integration_test/robots/wallet_group_existing_seed_page_robot.dart @@ -0,0 +1,16 @@ +import "package:cake_wallet/src/screens/new_wallet/wallet_group_existing_seed_description_page.dart"; + +import "../core/base_robot.dart"; + +class WalletGroupExistingSeedPageRobot extends BaseRobot { + WalletGroupExistingSeedPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future openWallet() async { + await tapByKey("wallet_group_existing_seed_description_page_open_wallet_button_key"); + } +} diff --git a/integration_test/robots/wallet_groups_display_page_robot.dart b/integration_test/robots/wallet_groups_display_page_robot.dart new file mode 100644 index 000000000..d87064863 --- /dev/null +++ b/integration_test/robots/wallet_groups_display_page_robot.dart @@ -0,0 +1,21 @@ +import "package:cake_wallet/src/screens/new_wallet/wallet_group_display_page.dart"; +import "package:flutter_test/flutter_test.dart"; + +import "../core/base_robot.dart"; + +class WalletGroupsDisplayPageRobot extends BaseRobot { + WalletGroupsDisplayPageRobot(super.tester); + + @override + Future isDisplayed() async { + await isSpecificPage(); + } + + Future selectWallet(String name) async { + await tapWhenVisible(find.text(name)); + } + + Future continueWithSelectedSeed() async { + await tapByKey("wallet_group_display_page_next_button_key"); + } +} diff --git a/integration_test/robots/wallet_keys_robot.dart b/integration_test/robots/wallet_keys_robot.dart index 6ec9f9df7..35f77fc27 100644 --- a/integration_test/robots/wallet_keys_robot.dart +++ b/integration_test/robots/wallet_keys_robot.dart @@ -1,48 +1,38 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/reactions/wallet_connect.dart'; -import 'package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart'; -import 'package:cake_wallet/store/app_store.dart'; -import 'package:cw_core/monero_wallet_keys.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:cw_monero/monero_wallet.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:polyseed/polyseed.dart'; +import "package:cake_wallet/reactions/wallet_connect.dart"; +import "package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/monero_wallet_keys.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:cw_monero/monero_wallet.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:polyseed/polyseed.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletKeysAndSeedPageRobot { - WalletKeysAndSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletKeysAndSeedPageRobot extends BaseRobot { + WalletKeysAndSeedPageRobot(super.tester); - final WidgetTester tester; - final CommonTestCases commonTestCases; - - Future isWalletKeysAndSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_keys_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } - void hasTitle() { - final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); - final walletKeysViewModel = walletKeysPage.walletKeysViewModel; - commonTestCases.hasText(walletKeysViewModel.title); - } - - void hasShareWarning() { - commonTestCases.hasText(S.current.do_not_share_warning_text.toUpperCase()); - } + int _verifiedCredentials = 0; Future confirmWalletCredentials(WalletType walletType) async { + _verifiedCredentials = 0; + final walletKeysPage = tester.widget(find.byType(WalletKeysPage)); final walletKeysViewModel = walletKeysPage.walletKeysViewModel; final appStore = walletKeysViewModel.appStore; final walletName = walletType.name; - bool hasSeed = appStore.wallet!.seed != null; - bool hasHexSeed = appStore.wallet!.hexSeed != null; - bool hasPrivateKey = appStore.wallet!.privateKey != null; + final hasSeed = appStore.wallet!.seed != null; + final hasHexSeed = appStore.wallet!.hexSeed != null; + final hasPrivateKey = appStore.wallet!.privateKey != null; if (walletType == WalletType.monero) { - final moneroWallet = appStore.wallet as MoneroWalletBase; + final moneroWallet = appStore.wallet! as MoneroWalletBase; final lang = PolyseedLang.getByPhrase(moneroWallet.seed); final legacySeed = moneroWallet.seedLegacy(lang.nameEnglish); @@ -57,51 +47,89 @@ class WalletKeysAndSeedPageRobot { if (walletType == WalletType.bitcoin || walletType == WalletType.litecoin || walletType == WalletType.bitcoinCash) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (isEVMCompatibleChain(walletType) || walletType == WalletType.solana || walletType == WalletType.tron) { if (hasSeed) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasPrivateKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.privateKey!); - tester.printToConsole('$walletName wallet has private key properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.privateKey!, "private key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private key properly displayed"); } } if (walletType == WalletType.nano || walletType == WalletType.banano) { if (hasSeed) { - final seedWords = appStore.wallet!.seed!.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + _showsSeedWords(appStore.wallet!.seed!); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasHexSeed) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.hexSeed!); - tester.printToConsole('$walletName wallet has hexSeed properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.hexSeed!, "hex seed"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has hexSeed properly displayed"); } if (hasPrivateKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(appStore.wallet!.privateKey!); - tester.printToConsole('$walletName wallet has private key properly displayed'); + await _openKeysTab(); + _showsCredentialOnce(appStore.wallet!.privateKey!, "private key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private key properly displayed"); } } - await commonTestCases.defaultSleepTime(seconds: 5); + _expectSomethingWasVerified(walletType); + } + + void _expectSomethingWasVerified(WalletType walletType) { + expect( + _verifiedCredentials, + greaterThan(0), + reason: "Nothing was verified for ${walletType.name}, this suite covers no credential " + "for that type so it cannot tell a working keys page from a broken one", + ); + } + + Future _openKeysTab() async { + await tapByKey("wallet_keys_page_keys"); + + await settle(); + } + + void _showsCredential(String value, String label) { + expect( + tester.any(find.text(value, skipOffstage: false)), + true, + reason: "The keys page did not show the wallet's $label", + ); + } + + void _showsCredentialOnce(String value, String label) { + final rows = tester.widgetList(find.text(value, skipOffstage: false)).length; + + expect( + rows, + 1, + reason: "The keys page showed $rows rows for the wallet's $label, expected one", + ); + } + + void _showsSeedWords(String seed) { + final words = seed.split(" "); + + for (var index = 0; index < words.length; index++) { + _showsCredential(words[index], "seed word ${index + 1}"); + } } Future _confirmMoneroWalletCredentials( @@ -111,68 +139,42 @@ class WalletKeysAndSeedPageRobot { String legacySeed, ) async { final keys = appStore.wallet!.keys as MoneroWalletKeys; - - final hasPublicSpendKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_public_spend_key_item_key', - ); - final hasPrivateSpendKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_private_spend_key_item_key', - ); - final hasPublicViewKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_public_view_key_item_key', - ); - final hasPrivateViewKey = commonTestCases.isKeyPresent( - '${walletName}_wallet_private_view_key_item_key', - ); final hasSeeds = seed.isNotEmpty; final hasSeedLegacy = Polyseed.isValidSeed(seed); - if (hasPublicSpendKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.publicSpendKey); - tester.printToConsole('$walletName wallet has public spend key properly displayed'); - } - if (hasPrivateSpendKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.privateSpendKey); - tester.printToConsole('$walletName wallet has private spend key properly displayed'); - } - if (hasPublicViewKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.publicViewKey); - tester.printToConsole('$walletName wallet has public view key properly displayed'); - } - if (hasPrivateViewKey) { - await commonTestCases.tapItemByKey('wallet_keys_page_keys'); - commonTestCases.hasText(keys.privateViewKey); - tester.printToConsole('$walletName wallet has private view key properly displayed'); - } + await _openKeysTab(); + + _showsCredentialOnce(keys.publicSpendKey, "public spend key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has public spend key properly displayed"); + + _showsCredentialOnce(keys.privateSpendKey, "private spend key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private spend key properly displayed"); + + _showsCredentialOnce(keys.publicViewKey, "public view key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has public view key properly displayed"); + + _showsCredentialOnce(keys.privateViewKey, "private view key"); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has private view key properly displayed"); + if (hasSeeds) { - await commonTestCases.tapItemByKey('wallet_keys_page_seed'); - final seedWords = seed.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has seeds properly displayed'); + await tapByKey("wallet_keys_page_seed"); + await settle(); + + _showsSeedWords(seed); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has seeds properly displayed"); } if (hasSeedLegacy) { - await commonTestCases.tapItemByKey('wallet_keys_page_seed_legacy'); - final seedWords = legacySeed.split(" "); - for (var seedWord in seedWords) { - commonTestCases.hasTextAtLestOnce(seedWord); - } - tester.printToConsole('$walletName wallet has legacy seeds properly displayed'); + await tapByKey("wallet_keys_page_seed_legacy"); + await settle(); + + _showsSeedWords(legacySeed); + _verifiedCredentials++; + tester.printToConsole("$walletName wallet has legacy seeds properly displayed"); } } - - Future backToDashboard() async { - tester.printToConsole('Going back to dashboard from credentials page'); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.goBack(); - await tester.pumpAndSettle(Duration(milliseconds: 1000)); - - await commonTestCases.goBack(); - await tester.pumpAndSettle(Duration(milliseconds: 2000)); - } } diff --git a/integration_test/robots/wallet_list_page_robot.dart b/integration_test/robots/wallet_list_page_robot.dart index 3ce6f0938..598f0b08e 100644 --- a/integration_test/robots/wallet_list_page_robot.dart +++ b/integration_test/robots/wallet_list_page_robot.dart @@ -1,28 +1,48 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/wallet_list/wallet_list_page.dart"; +import "package:flutter/material.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletListPageRobot { - WalletListPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletListPageRobot extends BaseRobot { + WalletListPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; + @override + Future isDisplayed() async { + final shown = await pumpUntil(_isShowing); - Future isWalletListPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_list_page'); + expect(shown, true, reason: "The wallets tab never came to the front"); } - void displaysCorrectTitle() { - commonTestCases.hasText(S.current.wallets); + Future openEditFor(String walletName) async { + await tapByKey("wallet_list_edit_${walletName}_button_key"); } + bool hasWallet(String walletName) => tester.any( + find.descendant( + of: find.byType(WalletListPage), + matching: find.text(walletName), + ), + ); + Future navigateToCreateNewWalletPage() async { - await commonTestCases.tapItemByKey('wallet_list_page_create_new_wallet_button_key'); + await tapByKey("wallet_list_page_create_new_wallet_button_key"); } Future navigateToRestoreWalletOptionsPage() async { - await commonTestCases.tapItemByKey('wallet_list_page_restore_wallet_button_key'); + await tapByKey("wallet_list_page_restore_wallet_button_key"); + } + + bool _isShowing() { + final stackFinder = find.byType(IndexedStack); + + if (!tester.any(stackFinder)) { + return false; + } + + final stack = tester.widget(stackFinder.first); + final index = stack.children.indexWhere((child) => child is WalletListPage); + + return index >= 0 && stack.index == index; } } diff --git a/integration_test/robots/wallet_seed_page_robot.dart b/integration_test/robots/wallet_seed_page_robot.dart index 7bcbe6afe..4697f3624 100644 --- a/integration_test/robots/wallet_seed_page_robot.dart +++ b/integration_test/robots/wallet_seed_page_robot.dart @@ -1,38 +1,19 @@ -import 'package:cake_wallet/generated/i18n.dart'; -import 'package:cake_wallet/src/screens/seed/wallet_seed_page.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/generated/i18n.dart"; +import "package:cake_wallet/src/screens/seed/wallet_seed_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WalletSeedPageRobot { - WalletSeedPageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WalletSeedPageRobot extends BaseRobot { + WalletSeedPageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isWalletSeedPage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('wallet_seed_page'); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future onVerifySeedButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_verify_seed_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onSaveSeedButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onConfirmButtonOnSeedAlertDialogPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_confirm_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onBackButtonOnSeedAlertDialogPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_seed_alert_back_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_seed_page_verify_seed_button_key"); } void confirmWalletDetailsDisplayCorrectly() { @@ -42,23 +23,18 @@ class WalletSeedPageRobot { final walletName = walletSeedViewModel.name; final walletSeeds = walletSeedViewModel.seedSplit; - commonTestCases.hasText(walletName); - for (var seed in walletSeeds) { - commonTestCases.hasTextAtLestOnce(seed); + hasText(walletName); + + for (var index = 0; index < walletSeeds.length; index++) { + expect( + tester.any(find.text(walletSeeds[index])), + true, + reason: "The seed page did not show seed word ${index + 1}", + ); } } void confirmWalletSeedReminderDisplays() { - commonTestCases.hasText(S.current.cake_seeds_save_disclaimer); - } - - Future onSaveSeedsButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_save_seeds_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future onCopySeedsButtonPressed() async { - await commonTestCases.tapItemByKey('wallet_seed_page_copy_seeds_button_key'); - await commonTestCases.defaultSleepTime(); + hasText(S.current.cake_seeds_save_disclaimer); } } diff --git a/integration_test/robots/welcome_page_robot.dart b/integration_test/robots/welcome_page_robot.dart index 56a54f38e..59428d54a 100644 --- a/integration_test/robots/welcome_page_robot.dart +++ b/integration_test/robots/welcome_page_robot.dart @@ -1,50 +1,28 @@ -import 'package:cake_wallet/src/screens/welcome/welcome_page.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; +import "package:cake_wallet/src/screens/welcome/welcome_page.dart"; +import "package:flutter_test/flutter_test.dart"; -import '../components/common_test_cases.dart'; +import "../core/base_robot.dart"; -class WelcomePageRobot { - WelcomePageRobot(this.tester) : commonTestCases = CommonTestCases(tester); +class WelcomePageRobot extends BaseRobot { + WelcomePageRobot(super.tester); - final WidgetTester tester; - late CommonTestCases commonTestCases; - - Future isWelcomePage() async { - await commonTestCases.isSpecificPage(); - await commonTestCases.takeScreenshots('welcome_page'); - } - - void confirmActionButtonsDisplay() { - final createNewWalletButton = find.byKey(ValueKey('welcome_page_create_new_wallet_button_key')); - - final restoreWalletButton = find.byKey(ValueKey('welcome_page_restore_wallet_button_key')); - - expect(createNewWalletButton, findsOneWidget); - expect(restoreWalletButton, findsOneWidget); + @override + Future isDisplayed() async { + await isSpecificPage(); } Future navigateToCreateNewWalletPage() async { - await commonTestCases.tapItemByKey('welcome_page_create_new_wallet_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("welcome_page_create_new_wallet_button_key"); } - bool hasNewSingleSeedButton() { - return commonTestCases.isKeyPresent('wallet_group_description_page_create_new_seed_button_key'); - } + bool hasNewSingleSeedButton() => + isKeyPresent("wallet_group_description_page_create_new_seed_button_key"); Future tapNewSingleSeed() async { - await commonTestCases.tapItemByKey('wallet_group_description_page_create_new_seed_button_key'); - await commonTestCases.defaultSleepTime(); + await tapByKey("wallet_group_description_page_create_new_seed_button_key"); } Future navigateToRestoreWalletPage() async { - await commonTestCases.tapItemByKey('welcome_page_restore_wallet_button_key'); - await commonTestCases.defaultSleepTime(); - } - - Future backAndVerify() async { - await commonTestCases.goBack(); - await isWelcomePage(); + await tapByKey("welcome_page_restore_wallet_button_key"); } } diff --git a/integration_test/suites/tier0/address_book_test.dart b/integration_test/suites/tier0/address_book_test.dart new file mode 100644 index 000000000..b45841384 --- /dev/null +++ b/integration_test/suites/tier0/address_book_test.dart @@ -0,0 +1,46 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/contact_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A saved contact keeps the address it was given", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final contactRobot = ContactRobot(tester); + + const contactName = "Integration Test Contact"; + final contactAddress = TestWallets.receiveAddressFor(WalletType.solana); + + await appLauncher.launchApp(testKey: "address_book_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await dashboardRobot.openContactsTab(); + await contactRobot.isDisplayed(); + + await contactRobot.openAddContact(); + + await contactRobot.enterName(contactName); + await contactRobot.chooseCurrency("sol"); + await contactRobot.enterAddress(contactAddress); + + await contactRobot.save(); + + await contactRobot.isDisplayed(); + + contactRobot.expectContactSaved(contactName, contactAddress); + }); +} diff --git a/integration_test/suites/tier0/change_pin_test.dart b/integration_test/suites/tier0/change_pin_test.dart new file mode 100644 index 000000000..fb04b3612 --- /dev/null +++ b/integration_test/suites/tier0/change_pin_test.dart @@ -0,0 +1,73 @@ +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/security_backup_page_robot.dart"; +import "../../robots/setup_pin_code_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Changing the pin replaces the one that unlocks the wallet", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final securityRobot = SecurityBackupPageRobot(tester); + final setupPinRobot = SetupPinCodeRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + final newPin = [1, 2, 3, 4]; + + expect(newPin, isNot(TestConfig.pin), reason: "The new pin has to differ from the old one"); + + await appLauncher.launchApp(testKey: "change_pin_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.securityBackupPage); + await securityRobot.isDisplayed(); + + await securityRobot.openChangePin(); + await authFlows.authenticateWithPin(); + + await setupPinRobot.isDisplayed(); + await setupPinRobot.enterPinCode(newPin); + await setupPinRobot.enterPinCode(newPin); + await setupPinRobot.tapSuccessButton(); + + await securityRobot.isDisplayed(); + + await securityRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + await authFlows.authenticateWithPin(pin: newPin); + + await walletKeysRobot.isDisplayed(); + + expect(find.byType(WalletKeysPage), findsOneWidget); + }); +} diff --git a/integration_test/suites/tier0/duplicate_wallet_name_test.dart b/integration_test/suites/tier0/duplicate_wallet_name_test.dart new file mode 100644 index 000000000..0e194ee55 --- /dev/null +++ b/integration_test/suites/tier0/duplicate_wallet_name_test.dart @@ -0,0 +1,42 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_wallet_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet name already in use is refused", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final newWalletPageRobot = NewWalletPageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "duplicate_wallet_name_test_app_key"); + + await onboardingFlows.createFirstWallet(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final existingName = getIt.get().wallet!.name; + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.startCreatingWalletFromWalletList(walletType); + + await newWalletPageRobot.enterWalletName(existingName); + await newWalletPageRobot.onNextButtonPressed(); + + await newWalletPageRobot.expectNameRejected(); + }); +} diff --git a/integration_test/suites/tier0/fiat_currency_test.dart b/integration_test/suites/tier0/fiat_currency_test.dart new file mode 100644 index 000000000..92e4a0940 --- /dev/null +++ b/integration_test/suites/tier0/fiat_currency_test.dart @@ -0,0 +1,107 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/entities/fiat_api_mode.dart"; +import "package:cake_wallet/entities/fiat_currency.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/store/settings_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/connection_sync_page_robot.dart"; +import "../../robots/display_settings_page_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Turning the fiat api off hides the currency setting, turning it on returns it", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final connectionRobot = ConnectionSyncPageRobot(tester); + final displayRobot = DisplaySettingsPageRobot(tester); + + const wanted = FiatCurrency.eur; + + await appLauncher.launchApp(testKey: "fiat_currency_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final settingsStore = getIt.get(); + + expect( + settingsStore.fiatCurrency, + isNot(wanted), + reason: "The wallet already starts on $wanted, so changing to it would prove nothing", + ); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + if (settingsStore.fiatApiMode != FiatApiMode.disabled) { + await settingsRobot.openRow(Routes.connectionSync); + await connectionRobot.isDisplayed(); + + await connectionRobot.setFiatApiMode(FiatApiMode.disabled); + connectionRobot.hasFiatApiMode(FiatApiMode.disabled); + + expect(settingsStore.fiatApiMode, FiatApiMode.disabled); + + await connectionRobot.goBack(); + await settingsRobot.isDisplayed(); + } + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + expect( + displayRobot.hasFiatCurrencyRow(), + false, + reason: "The currency setting is still showing while the fiat api is off", + ); + + await displayRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.connectionSync); + await connectionRobot.isDisplayed(); + + await connectionRobot.setFiatApiMode(FiatApiMode.enabled); + connectionRobot.hasFiatApiMode(FiatApiMode.enabled); + + expect(settingsStore.fiatApiMode, FiatApiMode.enabled); + + await connectionRobot.goBack(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + expect( + displayRobot.hasFiatCurrencyRow(), + true, + reason: "The currency setting did not come back after the fiat api was turned on", + ); + + await displayRobot.openFiatCurrencyPicker(); + await displayRobot.chooseFiatCurrency(wanted); + + displayRobot.expectFiatCurrencyShown(wanted); + + expect( + settingsStore.fiatCurrency, + wanted, + reason: "The row shows ${wanted.title} but the setting behind it did not change", + ); + }); +} diff --git a/integration_test/suites/tier0/invalid_seed_test.dart b/integration_test/suites/tier0/invalid_seed_test.dart new file mode 100644 index 000000000..63ac5b659 --- /dev/null +++ b/integration_test/suites/tier0/invalid_seed_test.dart @@ -0,0 +1,51 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/restore_from_seed_or_key_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A seed the wallet cannot parse restores nothing", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final restoreRobot = RestoreFromSeedOrKeysPageRobot(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "invalid_seed_test_app_key"); + + await onboardingFlows.startRestoringFirstWallet(walletType); + + // Twelve words that are not in the wordlist. Restoring from this would either fail + // outright or, worse it derives some other wallet the user has no keys for. + await restoreRobot.enterSeedPhraseForWalletRestore( + "alpha bravo charlie delta echo foxtrot golf hotel india juliet kilo lima", + ); + + await restoreRobot.onRestoreWalletButtonPressed(); + + await restoreRobot.expectRestoreRefused(); + + // The same form with the real seed goes through. Without this the test above would pass + // just as happily if the form were broken and restored nothing for any seed at all. + await restoreRobot.selectWalletNameFromAvailableOptions(); + await restoreRobot.enterSeedPhraseForWalletRestore(TestWallets.seedFor(walletType)); + await restoreRobot.onRestoreWalletButtonPressed(); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(getIt.get().wallet?.type, walletType); + }); +} diff --git a/integration_test/suites/tier0/language_test.dart b/integration_test/suites/tier0/language_test.dart new file mode 100644 index 000000000..c38783988 --- /dev/null +++ b/integration_test/suites/tier0/language_test.dart @@ -0,0 +1,46 @@ +import "package:cake_wallet/routes.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/display_settings_page_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Changing the language changes what the settings screen shows", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final displayRobot = DisplaySettingsPageRobot(tester); + + // Not the default, so seeing it afterwards means the change took rather than the screen + // having always said so. + const languageCode = "de"; + const languageName = "Deutsch (German)"; + + await appLauncher.launchApp(testKey: "language_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.displaySettingsPage); + await displayRobot.isDisplayed(); + + await displayRobot.openLanguagePicker(); + await displayRobot.chooseLanguage(languageCode); + + displayRobot.expectLanguageShown(languageName); + }); +} diff --git a/integration_test/suites/tier0/onboarding_create_test.dart b/integration_test/suites/tier0/onboarding_create_test.dart new file mode 100644 index 000000000..7dc1886b1 --- /dev/null +++ b/integration_test/suites/tier0/onboarding_create_test.dart @@ -0,0 +1,53 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Create a wallet for each configured wallet type", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "onboarding_create_test_app_key"); + + final walletTypes = TestConfig.walletTypesUnderTest; + final firstType = walletTypes.first; + + tester.printToConsole("Creating first wallet: ${firstType.name}"); + + await onboardingFlows.createFirstWallet(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, firstType); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + for (final type in walletTypes.skip(1)) { + tester.printToConsole("Creating additional wallet: ${type.name}"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.createAdditionalWalletFromWalletList(type); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(appStore.wallet?.type, type); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + } + }); +} diff --git a/integration_test/suites/tier0/onboarding_restore_test.dart b/integration_test/suites/tier0/onboarding_restore_test.dart new file mode 100644 index 000000000..ac5cf1110 --- /dev/null +++ b/integration_test/suites/tier0/onboarding_restore_test.dart @@ -0,0 +1,94 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_config.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Restore a wallet from seed for each configured wallet type", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "onboarding_restore_test_app_key"); + + final walletTypes = TestConfig.walletTypesUnderTest; + final firstType = walletTypes.first; + + tester.printToConsole("Restoring first wallet: ${firstType.name}"); + + await onboardingFlows.restoreFirstWalletFromSeed(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + expect(appStore.wallet?.type, firstType); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + await _expectRestoredTheRightWallet(homePageRobot, appStore, firstType); + + for (final type in walletTypes.skip(1)) { + tester.printToConsole("Restoring additional wallet: ${type.name}"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.restoreAdditionalWalletFromWalletList(type); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(appStore.wallet?.type, type); + + await homePageRobot.hasWalletName(appStore.wallet!.name); + + await _expectRestoredTheRightWallet(homePageRobot, appStore, type); + } + }); +} + +Future _expectRestoredTheRightWallet( + HomePageRobot robot, + AppStore appStore, + WalletType type, +) async { + final expectedAddress = TestWallets.receiveAddressFor(type); + + if (expectedAddress.isEmpty) { + robot.tester.printToConsole("No known address for ${type.name}, derivation not verified"); + return; + } + + final settled = await robot.pumpUntil( + () { + final addresses = appStore.wallet?.walletAddresses; + + if (addresses == null) { + return false; + } + + return addresses.address == expectedAddress || + addresses.addressesMap.containsKey(expectedAddress) || + addresses.allAddressesMap.containsKey(expectedAddress); + }, + timeout: const Duration(seconds: 60), + ); + + expect( + settled, + true, + reason: "${type.name} restored from a known seed derived " + "${appStore.wallet?.walletAddresses.address} and does not know $expectedAddress", + ); +} diff --git a/integration_test/suites/tier0/receive_address_test.dart b/integration_test/suites/tier0/receive_address_test.dart new file mode 100644 index 000000000..ee781d2eb --- /dev/null +++ b/integration_test/suites/tier0/receive_address_test.dart @@ -0,0 +1,43 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_receive_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Receive sheet shows the opened wallet's own address", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final receiveRobot = NewReceivePageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "receive_address_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final expectedAddress = getIt.get().wallet!.walletAddresses.address; + expect(expectedAddress.isNotEmpty, true); + + await homePageRobot.openReceiveSheet(); + + await receiveRobot.isDisplayed(); + await receiveRobot.confirmAddressMatches(expectedAddress); + + await receiveRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/seed_confirmation_test.dart b/integration_test/suites/tier0/seed_confirmation_test.dart new file mode 100644 index 000000000..fbe840e24 --- /dev/null +++ b/integration_test/suites/tier0/seed_confirmation_test.dart @@ -0,0 +1,70 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Displayed seed and keys match the opened wallet", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + // These three cover the different key display paths on the wallet keys page. + final walletTypes = [WalletType.solana, WalletType.bitcoin, WalletType.monero]; + + await appLauncher.launchApp(testKey: "seed_confirmation_test_app_key"); + + await onboardingFlows.createFirstWallet(walletTypes.first); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + + for (final type in walletTypes) { + if (type != walletTypes.first) { + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(type); + await homePageRobot.isDisplayed(); + } + + expect(appStore.wallet?.type, type); + + tester.printToConsole("Confirming displayed credentials for ${type.name}"); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + await authFlows.authenticateWithPin(); + + await walletKeysRobot.isDisplayed(); + await walletKeysRobot.confirmWalletCredentials(type); + + await settingsRobot.goBack(); + await settingsRobot.dismissModal(); + + await homePageRobot.isDisplayed(); + } + }); +} diff --git a/integration_test/suites/tier0/send_from_address_book_test.dart b/integration_test/suites/tier0/send_from_address_book_test.dart new file mode 100644 index 000000000..24779f4f2 --- /dev/null +++ b/integration_test/suites/tier0/send_from_address_book_test.dart @@ -0,0 +1,59 @@ +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/contact_robot.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A contact picked on the send screen fills in its own address", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final contactRobot = ContactRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + const contactName = "Send Book Contact"; + final contactAddress = TestWallets.receiveAddressFor(WalletType.solana); + + await appLauncher.launchApp(testKey: "send_from_address_book_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await dashboardRobot.openContactsTab(); + await contactRobot.isDisplayed(); + + await contactRobot.openAddContact(); + await contactRobot.enterName(contactName); + await contactRobot.chooseCurrency("sol"); + await contactRobot.enterAddress(contactAddress); + await contactRobot.save(); + + await contactRobot.isDisplayed(); + + await dashboardRobot.openHomeTab(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.pickContactFromAddressBook(contactName); + + expect( + sendRobot.enteredAddress(), + contactAddress, + reason: "The send screen was filled with an address the contact does not hold", + ); + }); +} diff --git a/integration_test/suites/tier0/send_validation_test.dart b/integration_test/suites/tier0/send_validation_test.dart new file mode 100644 index 000000000..1f347e0ec --- /dev/null +++ b/integration_test/suites/tier0/send_validation_test.dart @@ -0,0 +1,53 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_send_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Send screen refuses what it cannot send", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final sendRobot = NewSendPageRobot(tester); + + const walletType = WalletType.solana; + + await appLauncher.launchApp(testKey: "send_validation_test_app_key"); + + await onboardingFlows.createFirstWallet(walletType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSendSheet(); + await sendRobot.isDisplayed(); + + await sendRobot.tapSendButtonWhenReady(timeout: const Duration(minutes: 5)); + await sendRobot.expectNoTransactionBuilt(); + + // test that no transaction is built when the address is wrong or cannot be parsed + await sendRobot.enterAddress("not-a-real-address"); + await sendRobot.enterAmount("0.0001"); + await sendRobot.tapSendButton(); + + await sendRobot.expectNoTransactionBuilt(); + + // test that no transaction is built even if the address is valid but no balance + await sendRobot.enterAddress(TestWallets.receiveAddressFor(walletType)); + await sendRobot.enterAmount("0.0001"); + await sendRobot.tapSendButton(); + + await sendRobot.expectNoTransactionBuilt(); + + await sendRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/settings_nav_test.dart b/integration_test/suites/tier0/settings_nav_test.dart new file mode 100644 index 000000000..b599c9992 --- /dev/null +++ b/integration_test/suites/tier0/settings_nav_test.dart @@ -0,0 +1,61 @@ +import "package:cake_wallet/new-ui/pages/about_page.dart"; +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/settings/connection_sync_page.dart"; +import "package:cake_wallet/src/screens/settings/display_settings_page.dart"; +import "package:cake_wallet/src/screens/settings/manage_nodes_page.dart"; +import "package:cake_wallet/src/screens/settings/other_settings_page.dart"; +import "package:cake_wallet/src/screens/settings/privacy_page.dart"; +import "package:cake_wallet/src/screens/settings/security_backup_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Every settings row opens its page and backs out", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + + final rowsUnderTest = { + Routes.manageNodes: ManageNodesPage, + Routes.privacyPage: PrivacyPage, + Routes.otherSettingsPage: OtherSettingsPage, + Routes.connectionSync: ConnectionSyncPage, + Routes.displaySettingsPage: DisplaySettingsPage, + Routes.securityBackupPage: SecurityBackupPage, + Routes.aboutPage: AboutPage, + }; + + await appLauncher.launchApp(testKey: "settings_nav_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + for (final entry in rowsUnderTest.entries) { + tester.printToConsole("Opening settings row ${entry.key}"); + + await settingsRobot.openRow(entry.key); + await settingsRobot.confirmLeafPageDisplayed(entry.value); + + await settingsRobot.goBack(); + await settingsRobot.isDisplayed(); + } + + await settingsRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/show_keys_auth_test.dart b/integration_test/suites/tier0/show_keys_auth_test.dart new file mode 100644 index 000000000..6ee6aa2e4 --- /dev/null +++ b/integration_test/suites/tier0/show_keys_auth_test.dart @@ -0,0 +1,61 @@ +import "package:cake_wallet/routes.dart"; +import "package:cake_wallet/src/screens/wallet_keys/wallet_keys_page.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; +import "../../robots/show_keys_disclaimer_page_robot.dart"; +import "../../robots/wallet_keys_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Seed and keys page rejects a wrong pin and accepts the right one", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final walletKeysRobot = WalletKeysAndSeedPageRobot(tester); + final disclaimerRobot = ShowKeysDisclaimerPageRobot(tester); + + final wrongPin = [9, 9, 9, 9]; + + await appLauncher.launchApp(testKey: "show_keys_auth_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.showKeysDisclaimer); + await disclaimerRobot.isDisplayed(); + await disclaimerRobot.tapShowKeys(); + + await authFlows.authenticateWithPin(pin: wrongPin); + + // A wrong pin must keep the keys page locked away. + await settingsRobot.settle(max: const Duration(seconds: 3)); + expect(find.byType(WalletKeysPage), findsNothing); + expect(authFlows.isPinWidgetShown(), true); + + await authFlows.authenticateWithPin(); + + await walletKeysRobot.isDisplayed(); + + await settingsRobot.goBack(); + await settingsRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier0/wallet_delete_test.dart b/integration_test/suites/tier0/wallet_delete_test.dart new file mode 100644 index 000000000..183e6a035 --- /dev/null +++ b/integration_test/suites/tier0/wallet_delete_test.dart @@ -0,0 +1,85 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/auth_flows.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/wallet_edit_page_robot.dart"; +import "../../robots/wallet_list_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Deleting a wallet takes it off the list and out of storage", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final authFlows = AuthFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final walletListRobot = WalletListPageRobot(tester); + final walletEditRobot = WalletEditPageRobot(tester); + + await appLauncher.launchApp(testKey: "wallet_delete_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final deletedWallet = appStore.wallet!.name; + + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(WalletType.ethereum); + + await homePageRobot.isDisplayed(); + + final keptWallet = appStore.wallet!.name; + + expect(keptWallet, isNot(deletedWallet), reason: "The second wallet never became the open one"); + + await dashboardRobot.openWalletsTab(); + await walletListRobot.isDisplayed(); + + await walletListRobot.openEditFor(deletedWallet); + await walletEditRobot.isDisplayed(); + + await walletEditRobot.tapDelete(); + await authFlows.authenticateWithPin(required: false); + + await walletEditRobot.confirmDelete(); + + await walletListRobot.isDisplayed(); + + final removed = await walletListRobot.pumpUntil( + () => !walletListRobot.hasWallet(deletedWallet), + timeout: const Duration(minutes: 1), + ); + + expect(removed, true, reason: "$deletedWallet is still on the list after being deleted"); + + expect( + walletListRobot.hasWallet(keptWallet), + true, + reason: "Deleting $deletedWallet took $keptWallet with it", + ); + + final stored = await WalletInfo.getAll(); + final names = stored.map((wallet) => wallet.name).toList(); + + expect(names, isNot(contains(deletedWallet))); + expect(names, contains(keptWallet)); + + expect( + appStore.wallet?.name, + keptWallet, + reason: "Deleting another wallet changed which one is open", + ); + }); +} diff --git a/integration_test/suites/tier0/wallet_group_test.dart b/integration_test/suites/tier0/wallet_group_test.dart new file mode 100644 index 000000000..903d724da --- /dev/null +++ b/integration_test/suites/tier0/wallet_group_test.dart @@ -0,0 +1,70 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../flows/wallet_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet added to an existing seed shares it and both stay reachable", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final walletFlows = WalletFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + const firstType = WalletType.solana; + const secondType = WalletType.ethereum; + + await appLauncher.launchApp(testKey: "wallet_group_test_app_key"); + + await onboardingFlows.createFirstWallet(firstType); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final firstWalletName = appStore.wallet!.name; + final sharedSeed = appStore.wallet!.seed; + + expect(sharedSeed, isNotNull, reason: "The first wallet has no seed to share"); + + await dashboardRobot.openWalletsTab(); + + await onboardingFlows.createWalletInGroupOf(secondType, firstWalletName); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final secondWalletName = appStore.wallet!.name; + + expect( + secondWalletName, + isNot(firstWalletName), + reason: "Adding to a group opened the wallet that was already there", + ); + + expect(appStore.wallet?.type, secondType); + + expect( + appStore.wallet!.seed, + sharedSeed, + reason: "The wallet added to the group derived its own seed instead of sharing one", + ); + + await walletFlows.switchToWallet(firstWalletName); + + await homePageRobot.isDisplayed(); + await homePageRobot.hasWalletName(firstWalletName); + + expect(appStore.wallet?.seed, sharedSeed); + }); +} diff --git a/integration_test/suites/tier0/wallet_rename_test.dart b/integration_test/suites/tier0/wallet_rename_test.dart new file mode 100644 index 000000000..5163a0ca3 --- /dev/null +++ b/integration_test/suites/tier0/wallet_rename_test.dart @@ -0,0 +1,86 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_info.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/wallet_edit_page_robot.dart"; +import "../../robots/wallet_list_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A wallet can be renamed, but not onto a name already in use", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final walletListRobot = WalletListPageRobot(tester); + final walletEditRobot = WalletEditPageRobot(tester); + + const renamedTo = "Renamed Test Wallet"; + + await appLauncher.launchApp(testKey: "wallet_rename_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + final renamedWallet = appStore.wallet!.name; + + await dashboardRobot.openWalletsTab(); + await onboardingFlows.createAdditionalWalletFromWalletList(WalletType.ethereum); + + await homePageRobot.isDisplayed(); + + final openWallet = appStore.wallet!.name; + + expect(openWallet, isNot(renamedWallet), reason: "The second wallet never became the open one"); + + await dashboardRobot.openWalletsTab(); + await walletListRobot.isDisplayed(); + + await walletListRobot.openEditFor(renamedWallet); + await walletEditRobot.isDisplayed(); + + await walletEditRobot.enterName(openWallet); + await walletEditRobot.save(); + + await walletEditRobot.expectNameTakenRefusal(); + await walletEditRobot.dismissNameTakenRefusal(); + + await walletEditRobot.enterName(renamedTo); + await walletEditRobot.save(); + + await walletListRobot.isDisplayed(); + + final renamed = await walletListRobot.pumpUntil(() => walletListRobot.hasWallet(renamedTo)); + + expect(renamed, true, reason: "The list never showed $renamedTo after the rename was saved"); + + expect( + walletListRobot.hasWallet(renamedWallet), + false, + reason: "The list still shows $renamedWallet next to the name it was renamed to", + ); + + final stored = await WalletInfo.getAll(); + final names = stored.map((wallet) => wallet.name).toList(); + + expect(names, contains(renamedTo)); + expect(names, isNot(contains(renamedWallet))); + + expect( + appStore.wallet?.name, + openWallet, + reason: "Renaming another wallet changed which one is open", + ); + }); +} diff --git a/integration_test/suites/tier1/monero_legacy_seed_test.dart b/integration_test/suites/tier1/monero_legacy_seed_test.dart new file mode 100644 index 000000000..89155f875 --- /dev/null +++ b/integration_test/suites/tier1/monero_legacy_seed_test.dart @@ -0,0 +1,47 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../core/test_wallets.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("A 25 word monero seed restores through the legacy path", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + final legacySeed = TestWallets.moneroLegacySeed; + + expect( + legacySeed.split(" ").length, + 25, + reason: "The legacy seed secret is not a 25 word seed, this suite would restore a polyseed", + ); + + await appLauncher.launchApp(testKey: "monero_legacy_seed_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.monero, seed: legacySeed); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + final appStore = getIt.get(); + + expect(appStore.wallet?.type, WalletType.monero); + + expect( + appStore.wallet?.seed?.split(" ").length, + 25, + reason: "The wallet came back with a seed of a different length than the one restored", + ); + }); +} diff --git a/integration_test/suites/tier1/node_switching_test.dart b/integration_test/suites/tier1/node_switching_test.dart new file mode 100644 index 000000000..9ff1878df --- /dev/null +++ b/integration_test/suites/tier1/node_switching_test.dart @@ -0,0 +1,49 @@ +import "package:cake_wallet/routes.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/manage_nodes_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_settings_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Switching nodes changes which one the wallet is pointed at", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final settingsRobot = NewSettingsPageRobot(tester); + final nodesRobot = ManageNodesPageRobot(tester); + + await appLauncher.launchApp(testKey: "node_switching_test_app_key"); + + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSettingsSheet(); + await settingsRobot.isDisplayed(); + + await settingsRobot.openRow(Routes.manageNodes); + await nodesRobot.isDisplayed(); + + final initialNodeUri = nodesRobot.currentNodeUri(); + + expect(initialNodeUri, isNotEmpty, reason: "The wallet started with no node at all"); + + await nodesRobot.switchToAnotherNode(); + + expect( + nodesRobot.currentNodeUri(), + isNot(initialNodeUri), + reason: "The node was confirmed but the wallet is still pointed at $initialNodeUri", + ); + }); +} diff --git a/integration_test/suites/tier1/swap_quote_test.dart b/integration_test/suites/tier1/swap_quote_test.dart new file mode 100644 index 000000000..d7aa90a80 --- /dev/null +++ b/integration_test/suites/tier1/swap_quote_test.dart @@ -0,0 +1,37 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; +import "../../robots/new_swap_page_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Swap sheet fetches a provider quote, no trade is created", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + final swapRobot = NewSwapPageRobot(tester); + + await appLauncher.launchApp(testKey: "swap_quote_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.openSwapSheet(); + await swapRobot.isDisplayed(); + + await swapRobot.enterDepositAmount("1"); + + await swapRobot.confirmQuoteReceived(); + + await swapRobot.dismissModal(); + await homePageRobot.isDisplayed(); + }); +} diff --git a/integration_test/suites/tier1/sync_status_test.dart b/integration_test/suites/tier1/sync_status_test.dart new file mode 100644 index 000000000..902e91f40 --- /dev/null +++ b/integration_test/suites/tier1/sync_status_test.dart @@ -0,0 +1,27 @@ +import "package:cw_core/wallet_type.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Restored wallet connects to a node and starts syncing", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "sync_status_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.bitcoin); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.confirmSyncIndicatorShown(); + }); +} diff --git a/integration_test/suites/tier1/transaction_details_test.dart b/integration_test/suites/tier1/transaction_details_test.dart new file mode 100644 index 000000000..317793319 --- /dev/null +++ b/integration_test/suites/tier1/transaction_details_test.dart @@ -0,0 +1,47 @@ +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Tapping a transaction opens the details of that transaction", (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "transaction_details_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + await homePageRobot.confirmTransactionHistoryVisible(); + + await homePageRobot.openAllTransactions(); + await homePageRobot.confirmAllTransactionsVisible(); + + final tapped = homePageRobot.firstTransactionIdInAllView(); + + expect(tapped, isNotEmpty, reason: "The tile carries no transaction id to check against"); + + await homePageRobot.openFirstTransactionDetails(); + + // A list that hands the wrong transaction to the details screen shows one person's + // amounts under another's id, and nothing on the screen would say so. + expect( + homePageRobot.openedTransactionId(), + tapped, + reason: "The details opened for a different transaction than the one tapped", + ); + + homePageRobot.hasTransactionIdRow(); + }); +} diff --git a/integration_test/suites/tier1/transaction_history_test.dart b/integration_test/suites/tier1/transaction_history_test.dart new file mode 100644 index 000000000..db97cd105 --- /dev/null +++ b/integration_test/suites/tier1/transaction_history_test.dart @@ -0,0 +1,37 @@ +import "package:cake_wallet/di.dart"; +import "package:cake_wallet/store/app_store.dart"; +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("Transaction history renders for a wallet with known transactions", + (tester) async { + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + await appLauncher.launchApp(testKey: "transaction_history_test_app_key"); + + await onboardingFlows.restoreFirstWalletFromSeed(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + expect(getIt.get().wallet?.type, WalletType.solana); + + await homePageRobot.confirmTransactionHistoryVisible(); + + await homePageRobot.openAllTransactions(); + + await homePageRobot.confirmAllTransactionsVisible(); + }); +} diff --git a/integration_test/templates/example_feature_test.dart.example b/integration_test/templates/example_feature_test.dart.example new file mode 100644 index 000000000..84799cbce --- /dev/null +++ b/integration_test/templates/example_feature_test.dart.example @@ -0,0 +1,55 @@ +// Copy this file into integration_test/suites/tier0/ or tier1/, rename it to +// _test.dart and replace the example steps with your journey. +// The runner only picks up files ending in _test.dart, this template never runs. + +import "package:cw_core/wallet_type.dart"; +import "package:flutter_test/flutter_test.dart"; +import "package:integration_test/integration_test.dart"; + +import "../../core/app_launcher.dart"; +import "../../flows/onboarding_flows.dart"; +import "../../robots/home_page_robot.dart"; +import "../../robots/new_dashboard_robot.dart"; + +void main() { + // Required first line in every suite, integrationTest contains background app errors + // that testWidgets would turn into failures. + IntegrationTestWidgetsFlutterBinding.ensureInitialized(); + + integrationTest("One sentence describing the journey under test", (tester) async { + // Robots and flows take the tester, create the ones your journey needs. + final appLauncher = AppLauncher(tester); + final onboardingFlows = OnboardingFlows(tester); + final dashboardRobot = NewDashboardRobot(tester); + final homePageRobot = HomePageRobot(tester); + + // Boots the real app, the key names the suite in failure output. + await appLauncher.launchApp(testKey: "example_feature_test_app_key"); + + // Every suite starts from a fresh install, so get to a working wallet first. + // Use restoreFirstWalletFromSeed instead when your feature needs a known wallet. + await onboardingFlows.createFirstWallet(WalletType.solana); + + await dashboardRobot.isDisplayed(); + await homePageRobot.isDisplayed(); + + // Drive your feature through its robot from here. Robots own the finders and + // assertions for their screen, the suite only sequences them: + // + // await homePageRobot.openSettingsSheet(); + // await mySettingsRobot.isDisplayed(); + // await mySettingsRobot.doTheThing(); + // + // Rules of thumb: + // - Wait with pumpUntilFound and pumpUntil, never with sleeps or pumpAndSettle. + // - Tap and read widgets by ValueKey, add keys to lib/ widgets when they are missing. + // - Assert app state through getIt (AppStore) or the page widget's view model. + // - A widget being in the tree does not mean it is on screen. Every dashboard tab + // stays mounted in an IndexedStack, so find.byType on a tab passes from any other + // tab. Check what is in front, the way WalletListPageRobot does. + // - A button being findable does not mean it is enabled. Tapping a disabled button + // does nothing at all and reads later as a missing widget somewhere else, so wait + // on whatever the screen itself gates on. + // - Add the finished suite to the table in docs/INTEGRATION_TESTS.md. + }); +} diff --git a/integration_test/test_suites/confirm_seeds_flow_test.dart b/integration_test/test_suites/confirm_seeds_flow_test.dart deleted file mode 100644 index 121f0315a..000000000 --- a/integration_test/test_suites/confirm_seeds_flow_test.dart +++ /dev/null @@ -1,122 +0,0 @@ -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/foundation.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/auth_page_robot.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/security_and_backup_page_robot.dart'; -import '../robots/wallet_keys_robot.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - AuthPageRobot authPageRobot; - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot; - SecurityAndBackupPageRobot securityAndBackupPageRobot; - - testWidgets( - 'Confirm if the seeds display properly', - (tester) async { - // Store the original FlutterError.onError handler - final originalOnError = FlutterError.onError; - - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - try { - authPageRobot = AuthPageRobot(tester); - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - walletKeysAndSeedPageRobot = WalletKeysAndSeedPageRobot(tester); - securityAndBackupPageRobot = SecurityAndBackupPageRobot(tester); - - await commonTestFlows.startAppFlow( - ValueKey('confirm_creds_display_correctly_flow_app_key'), - ); - - await commonTestFlows.welcomePageToCreateNewWalletFlow( - WalletType.solana, - CommonTestConstants.pin, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - await _confirmSeedsFlowForWalletType( - WalletType.solana, - authPageRobot, - dashboardPageRobot, - securityAndBackupPageRobot, - walletKeysAndSeedPageRobot, - tester, - ); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(isDrawer: true); - - await commonTestFlows.createNewWalletFromWalletMenu(walletType); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - - await _confirmSeedsFlowForWalletType( - walletType, - authPageRobot, - dashboardPageRobot, - securityAndBackupPageRobot, - walletKeysAndSeedPageRobot, - tester, - ); - } - - await Future.delayed(Duration(seconds: 15)); - } finally { - FlutterError.onError = originalOnError; - } - }, - ); -} - -Future _confirmSeedsFlowForWalletType( - WalletType walletType, - AuthPageRobot authPageRobot, - DashboardPageRobot dashboardPageRobot, - SecurityAndBackupPageRobot securityAndBackupPageRobot, - WalletKeysAndSeedPageRobot walletKeysAndSeedPageRobot, - WidgetTester tester, -) async { - await dashboardPageRobot.openDrawerMenu(); - await dashboardPageRobot.dashboardMenuWidgetRobot.navigateToSecurityAndBackupPage(); - - await securityAndBackupPageRobot.navigateToShowKeysPage(); - - final onAuthPage = authPageRobot.onAuthPage(); - if (onAuthPage) { - await authPageRobot.enterPinCode(CommonTestConstants.pin); - } - - final onAuthPageDesktop = authPageRobot.onAuthPageDesktop(); - if (onAuthPageDesktop) { - await authPageRobot.enterPassword(CommonTestConstants.pin.join("")); - } - await tester.pumpAndSettle(); - - await walletKeysAndSeedPageRobot.isWalletKeysAndSeedPage(); - walletKeysAndSeedPageRobot.hasTitle(); - walletKeysAndSeedPageRobot.hasShareWarning(); - - await walletKeysAndSeedPageRobot.confirmWalletCredentials(walletType); - - await walletKeysAndSeedPageRobot.backToDashboard(); -} diff --git a/integration_test/test_suites/create_wallet_flow_test.dart b/integration_test/test_suites/create_wallet_flow_test.dart deleted file mode 100644 index 2b290dcd3..000000000 --- a/integration_test/test_suites/create_wallet_flow_test.dart +++ /dev/null @@ -1,60 +0,0 @@ -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - - testWidgets( - 'Create Wallet Flow', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - - // Start the app - await commonTestFlows.startAppFlow( - ValueKey('create_wallets_through_seeds_test_app_key'), - ); - - await commonTestFlows.welcomePageToCreateNewWalletFlow( - WalletType.solana, - CommonTestConstants.pin, - ); - - // Confirm it actually restores a solana wallet - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.createNewWalletFromWalletMenu(walletType); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - } - - // Goes to the wallet menu and provides a confirmation that all the wallets were correctly restored - await dashboardPageRobot.navigateToWalletsListPage(); - - commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); - - await Future.delayed(Duration(seconds: 5)); - }, - ); -} diff --git a/integration_test/test_suites/exchange_flow_test.dart b/integration_test/test_suites/exchange_flow_test.dart deleted file mode 100644 index 0b876eff1..000000000 --- a/integration_test/test_suites/exchange_flow_test.dart +++ /dev/null @@ -1,65 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/exchange_confirm_page_robot.dart'; -import '../robots/exchange_page_robot.dart'; -import '../robots/exchange_trade_external_send_page_robot.dart'; -import '../robots/exchange_trade_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - ExchangePageRobot exchangePageRobot; - DashboardPageRobot dashboardPageRobot; - ExchangeTradePageRobot exchangeTradePageRobot; - ExchangeConfirmPageRobot exchangeConfirmPageRobot; - ExchangeTradeExternalSendPageRobot exchangeTradeExternalSendPageRobot; - - testWidgets('Exchange flow', (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - exchangePageRobot = ExchangePageRobot(tester); - dashboardPageRobot = DashboardPageRobot(tester); - exchangeTradePageRobot = ExchangeTradePageRobot(tester); - exchangeConfirmPageRobot = ExchangeConfirmPageRobot(tester); - exchangeTradeExternalSendPageRobot = ExchangeTradeExternalSendPageRobot(tester); - - await commonTestFlows.startAppFlow(ValueKey('exchange_app_test_key')); - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - CommonTestConstants.testWalletType, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - await dashboardPageRobot.navigateToExchangePage(); - - // ----------- Exchange Page ------------- - await exchangePageRobot.selectDepositCurrency(CommonTestConstants.exchangeTestDepositCurrency); - await exchangePageRobot.selectReceiveCurrency(CommonTestConstants.exchangeTestReceiveCurrency); - - await exchangePageRobot.enterDepositAmount(CommonTestConstants.exchangeTestAmount); - await exchangePageRobot.enterDepositRefundAddress( - depositAddress: CommonTestConstants.testWalletAddress, - ); - await exchangePageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await exchangePageRobot.onExchangeButtonPressed(); - await exchangePageRobot.handleErrors(CommonTestConstants.exchangeTestAmount); - - await exchangeConfirmPageRobot.onSavedTradeIdButtonPressed(); - - await exchangeTradePageRobot.onGotItButtonPressed(); - await exchangeTradePageRobot.onSendFromExternalButtonPressed(); - - await exchangeTradeExternalSendPageRobot.isExchangeTradeExternalSendPage(); - await exchangeTradeExternalSendPageRobot.verifySendDetailsItemsDisplayProperly(); - await exchangeTradeExternalSendPageRobot.onContinueButtonPressed(); - }); -} diff --git a/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart b/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart deleted file mode 100644 index 40957681c..000000000 --- a/integration_test/test_suites/restore_wallet_through_seeds_flow_test.dart +++ /dev/null @@ -1,75 +0,0 @@ -import 'package:cake_wallet/wallet_types.g.dart'; -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - - testWidgets( - 'Restoring Wallets Through Seeds', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - - // Start the app - await commonTestFlows.startAppFlow( - ValueKey('restore_wallets_through_seeds_test_app_key'), - ); - - // Restore the first wallet type: Solana - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - WalletType.solana, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - // Confirm it actually restores a solana wallet - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - // Do the same for other available wallet types - for (var walletType in availableWalletTypes) { - if (walletType == WalletType.solana) { - continue; - } - final seed = commonTestFlows.getWalletSeedsByWalletType(walletType); - if (seed.isEmpty) { - tester.printToConsole("----------------------------"); - tester.printToConsole("- Skipped wallet: ${walletType}"); - tester.printToConsole("- Make sure to add seed to secrets"); - tester.printToConsole("----------------------------"); - continue; - } - - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.restoreWalletFromWalletMenu( - walletType, - seed, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(walletType); - } - - // Goes to the wallet menu and provides a visual confirmation that all the wallets were correctly restored - await dashboardPageRobot.navigateToWalletsListPage(); - - commonTestFlows.confirmAllAvailableWalletTypeIconsDisplayCorrectly(); - - await Future.delayed(Duration(seconds: 5)); - }, - ); -} diff --git a/integration_test/test_suites/send_flow_test.dart b/integration_test/test_suites/send_flow_test.dart deleted file mode 100644 index 237de73bc..000000000 --- a/integration_test/test_suites/send_flow_test.dart +++ /dev/null @@ -1,133 +0,0 @@ -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; -import 'package:cw_core/wallet_type.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../components/common_test_cases.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/send_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - SendPageRobot sendPageRobot; - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - CommonTestCases commonTestCases; - - testWidgets('Send flow with wallet switching for insufficient balance - testing \$1 send', - (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - sendPageRobot = SendPageRobot(tester: tester); - dashboardPageRobot = DashboardPageRobot(tester); - commonTestCases = CommonTestCases(tester); - - await commonTestFlows.startAppFlow(ValueKey('send_test_app_key')); - - // Various wallet options we want to use to test the send flow - final walletConfigs = [ - { - 'type': WalletType.solana, - 'seed': secrets.solanaTestWalletSeeds2, - 'name': 'Solana Wallet 1', - }, - { - 'type': WalletType.solana, - 'seed': secrets.solanaTestWalletSeeds, - 'name': 'Solana Wallet 2', - }, - { - 'type': WalletType.ethereum, - 'seed': secrets.ethereumTestWalletSeeds, - 'name': 'Ethereum Wallet', - }, - ]; - - bool hasSufficientBalance = false; - WalletType? successfulWalletType; - - // We try each wallet configuration until we find one with sufficient balance - for (int i = 0; i < walletConfigs.length; i++) { - final config = walletConfigs[i]; - - if ((config['seed'] as String).isEmpty) { - tester.printToConsole('Skipping ${config['name']}, seed is empty'); - continue; - } - - tester.printToConsole('Trying ${config['name']}'); - - // For first wallet, we restore wallet afresh, for subsequent ones, we do a wallet switch - if (i == 0) { - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - config['type'] as WalletType, - config['seed'] as String, - CommonTestConstants.pin, - ); - } else { - await commonTestCases.goBack(); - await commonTestCases.defaultSleepTime(); - - await dashboardPageRobot.navigateToWalletsListPage(); - await commonTestCases.defaultSleepTime(); - - await commonTestFlows.restoreWalletFromWalletMenu( - config['type'] as WalletType, - config['seed'] as String, - ); - } - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(config['type'] as WalletType); - - // Navigate to send page - await dashboardPageRobot.navigateToSendPage(); - await sendPageRobot.checkIfSendPageIsVisible(); - - await sendPageRobot.enterReceiveAddress(CommonTestConstants.testWalletAddress); - await sendPageRobot.selectReceiveCurrency(CommonTestConstants.sendTestReceiveCurrency); - - await sendPageRobot.selectTransactionPriority(); - - // Main check to see if this wallet has sufficient balance for $1 send - final hasBalance = await sendPageRobot.validateWalletBalanceForOneDollarSend(); - if (hasBalance) { - hasSufficientBalance = true; - successfulWalletType = config['type'] as WalletType; - tester.printToConsole( - '${config['name']} has sufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send, proceeding with send', - ); - break; - } else { - tester.printToConsole( - '${config['name']} has insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - if (i < walletConfigs.length - 1) { - tester.printToConsole('Switching to next wallet...'); - } - } - } - - // We only proceed with the send if we have sufficient balance - if (hasSufficientBalance && successfulWalletType != null) { - tester.printToConsole('Performing send transaction with $successfulWalletType wallet'); - - await sendPageRobot.testFiatAmountEntry(); - await sendPageRobot.testCryptoAmountEntry(); - - await sendPageRobot.onSendButtonPressed(); - await sendPageRobot.onSendSliderOnConfirmSendingBottomSheetDragged(); - await sendPageRobot.handleTransactionSuccessFlow(); - } else { - tester.printToConsole( - 'Test completed without sending - all available wallets have insufficient balance for \$${CommonTestConstants.sendTestFiatAmount} send', - ); - } - }); -} diff --git a/integration_test/test_suites/transaction_history_flow_test.dart b/integration_test/test_suites/transaction_history_flow_test.dart deleted file mode 100644 index c019ce6c4..000000000 --- a/integration_test/test_suites/transaction_history_flow_test.dart +++ /dev/null @@ -1,82 +0,0 @@ -import 'package:cw_core/wallet_type.dart'; -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; -import 'package:integration_test/integration_test.dart'; - -import '../components/common_test_constants.dart'; -import '../components/common_test_flows.dart'; -import '../robots/dashboard_page_robot.dart'; -import '../robots/transactions_page_robot.dart'; -import 'package:cake_wallet/.secrets.g.dart' as secrets; - -void main() { - IntegrationTestWidgetsFlutterBinding.ensureInitialized(); - - CommonTestFlows commonTestFlows; - DashboardPageRobot dashboardPageRobot; - TransactionsPageRobot transactionsPageRobot; - - /// Two Test Scenarios - /// - Fully Synchronizes and display the transaction history either immediately or few seconds after fully synchronizing - /// - Displays the transaction history progressively as synchronizing happens - testWidgets('Transaction history flow', (tester) async { - FlutterError.onError = (FlutterErrorDetails details) { - debugPrint('FlutterError caught: ${details.exception}'); - }; - - commonTestFlows = CommonTestFlows(tester); - dashboardPageRobot = DashboardPageRobot(tester); - transactionsPageRobot = TransactionsPageRobot(tester); - - await commonTestFlows.startAppFlow( - ValueKey('confirm_creds_display_correctly_flow_app_key'), - ); - - /// Test Scenario 1 - Displays transaction history list while synchronizing. - /// - /// For Solana/Tron WalletTypes. - await commonTestFlows.welcomePageToRestoreWalletThroughSeedsFlow( - WalletType.solana, - secrets.solanaTestWalletSeeds, - CommonTestConstants.pin, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.solana); - - await dashboardPageRobot.swipeDashboardTab(true); - - await transactionsPageRobot.isTransactionsPage(); - - await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); - - // Wait time for the first scenario to ensure proper loading - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(true); - - /// Test Scenario 2 - Displays transaction history list after fully synchronizing. - /// - /// For Bitcoin/Monero/Wownero WalletTypes. - await dashboardPageRobot.navigateToWalletsListPage(); - - await commonTestFlows.restoreWalletFromWalletMenu( - WalletType.bitcoin, - secrets.bitcoinTestWalletSeeds, - ); - - await dashboardPageRobot.confirmWalletTypeIsDisplayedCorrectly(WalletType.bitcoin); - - await dashboardPageRobot.swipeDashboardTab(true); - - await transactionsPageRobot.isTransactionsPage(); - - await transactionsPageRobot.confirmTransactionsPageConstantsDisplayProperly(); - - // Wait time for the second scenario to ensure proper loading - await tester.pump(Duration(seconds: 3)); - await tester.pumpAndSettle(); - - await transactionsPageRobot.confirmTransactionHistoryListDisplaysCorrectly(false); - }); -} diff --git a/integration_test_runner.sh b/integration_test_runner.sh index de21b3edc..c38f07667 100755 --- a/integration_test_runner.sh +++ b/integration_test_runner.sh @@ -1,38 +1,65 @@ #!/bin/bash -# Exit on any error set -euo pipefail -# Configurations -export DESKTOP_FORCE_MOBILE="Y" -MAX_INACTIVITY=${MAX_INACTIVITY:-180} -MAX_PARALLEL_TESTS=${MAX_PARALLEL_TESTS:-2} -TEST_TIMEOUT=${TEST_TIMEOUT:-600} -RETRY_COUNT=${RETRY_COUNT:-1} +# Runner params/knobs, all overridable from the environment: +# SUITE_DIR directory to collect *_test.dart suites from, or a single suite file +# (default integration_test/suites) +# TEST_TIER tier0, tier1 or all, narrows SUITE_DIR to a tier subdirectory +# PLATFORM android, linux, macos or auto, picks the between-suite data reset +# ANDROID_APP_ID package cleared between suites on android (default read from android/app.properties) +# PREBUILT_APK prebuilt apk passed to flutter drive, its dart defines are already baked in +# EXTRA_DART_DEFINES semicolon separated KEY=VALUE pairs +# FLUTTER_DEVICE device id passed to flutter drive, needed when several devices are attached +# TEST_TIMEOUT per-attempt timeout in seconds (default 900) +# RETRY_COUNT retries per suite after a failure (default 1) +# MAX_VOID_ATTEMPTS extra attempts when the driver never attached (default 2) +# MAX_TOTAL_VOID_ATTEMPTS give up on the whole run after this many, the environment is broken (default 4) +# REMOVE_DATA_DIRECTORY set to N to keep app data between suites (default wipes it) +# REMOVE_DESKTOP_DATA set to Y to let a desktop run wipe the wallet directories below, +# they belong to the installed wallet so this is off by default +# SUMMARY_FILE path to write a key=value summary of the run to, for reporting +# VOID_GRACE seconds to let a driver that cannot attach recover before the +# attempt is ended early (default 60) -# Data directories to clean +SUITE_DIR=${SUITE_DIR:-integration_test/suites} +TEST_TIER=${TEST_TIER:-all} +PLATFORM=${PLATFORM:-auto} +TEST_TIMEOUT=${TEST_TIMEOUT:-900} +RETRY_COUNT=${RETRY_COUNT:-1} +MAX_VOID_ATTEMPTS=${MAX_VOID_ATTEMPTS:-2} +MAX_TOTAL_VOID_ATTEMPTS=${MAX_TOTAL_VOID_ATTEMPTS:-4} +REMOVE_DATA_DIRECTORY=${REMOVE_DATA_DIRECTORY:-Y} +REMOVE_DESKTOP_DATA=${REMOVE_DESKTOP_DATA:-N} +EXTRA_DART_DEFINES=${EXTRA_DART_DEFINES:-} +SUMMARY_FILE=${SUMMARY_FILE:-} +VOID_GRACE=${VOID_GRACE:-60} + +# Using this to capture as many of these annoying flutter drive errors as possible, +# so we can handle them, and the test doesn't just hang forever +VOID_MARKERS="unusually long time to connect to the VM|taking unusually long time to initialize|Service has disappeared|Flutter Driver extension is taking a long time to become available" +PREBUILT_APK=${PREBUILT_APK:-} + +# Desktop data directories, shared with the installed wallet, see REMOVE_DESKTOP_DATA DATA_DIRS=( "$HOME/.local/share/com.example.cake_wallet" "$HOME/Documents/cake_wallet" "$HOME/.config/cake_wallet" ) -# Global state -declare -a targets -declare -a passed_tests -declare -a failed_tests -declare -a test_durations -declare -a test_start_times +targets=() +passed_tests=() +failed_tests=() +funds_results=() +total_void_attempts=0 -# Signal handling cleanup() { - echo "๐Ÿ›‘ Received interrupt signal, cleaning up..." + echo "Received interrupt signal, cleaning up..." exit 1 } trap cleanup SIGINT SIGTERM -# Utility functions log() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" } @@ -41,7 +68,6 @@ error() { echo "[$(date '+%Y-%m-%d %H:%M:%S')] ERROR: $1" >&2 } -# Format seconds into hours, minutes, seconds format_duration() { local seconds=$1 local hours=$((seconds / 3600)) @@ -57,101 +83,356 @@ format_duration() { fi } -clean_data_directories() { - if [[ "${REMOVE_DATA_DIRECTORY:-}" == "Y" ]]; then - log "Cleaning data directories..." - for dir in "${DATA_DIRS[@]}"; do - if [[ -d "$dir" ]]; then - rm -rf "$dir" || error "Failed to remove $dir" - fi - done +resolve_platform() { + if [[ "$PLATFORM" != "auto" ]]; then + return + fi + + if command -v adb > /dev/null 2>&1 && adb devices 2>/dev/null | grep -q "device$"; then + PLATFORM="android" + elif [[ "$(uname -s)" == "Darwin" ]]; then + PLATFORM="macos" + else + PLATFORM="linux" + fi + + log "Resolved platform: $PLATFORM" +} + +resolve_android_app_id() { + if [[ -n "${ANDROID_APP_ID:-}" ]]; then + return + fi + + if [[ -f "android/app.properties" ]]; then + ANDROID_APP_ID=$(grep '^id=' android/app.properties | cut -d'=' -f2) + fi + + ANDROID_APP_ID=${ANDROID_APP_ID:-com.cakewallet.cake_wallet} +} + +restart_adb() { + if [[ "$PLATFORM" != "android" ]]; then + return + fi + + bounded 30 adb kill-server + bounded 30 adb start-server + bounded 60 adb wait-for-device + + if ! bounded 30 adb devices | grep -q "device$"; then + error "No device is attached after restarting adb" + return 1 fi } -# Run a single test with retry logic +bounded() { + local limit="$1" + shift + + if command -v timeout > /dev/null 2>&1; then + timeout "$limit" "$@" 2>/dev/null || return 1 + elif command -v perl > /dev/null 2>&1; then + perl -e 'alarm shift; exec @ARGV' "$limit" "$@" 2>/dev/null || return 1 + else + "$@" 2>/dev/null || return 1 + fi +} + +capture_failure_logcat() { + local test_name="$1" + + if [[ "$PLATFORM" != "android" ]]; then + return + fi + + echo "===== logcat after failed attempt: $test_name =====" + bounded 60 adb logcat -d -t 400 || echo "logcat unavailable" + echo "===== end logcat =====" +} + +clean_data_directories() { + if [[ "$REMOVE_DATA_DIRECTORY" != "Y" ]]; then + return + fi + + if [[ "$PLATFORM" == "android" ]]; then + if ! command -v adb > /dev/null 2>&1; then + error "adb is not on PATH, every suite would run on whatever data is already installed" + exit 1 + fi + + if [[ "$ANDROID_APP_ID" != *".test_"* ]]; then + error "Refusing to clear $ANDROID_APP_ID, it is not a test build. Rename the app first: printf 'id=com.cakewallet.test_local\\nname=local\\n' > android/app.properties" + exit 1 + fi + + log "Cleaning app data..." + adb shell pm clear "$ANDROID_APP_ID" > /dev/null 2>&1 || log "pm clear skipped, $ANDROID_APP_ID not installed yet" + return + fi + + if [[ "$REMOVE_DESKTOP_DATA" != "Y" ]]; then + log "Keeping the desktop wallet data, set REMOVE_DESKTOP_DATA=Y to wipe it between suites" + return + fi + + log "Cleaning app data..." + + for dir in "${DATA_DIRS[@]}"; do + if [[ -d "$dir" ]]; then + rm -rf "$dir" || error "Failed to remove $dir" + fi + done +} + +build_drive_command() { + drive_command=(flutter drive --driver=test_driver/integration_test.dart --dart-define=CI_BUILD=true) + + if [[ -n "${FLUTTER_DEVICE:-}" ]]; then + drive_command+=("-d" "$FLUTTER_DEVICE") + fi + + if [[ -n "$PREBUILT_APK" ]]; then + drive_command+=("--use-application-binary=$PREBUILT_APK") + fi + + if [[ -n "$EXTRA_DART_DEFINES" ]]; then + IFS=';' read -ra defines <<< "$EXTRA_DART_DEFINES" + for define in "${defines[@]}"; do + drive_command+=("--dart-define=$define") + done + fi + + if command -v timeout > /dev/null 2>&1; then + drive_command=(timeout "$TEST_TIMEOUT" "${drive_command[@]}") + elif command -v perl > /dev/null 2>&1; then + drive_command=(perl -e 'alarm shift; exec @ARGV' "$TEST_TIMEOUT" "${drive_command[@]}") + fi +} + +end_process_tree() { + local root="$1" signal="$2" + local child + + for child in $(pgrep -P "$root" 2>/dev/null); do + end_process_tree "$child" "$signal" + done + + kill "-$signal" "$root" 2>/dev/null || true +} + +watch_for_dead_driver() { + local log_file="$1" drive_pid="$2" + local marker_seen=0 + + while kill -0 "$drive_pid" 2>/dev/null; do + if (( marker_seen == 0 )) && grep -qE "$VOID_MARKERS" "$log_file" 2>/dev/null; then + marker_seen=$(date +%s) + log "Driver is struggling to attach, giving it ${VOID_GRACE}s to recover" + fi + + if (( marker_seen > 0 )) && (( $(date +%s) - marker_seen >= VOID_GRACE )); then + log "Driver never attached within ${VOID_GRACE}s, ending this attempt early" + + end_process_tree "$drive_pid" TERM + sleep 5 + end_process_tree "$drive_pid" KILL + + return 0 + fi + + sleep 5 + done +} + +collect_funds_results() { + local log_file="$1" + local line + + while IFS= read -r line; do + funds_results+=("$line") + done < <(sed -n 's/.*FUNDS_RESULT|//p' "$log_file" | sort -u) +} + run_test() { local test_file="$1" local test_name=$(basename "$test_file" .dart) local retry_count=0 + local void_attempts=0 while (( retry_count <= RETRY_COUNT )); do log "Running test: $test_name (attempt $((retry_count + 1)))" - # Clean data directories before each attempt clean_data_directories - # Record start time local start_time=$(date +%s) - test_start_times+=("$start_time") + local attempt_log + attempt_log=$(mktemp) - # Run the test in foreground - if flutter drive \ - --driver=test_driver/integration_test.dart \ - --target="$test_file" \ - --dart-define=CI_BUILD=true; then - # Calculate duration - local end_time=$(date +%s) - local duration=$((end_time - start_time)) - test_durations+=("$duration") + "${drive_command[@]}" --target="$test_file" > >(tee "$attempt_log") 2>&1 & + local drive_pid=$! - log "โœ… Test passed: $test_name ($(format_duration $duration))" - passed_tests+=("$test_name") + watch_for_dead_driver "$attempt_log" "$drive_pid" & + local watchdog_pid=$! + disown "$watchdog_pid" 2>/dev/null || true + + local attempt_status=0 + wait "$drive_pid" 2>/dev/null || attempt_status=$? + + kill "$watchdog_pid" 2>/dev/null || true + + if (( attempt_status == 0 )) && ! grep -q "All tests passed." "$attempt_log"; then + attempt_status=1 + fi + + if (( attempt_status == 0 )); then + local duration=$(( $(date +%s) - start_time )) + + log "PASS: $test_name ($(format_duration $duration))" + passed_tests+=("$test_name|$duration") + collect_funds_results "$attempt_log" + rm -f "$attempt_log" return 0 else - # Calculate duration even for failed tests - local end_time=$(date +%s) - local duration=$((end_time - start_time)) - test_durations+=("$duration") + local duration=$(( $(date +%s) - start_time )) - log "โŒ Test failed: $test_name ($(format_duration $duration))" + local attempt_was_void=N + if grep -qE "$VOID_MARKERS" "$attempt_log"; then + attempt_was_void=Y + fi - if (( retry_count < RETRY_COUNT )); then + collect_funds_results "$attempt_log" + + rm -f "$attempt_log" + + log "FAIL: $test_name ($(format_duration $duration))" + + capture_failure_logcat "$test_name" + + if [[ "$attempt_was_void" == "Y" ]]; then + total_void_attempts=$((total_void_attempts + 1)) + + if (( total_void_attempts > MAX_TOTAL_VOID_ATTEMPTS )); then + error "The flutter driver failed to attach $total_void_attempts times, the emulator or adb is broken" + failed_tests+=("$test_name|$duration|void") + return 1 + fi + fi + + if [[ "$attempt_was_void" == "Y" ]] && (( void_attempts < MAX_VOID_ATTEMPTS )); then + log "Driver never attached to the app, retrying without spending a retry" + void_attempts=$((void_attempts + 1)) + + if ! restart_adb; then + error "Could not bring adb back, aborting this suite" + failed_tests+=("$test_name|$duration|void") + return 1 + fi + + sleep 5 + elif (( retry_count < RETRY_COUNT )); then log "Retrying test: $test_name" retry_count=$((retry_count + 1)) - sleep 5 # Brief pause before retry + sleep 5 else - failed_tests+=("$test_name") + local fail_reason="test" + [[ "$attempt_was_void" == "Y" ]] && fail_reason="void" + + failed_tests+=("$test_name|$duration|$fail_reason") return 1 fi fi done } -# Main execution flow +write_github_summary() { + if [[ -z "${GITHUB_STEP_SUMMARY:-}" ]]; then + return + fi + + { + echo "### Integration tests ($SUITE_DIR, tier: $TEST_TIER)" + echo "" + echo "| Suite | Result | Duration |" + echo "|---|---|---|" + for entry in "${passed_tests[@]+"${passed_tests[@]}"}"; do + echo "| ${entry%%|*} | pass | $(format_duration "${entry##*|}") |" + done + for entry in "${failed_tests[@]+"${failed_tests[@]}"}"; do + local rest="${entry#*|}" + echo "| ${entry%%|*} | ${rest#*|} | $(format_duration "${rest%%|*}") |" + done + } >> "$GITHUB_STEP_SUMMARY" +} + +write_summary_file() { + if [[ -z "$SUMMARY_FILE" ]]; then + return + fi + + { + echo "tier=$TEST_TIER" + echo "total=${#targets[@]}" + echo "passed=${#passed_tests[@]}" + echo "failed=${#failed_tests[@]}" + echo "duration=$1" + for entry in "${passed_tests[@]+"${passed_tests[@]}"}"; do + echo "pass=$entry" + done + for entry in "${failed_tests[@]+"${failed_tests[@]}"}"; do + echo "fail=$entry" + done + for entry in "${funds_results[@]+"${funds_results[@]}"}"; do + echo "chain=$entry" + done + } > "$SUMMARY_FILE" +} + main() { log "Starting integration test runner" - log "Configuration: RETRY_COUNT=${RETRY_COUNT}" - # Collect all Dart test files from test_suites directory - while IFS= read -r -d $'\0' file; do - targets+=("$file") - done < <(find integration_test/test_suites -name "*.dart" -type f -print0) + local search_dir="$SUITE_DIR" + if [[ "$TEST_TIER" != "all" ]]; then + search_dir="$SUITE_DIR/$TEST_TIER" + fi - if [[ $? -ne 0 ]]; then - error "Failed to find test files" + if [[ -f "$SUITE_DIR" ]]; then + search_dir="$SUITE_DIR" + elif [[ ! -d "$search_dir" ]]; then + error "Suite directory not found: $search_dir" exit 1 fi + resolve_platform + resolve_android_app_id + build_drive_command + + log "Configuration: SUITE_DIR=$search_dir PLATFORM=$PLATFORM RETRY_COUNT=$RETRY_COUNT" + + if [[ -f "$search_dir" ]]; then + targets+=("$search_dir") + else + while IFS= read -r -d $'\0' file; do + targets+=("$file") + done < <(find "$search_dir" -name "*_test.dart" -type f -print0 | sort -z) + fi + if (( ${#targets[@]} == 0 )); then - error "No test files found in integration_test/test_suites directory" + error "No test files found in $search_dir" exit 1 fi log "Found ${#targets[@]} test files" - # Record overall start time local overall_start_time=$(date +%s) - # Run tests sequentially for target in "${targets[@]}"; do - run_test "$target" + run_test "$target" || true done - # Calculate total duration - local overall_end_time=$(date +%s) - local total_duration=$((overall_end_time - overall_start_time)) + local total_duration=$(( $(date +%s) - overall_start_time )) - # Generate summary echo -e "\n===== Test Summary =====" echo "Total tests: ${#targets[@]}" echo "Passed: ${#passed_tests[@]}" @@ -159,26 +440,26 @@ main() { echo "Total duration: $(format_duration $total_duration)" if (( ${#passed_tests[@]} > 0 )); then - echo -e "\nโœ… Passed Tests:" - for i in $(seq 0 $((${#passed_tests[@]} - 1))); do - local test_name="${passed_tests[$i]}" - local duration="${test_durations[$i]}" - echo " - $test_name ($(format_duration $duration))" + echo -e "\nPassed Tests:" + for entry in "${passed_tests[@]}"; do + echo " - ${entry%%|*} ($(format_duration "${entry##*|}"))" done fi + write_github_summary + write_summary_file "$total_duration" + if (( ${#failed_tests[@]} > 0 )); then - echo -e "\nโŒ Failed Tests:" - for i in $(seq 0 $((${#failed_tests[@]} - 1))); do - local test_name="${failed_tests[$i]}" - local duration="${test_durations[$i]}" - echo " - $test_name ($(format_duration $duration))" + echo -e "\nFailed Tests:" + local rest + for entry in "${failed_tests[@]}"; do + rest="${entry#*|}" + echo " - ${entry%%|*} ($(format_duration "${rest%%|*}"), ${rest#*|})" done exit 1 - else - echo -e "\n๐ŸŽ‰ All tests passed successfully!" fi + + echo -e "\nAll tests passed successfully!" } -# Run main function main "$@" diff --git a/lib/new-ui/new_dashboard.dart b/lib/new-ui/new_dashboard.dart index 898d00829..eadf0d222 100644 --- a/lib/new-ui/new_dashboard.dart +++ b/lib/new-ui/new_dashboard.dart @@ -71,6 +71,7 @@ class _NewDashboardState extends State { bottomSheetService: widget.bottomSheetService, child: CupertinoScaffold( body: Material( + key: ValueKey("new_dashboard_page_key"), child: Stack( alignment: Alignment.bottomCenter, children: [ diff --git a/lib/new-ui/pages/lightning_username_page.dart b/lib/new-ui/pages/lightning_username_page.dart index c42c19f0b..9ab8a5c6a 100644 --- a/lib/new-ui/pages/lightning_username_page.dart +++ b/lib/new-ui/pages/lightning_username_page.dart @@ -112,6 +112,7 @@ class _LightningUsernamePageState extends State { ), SizedBox(), NewPrimaryButton( + key: ValueKey("lightning_username_page_skip_button_key"), onPressed: () { Navigator.of(context).popUntil((route) => route.isFirst); }, diff --git a/lib/new-ui/pages/send_page.dart b/lib/new-ui/pages/send_page.dart index a6ab1625a..e78b5d247 100644 --- a/lib/new-ui/pages/send_page.dart +++ b/lib/new-ui/pages/send_page.dart @@ -464,6 +464,7 @@ class _NewSendPageState extends State { child: Text(S.of(context).address_or_alias), ), NewSendAddressInput( + key: ValueKey("send_page_address_input_key"), displayName: output.displayName, validator: output.isParsedAddress ? widget.sendViewModel.textValidator @@ -534,6 +535,7 @@ class _NewSendPageState extends State { // test/new-ui/widgets/send_page/send_amount_input_test.dart. Text(S.of(context).amount), NewSendAmountInput( + key: ValueKey("send_page_amount_input_key"), validator: output.sendAll ? widget.sendViewModel.allAmountValidator : widget.sendViewModel.amountValidator(output), diff --git a/lib/new-ui/pages/settings_page.dart b/lib/new-ui/pages/settings_page.dart index 313c5b86c..a2615cae7 100644 --- a/lib/new-ui/pages/settings_page.dart +++ b/lib/new-ui/pages/settings_page.dart @@ -146,7 +146,7 @@ class SettingsMainPage extends StatelessWidget { List buildItems(SettingsSectionData section) => section.items .map((item) => item.condition(dashboardViewModel) ? ListItemRegularRow( - keyValue: item.title, + keyValue: item.route, label: item.title, iconPath: item.iconPath, onTap: () { diff --git a/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart b/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart index 52082f0e9..c83e40fc5 100644 --- a/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart +++ b/lib/new-ui/widgets/coins_page/action_row/coin_action_row.dart @@ -44,6 +44,7 @@ class CoinActionRow extends StatelessWidget { spacing: MediaQuery.of(context).size.width * 0.05, children: [ CoinActionButton( + key: ValueKey("home_page_send_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/send.svg", colorFilter: ColorFilter.mode( @@ -76,6 +77,7 @@ class CoinActionRow extends StatelessWidget { }, ), CoinActionButton( + key: ValueKey("home_page_receive_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/receive.svg", colorFilter: ColorFilter.mode( @@ -97,6 +99,7 @@ class CoinActionRow extends StatelessWidget { ), if (showSwap) CoinActionButton( + key: ValueKey("home_page_swap_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/exchange.svg", colorFilter: ColorFilter.mode( @@ -120,6 +123,7 @@ class CoinActionRow extends StatelessWidget { }, ), CoinActionButton( + key: ValueKey("home_page_scan_button_key"), icon: CakeImageWidget( imageUrl: "assets/new-ui/scan.svg", colorFilter: ColorFilter.mode( diff --git a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart index cceb6ac08..17b008a52 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/assets_top_bar.dart @@ -67,6 +67,7 @@ class AssetsTopBar extends StatelessWidget { child: Semantics( button: true, child: GestureDetector( + key: const ValueKey("assets_history_action_button_key"), onTap: actionButton.onPressed, child: Container( height: 40, diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart index f758b5406..c14c54c09 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_modal.dart @@ -18,6 +18,7 @@ class HistoryModal extends StatelessWidget { @override Widget build(BuildContext context) { return Container( + key: const ValueKey("history_modal_key"), decoration: BoxDecoration( color: Theme.of(context).colorScheme.surface, borderRadius: BorderRadius.vertical(top: Radius.circular(18))), diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart index d27835ee7..182dfcfe1 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_section.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_section.dart @@ -116,6 +116,7 @@ class HistorySection extends StatelessWidget { } }, child: HistoryTile( + key: ValueKey("home_page_transaction_${transaction.id}_key"), title: item.formattedTitle + transactionType, date: _formatTransactionDate(item.date, localeName), amount: item.formattedCryptoAmount, diff --git a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart index 0203d59ba..4acd94e8a 100644 --- a/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart +++ b/lib/new-ui/widgets/coins_page/assets_history/history_top_bar.dart @@ -17,6 +17,7 @@ class HistoryTopBar extends StatelessWidget { onTap: onTap, child: ExcludeSemantics( child: GestureDetector( + key: const ValueKey("history_top_bar_key"), onTap: onTap, behavior: HitTestBehavior.opaque, child: Padding( diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart index 9bf3ec045..0ea80298f 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/sync_bar.dart @@ -153,7 +153,11 @@ class SyncBar extends StatelessWidget { width: 20, height: 20, ), - if (_showDot()) const CupertinoActivityIndicator(radius: 8), + if (_showDot()) + CupertinoActivityIndicator( + key: ValueKey(dashboardViewModel.status.runtimeType), + radius: 8, + ), if (_showLightSyncCheck()) const Icon(Icons.check, color: syncedColor, size: 18), ], ); diff --git a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart index 026af46ba..91e28fc02 100644 --- a/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart +++ b/lib/new-ui/widgets/coins_page/top_bar_widget/top_bar.dart @@ -165,6 +165,7 @@ class _TopBarState extends State { ), ), ModernButton.svg( + key: const ValueKey("home_page_settings_button_key"), iconColor: Theme.of(context).colorScheme.primary, size: 36, onPressed: () { diff --git a/lib/new-ui/widgets/coins_page/wallet_info.dart b/lib/new-ui/widgets/coins_page/wallet_info.dart index b7478abce..469ff630c 100644 --- a/lib/new-ui/widgets/coins_page/wallet_info.dart +++ b/lib/new-ui/widgets/coins_page/wallet_info.dart @@ -49,6 +49,7 @@ class WalletInfoBar extends StatelessWidget { Expanded( child: Text( name, + key: const ValueKey("home_page_wallet_name_text_key"), maxLines: 1, softWrap: false, overflow: TextOverflow.ellipsis, diff --git a/lib/new-ui/widgets/line_tab_switcher.dart b/lib/new-ui/widgets/line_tab_switcher.dart index 9b7ebf09d..4e894a589 100644 --- a/lib/new-ui/widgets/line_tab_switcher.dart +++ b/lib/new-ui/widgets/line_tab_switcher.dart @@ -101,6 +101,7 @@ class _LineTabSwitcherState extends State { selected: widget.selectedTab == index, inMutuallyExclusiveGroup: true, child: GestureDetector( + key: ValueKey("line_tab_switcher_${index}_key"), onTap: () { widget.onTabChange(index); }, diff --git a/lib/new-ui/widgets/receive_page/receive_address_widget.dart b/lib/new-ui/widgets/receive_page/receive_address_widget.dart index 1584d411c..a327b7038 100644 --- a/lib/new-ui/widgets/receive_page/receive_address_widget.dart +++ b/lib/new-ui/widgets/receive_page/receive_address_widget.dart @@ -11,6 +11,7 @@ class ReceiveAddressWidget extends StatelessWidget { @override Widget build(BuildContext context) { return Padding( + key: const ValueKey("receive_page_address_key"), padding: const EdgeInsets.symmetric(horizontal: 50.0), child: Observer( builder: (_) => AddressFormatter.buildSegmentedAddress( diff --git a/lib/new-ui/widgets/send_page/send_address_input.dart b/lib/new-ui/widgets/send_page/send_address_input.dart index e7ff361b8..c8c8d57e1 100644 --- a/lib/new-ui/widgets/send_page/send_address_input.dart +++ b/lib/new-ui/widgets/send_page/send_address_input.dart @@ -148,6 +148,7 @@ class _NewSendAddressInputState extends State { _presentQRScanner(context); }), FloatingIconButton( + key: ValueKey("send_page_address_book_button_key"), iconPath: "assets/new-ui/contacts_outlined.svg", semanticLabel: S.of(context).address_book, onPressed: () { diff --git a/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart b/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart index d09149569..5c9b076a2 100644 --- a/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart +++ b/lib/new-ui/widgets/send_page/send_confirm_bottom_widget.dart @@ -46,6 +46,7 @@ class SendConfirmBottomWidget extends StatelessWidget { switch (state) { case ExecutedSuccessfullyState: return ConfirmSwiper( + key: ValueKey("send_page_confirm_swiper_key"), onConfirmed: () { sendViewModel.commitTransaction(context); }, diff --git a/lib/new-ui/widgets/swap_page/swap_amount_box.dart b/lib/new-ui/widgets/swap_page/swap_amount_box.dart index 784dbd4ef..a7f058193 100644 --- a/lib/new-ui/widgets/swap_page/swap_amount_box.dart +++ b/lib/new-ui/widgets/swap_page/swap_amount_box.dart @@ -211,6 +211,9 @@ class SwapAmountBoxState extends State { widget.exchangeViewModel.receiveAmount.isNotEmpty && widget.exchangeViewModel.depositAmount.isEmpty; return TextField( + key: ValueKey(widget.isReceiverCard + ? "swap_page_receive_amount_field_key" + : "swap_page_deposit_amount_field_key"), keyboardType: TextInputType.numberWithOptions( signed: false, decimal: !widget.useBaseUnit, @@ -260,6 +263,9 @@ class SwapAmountBoxState extends State { ), ), GestureDetector( + key: ValueKey(widget.isReceiverCard + ? "swap_amount_box_receive_currency_button_key" + : "swap_amount_box_deposit_currency_button_key"), onTap: _presentCurrencyPicker, child: Container( decoration: BoxDecoration( diff --git a/lib/src/screens/contact/contact_list_page.dart b/lib/src/screens/contact/contact_list_page.dart index 6e988384e..61fa3f38a 100644 --- a/lib/src/screens/contact/contact_list_page.dart +++ b/lib/src/screens/contact/contact_list_page.dart @@ -68,6 +68,7 @@ class ContactListPage extends BasePage implements PageOpenListener { minWidth: 32.0, height: 32.0, child: TextButton( + key: ValueKey("contact_list_page_add_contact_button_key"), // FIX-ME: Style //shape: CircleBorder(), onPressed: () async { diff --git a/lib/src/screens/contact/contact_page.dart b/lib/src/screens/contact/contact_page.dart index c1c7337d8..abfbca1bd 100644 --- a/lib/src/screens/contact/contact_page.dart +++ b/lib/src/screens/contact/contact_page.dart @@ -73,6 +73,7 @@ class ContactPage extends BasePage { mainAxisSize: MainAxisSize.min, children: [ BaseTextFormField( + key: ValueKey("contact_page_name_textfield_key"), controller: _nameController, hintText: S.of(context).contact_name, validator: ContactNameValidator(), @@ -81,6 +82,7 @@ class ContactPage extends BasePage { padding: EdgeInsets.only(top: 20), child: Container( child: InkWell( + key: ValueKey("contact_page_currency_picker_button_key"), onTap: () => _presentCurrencyPicker(context), child: IgnorePointer( child: BaseTextFormField( @@ -105,6 +107,7 @@ class ContactPage extends BasePage { child: Observer( builder: (_) { return NewSendAddressInput( + key: ValueKey("contact_page_address_input_key"), focusNode: addressFocusNode, displayName: contactViewModel.displayName, addressController: _addressController, @@ -138,6 +141,7 @@ class ContactPage extends BasePage { Expanded( child: Observer( builder: (_) => PrimaryButton( + key: ValueKey("contact_page_save_button_key"), onPressed: () async { FocusScope.of(context).unfocus(); _extractParsedAddress(context); diff --git a/lib/src/screens/dashboard/favorite_token_modal.dart b/lib/src/screens/dashboard/favorite_token_modal.dart index 1b0c9ac13..19df21071 100644 --- a/lib/src/screens/dashboard/favorite_token_modal.dart +++ b/lib/src/screens/dashboard/favorite_token_modal.dart @@ -39,11 +39,13 @@ class FavoriteTokenModal extends StatelessWidget { child: NewListSections( sections: { "": tokens - .map((item) => ListItemRegularRow( - keyValue: item.title, - label: "${item.fullName} (${item.title})", - iconPath: item.iconPath, - onTap: () => Navigator.of(context).pop(item))) + .asMap() + .entries + .map((entry) => ListItemRegularRow( + keyValue: "${entry.value.title}_${entry.key}", + label: "${entry.value.fullName} (${entry.value.title})", + iconPath: entry.value.iconPath, + onTap: () => Navigator.of(context).pop(entry.value))) .toList() }, ), diff --git a/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart b/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart index a1b3b6697..d3b4e4976 100644 --- a/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart +++ b/lib/src/screens/dashboard/widgets/new_main_navbar_widget.dart @@ -193,6 +193,7 @@ class _NEWNewMainNavBarState extends State { inMutuallyExclusiveGroup: true, label: visibleActions[i].name(context), child: InkWell( + key: visibleActions[i].key, splashFactory: NoSplash.splashFactory, splashColor: Colors.transparent, borderRadius: BorderRadius.circular(pillBorderRadius), diff --git a/lib/src/screens/new_wallet/wallet_group_display_page.dart b/lib/src/screens/new_wallet/wallet_group_display_page.dart index 0b8f34626..5e1976820 100644 --- a/lib/src/screens/new_wallet/wallet_group_display_page.dart +++ b/lib/src/screens/new_wallet/wallet_group_display_page.dart @@ -114,6 +114,7 @@ class WalletGroupsDisplayBody extends StatelessWidget { Observer( builder: (context) { return LoadingPrimaryButton( + key: ValueKey("wallet_group_display_page_next_button_key"), isLoading: walletGroupsDisplayViewModel.isFetchingMnemonic, onPressed: () { if (walletGroupsDisplayViewModel.hasNoFilteredWallet) { diff --git a/lib/src/screens/restore/wallet_restore_page.dart b/lib/src/screens/restore/wallet_restore_page.dart index af8e9b91e..6970b857e 100644 --- a/lib/src/screens/restore/wallet_restore_page.dart +++ b/lib/src/screens/restore/wallet_restore_page.dart @@ -102,6 +102,7 @@ class WalletRestorePage extends BasePage { builder: (context) { return walletRestoreViewModel.passphraseAvailable ? StandardCheckbox( + key: const ValueKey("wallet_restore_has_passphrase_checkbox_key"), captionColor: Theme.of(context).colorScheme.onSecondaryContainer, value: walletRestoreViewModel.hasPassphrase, caption: S.of(context).wallet_has_passphrase, diff --git a/lib/src/screens/wallet/wallet_edit_page.dart b/lib/src/screens/wallet/wallet_edit_page.dart index 8e8fc795a..88222ba4d 100644 --- a/lib/src/screens/wallet/wallet_edit_page.dart +++ b/lib/src/screens/wallet/wallet_edit_page.dart @@ -56,6 +56,7 @@ class WalletEditPage extends BasePage { Expanded( child: Center( child: BaseTextFormField( + key: ValueKey("wallet_edit_page_name_input_key"), controller: _labelController, hintText: S.of(context).wallet_list_wallet_name, validator: WalletNameValidator(), @@ -74,6 +75,7 @@ class WalletEditPage extends BasePage { child: Container( padding: EdgeInsets.only(right: 8.0), child: LoadingPrimaryButton( + key: ValueKey("wallet_edit_page_delete_button_key"), isDisabled: isLoading, onPressed: () => _removeWallet(context), text: S.of(context).delete, @@ -86,6 +88,7 @@ class WalletEditPage extends BasePage { child: Container( padding: EdgeInsets.only(left: 8.0), child: LoadingPrimaryButton( + key: ValueKey("wallet_edit_page_save_button_key"), onPressed: () async { if (_formKey.currentState?.validate() ?? false) { if (!pageArguments.isWalletGroup && @@ -95,6 +98,8 @@ class WalletEditPage extends BasePage { context: context, builder: (_) { return AlertWithOneAction( + buttonKey: + ValueKey("wallet_edit_page_name_taken_ok_button_key"), alertTitle: '', alertContent: S.of(context).wallet_name_exists, buttonText: S.of(context).ok, @@ -182,6 +187,8 @@ class WalletEditPage extends BasePage { context: context, builder: (BuildContext dialogContext) { return AlertWithTwoActions( + alertRightActionButtonKey: ValueKey("wallet_edit_page_confirm_delete_button_key"), + alertLeftActionButtonKey: ValueKey("wallet_edit_page_cancel_delete_button_key"), alertTitle: S.of(context).delete_wallet, alertContent: S.of(context).delete_wallet_confirm_message(pageArguments.editingWallet.name), diff --git a/lib/src/screens/wallet_list/wallet_list_page.dart b/lib/src/screens/wallet_list/wallet_list_page.dart index f9ad4b180..b8fb25956 100644 --- a/lib/src/screens/wallet_list/wallet_list_page.dart +++ b/lib/src/screens/wallet_list/wallet_list_page.dart @@ -231,6 +231,7 @@ class WalletListBodyState extends State { return item.isCurrent ? SizedBox.shrink() : EditWalletButtonWidget( + key: ValueKey("wallet_list_edit_${item.name}_button_key"), width: 64, onTap: () => Navigator.of(context).pushNamed( Routes.walletEdit, @@ -314,6 +315,7 @@ class WalletListBodyState extends State { trailingWidget: wallet.isCurrent ? null : EditWalletButtonWidget( + key: ValueKey("wallet_list_edit_${wallet.name}_button_key"), width: 64, onTap: () { Navigator.of(context).pushNamed( diff --git a/lib/src/widgets/alert_with_one_action.dart b/lib/src/widgets/alert_with_one_action.dart index de6c0a870..95d819d93 100644 --- a/lib/src/widgets/alert_with_one_action.dart +++ b/lib/src/widgets/alert_with_one_action.dart @@ -47,31 +47,6 @@ class AlertWithOneAction extends BaseAlertDialog { @override String get rightActionButtonText => buttonText; - // @override - // Widget actionButtons(BuildContext context) { - // return Container( - // padding: EdgeInsets.only(left: 12, right: 12), - // color: Theme.of(context).colorScheme.surface, - // child: ButtonTheme( - // minWidth: double.infinity, - // child: TextButton( - // key: buttonKey, - // onPressed: buttonAction, - // // FIX-ME: Style - // //highlightColor: Colors.transparent, - // //splashColor: Colors.transparent, - // child: Text( - // buttonText, - // textAlign: TextAlign.center, - // style: Theme.of(context).textTheme.bodyMedium!.copyWith( - // fontSize: 15, - // fontWeight: FontWeight.w600, - // color: Theme.of(context).colorScheme.primary, - // decoration: TextDecoration.none, - // ), - // ), - // ), - // ), - // ); - // } + @override + Key? get rightActionButtonKey => buttonKey; } diff --git a/lib/src/widgets/new_list_row/new_list_section.dart b/lib/src/widgets/new_list_row/new_list_section.dart index c4f4e9201..8be05696c 100644 --- a/lib/src/widgets/new_list_row/new_list_section.dart +++ b/lib/src/widgets/new_list_row/new_list_section.dart @@ -90,6 +90,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemRegularRow) { return ListItemRegularRowWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, subtitle: item.subtitle, @@ -118,6 +119,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemToggle) { return ListItemToggleWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, subtitle: item.subtitle, @@ -132,6 +134,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemCheckbox) { return ListItemCheckboxWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, subtitle: item.subtitle, @@ -148,6 +151,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemDropdown) { return ListItemDropdownWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, trailingText: item.trailingText, @@ -159,6 +163,7 @@ class NewListSections extends StatelessWidget { if (item is ListItemSelector) { return ListItemSelectorWidget( + key: ValueKey(item.keyValue), keyValue: item.keyValue, label: item.label, options: item.options, diff --git a/lib/src/widgets/picker.dart b/lib/src/widgets/picker.dart index 117c15d95..1695216bd 100644 --- a/lib/src/widgets/picker.dart +++ b/lib/src/widgets/picker.dart @@ -2,13 +2,11 @@ import 'dart:math'; -import 'package:cake_wallet/entities/seed_type.dart'; import 'package:cake_wallet/new-ui/widgets/coins_page/token_image_widget.dart'; import 'package:cake_wallet/src/widgets/search_bar_widget.dart'; import 'package:cake_wallet/themes/core/theme_extension.dart'; import 'package:cake_wallet/utils/responsive_layout_util.dart'; import 'package:cw_core/crypto_currency.dart'; -import 'package:cw_core/transaction_priority.dart'; import 'package:flutter/material.dart'; import 'package:cw_core/currency.dart'; import 'package:cake_wallet/src/widgets/picker_wrapper_widget.dart'; @@ -304,20 +302,7 @@ class _PickerState extends State> { ); } - String _getItemName(Item item) { - String itemName; - if (item is Currency) { - itemName = item.name; - } else if (item is TransactionPriority) { - itemName = item.title; - } else if (item is MoneroSeedType) { - itemName = item.title; - } else { - itemName = ''; - } - - return itemName; - } + String _getItemName(Item item) => item is Currency ? item.name : item.toString(); Widget buildItem(int index) { final item = widget.headerEnabled ? filteredItems[index] : items[index]; diff --git a/lib/src/widgets/standard_checkbox.dart b/lib/src/widgets/standard_checkbox.dart index d67e368ac..0379fe21a 100644 --- a/lib/src/widgets/standard_checkbox.dart +++ b/lib/src/widgets/standard_checkbox.dart @@ -2,7 +2,8 @@ import 'package:flutter/material.dart'; class StandardCheckbox extends StatelessWidget { StandardCheckbox( - {required this.value, + {super.key, + required this.value, this.caption = '', this.gradientBackground = false, this.borderColor, diff --git a/scripts/ci/emulator_ready.sh b/scripts/ci/emulator_ready.sh new file mode 100644 index 000000000..e46b8f006 --- /dev/null +++ b/scripts/ci/emulator_ready.sh @@ -0,0 +1,12 @@ +#!/bin/bash + +echo "Waiting for boot completion..." +timeout 300 bash -c 'until adb shell getprop sys.boot_completed 2>/dev/null | grep -q "1"; do sleep 5; done' || echo "Boot completion timeout, continuing" + +echo "Waiting for package manager..." +timeout 60 bash -c 'until adb shell pm list packages 2>/dev/null >/dev/null; do sleep 2; done' || echo "Package manager timeout, continuing" + +echo "Waiting for settings service..." +timeout 60 bash -c 'until adb shell settings get global window_animation_scale 2>/dev/null >/dev/null; do sleep 2; done' || echo "Settings service timeout, continuing" + +echo "Emulator readiness check completed" diff --git a/scripts/ci/run_integration_tests.sh b/scripts/ci/run_integration_tests.sh new file mode 100644 index 000000000..f41989494 --- /dev/null +++ b/scripts/ci/run_integration_tests.sh @@ -0,0 +1,115 @@ +#!/bin/bash + +TIER="$1" + +if [[ -z "${SUITE_DIR_INPUT:-}" ]]; then + echo "SUITE_DIR_INPUT never reached the emulator script" + exit 1 +fi + +KEYS_IMPORTED="" + +import_encryption_keys() { + if [[ -n "$KEYS_IMPORTED" ]]; then + return + fi + + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 6B3199AD9B3D23B8 || true # konstantin@cakewallet.com + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 35C8DBAFB8D9ACAC || true # cyjan@mrcyjanek.net + gpg --keyserver hkps://keyserver.ubuntu.com --recv-keys 0126DFF8D54495EB || true # david@cakewallet.com + + if gpg --list-keys 6B3199AD9B3D23B8 > /dev/null 2>&1 && + gpg --list-keys 35C8DBAFB8D9ACAC > /dev/null 2>&1 && + gpg --list-keys 0126DFF8D54495EB > /dev/null 2>&1; then + KEYS_IMPORTED=1 + else + echo "Recipient keys missing from the keyring, the next call retries the import" + fi +} + +encrypt_and_drop() { + gpg --trust-model always --encrypt --output "$1.gpg" \ + --recipient 6B3199AD9B3D23B8 \ + --recipient 35C8DBAFB8D9ACAC \ + --recipient 0126DFF8D54495EB \ + "$1" || echo "Encrypting $1 failed, discarding it" + rm -f "$1" +} + +bash scripts/ci/emulator_ready.sh + +adb shell settings put global window_animation_scale 0.0 || true +adb shell settings put global transition_animation_scale 0.0 || true +adb shell settings put global animator_duration_scale 0.0 || true + +echo "===== resources before $TIER =====" +free -h || true +df -h / || true + +if [[ "$RECORD_SCREEN" == "true" ]]; then + echo "Starting chunked screen recording..." + (i=0; while true; do adb shell screenrecord --time-limit 180 --bit-rate 2000000 "/sdcard/rec_${TIER}_$i.mp4" || break; i=$((i+1)); done) & + RECORD_LOOP_PID=$! +fi + +# Both tiers get one retry. tier1 talks to live nodes and providers, and giving it +# no retry meant a driver that never attached was reported as a failing test. +RETRIES=1 + +# The step log is public, so a run log that gets encrypted below must not print here. +run_suites() { + env \ + SUITE_DIR="$SUITE_DIR_INPUT" \ + TEST_TIER="$TIER" \ + EXTRA_DART_DEFINES="$EXTRA_DART_DEFINES_INPUT" \ + TEST_TIMEOUT=600 \ + RETRY_COUNT=$RETRIES \ + PLATFORM=android \ + SUMMARY_FILE="${TIER}_summary.txt" \ + ./integration_test_runner.sh 2>&1 +} + +set -o pipefail +set +e +if [[ "${ENCRYPT_LOGS:-}" == "true" ]]; then + run_suites > "${TIER}_run.log" +else + run_suites | tee "${TIER}_run.log" +fi +TEST_EXIT_CODE=$? +set -e + +echo "===== resources after $TIER =====" +free -h || true +df -h / || true + +adb logcat -d > "${TIER}_logcat.txt" || true + +# A funds run puts real funded seeds on the device and these carry whatever the app +# logs, so they leave the runner the same way the recordings do +if [[ "${ENCRYPT_LOGS:-}" == "true" ]]; then + import_encryption_keys + encrypt_and_drop "${TIER}_logcat.txt" + encrypt_and_drop "${TIER}_run.log" +fi + +if [[ -n "${RECORD_LOOP_PID:-}" ]]; then + echo "Stopping screen recording and encrypting..." + kill "$RECORD_LOOP_PID" || true + adb shell killall -INT screenrecord || true + sleep 5 + + mkdir -p recordings + for f in $(adb shell ls /sdcard/rec_*.mp4 2>/dev/null | tr -d '\r'); do + adb pull "$f" recordings/ || true + done + + # Recordings show seed words on screen so they are never uploaded in plaintext + import_encryption_keys + for f in recordings/*.mp4; do + [[ -f "$f" ]] || continue + encrypt_and_drop "$f" + done +fi + +exit $TEST_EXIT_CODE diff --git a/test/widget_test.dart b/test/widget_test.dart deleted file mode 100644 index c79ce99ad..000000000 --- a/test/widget_test.dart +++ /dev/null @@ -1,34 +0,0 @@ -// This is a basic Flutter widget test. -// -// To perform an interaction with a widget in your test, use the WidgetTester -// utility that Flutter provides. For example, you can send tap and scroll -// gestures. You can also use WidgetTester to find child widgets in the widget -// tree, read text, and verify that the values of widget properties are correct. - -import 'dart:async'; - -import 'package:flutter/material.dart'; -import 'package:flutter_test/flutter_test.dart'; - -import 'package:cake_wallet/main.dart'; - -final quickActionsStream = StreamController.broadcast(); - -void main() { - testWidgets('Counter increments smoke test', (WidgetTester tester) async { - // Build our app and trigger a frame. - await tester.pumpWidget(App(quickActionsStream: quickActionsStream.stream)); - - // Verify that our counter starts at 0. - expect(find.text('0'), findsOneWidget); - expect(find.text('1'), findsNothing); - - // Tap the '+' icon and trigger a frame. - await tester.tap(find.byIcon(Icons.add)); - await tester.pump(); - - // Verify that our counter has incremented. - expect(find.text('0'), findsNothing); - expect(find.text('1'), findsOneWidget); - }); -} diff --git a/test_driver/integration_test.dart b/test_driver/integration_test.dart index 2e67d866f..045b33472 100644 --- a/test_driver/integration_test.dart +++ b/test_driver/integration_test.dart @@ -1,19 +1,40 @@ -import 'dart:convert'; +import "dart:async"; +import "dart:convert"; -import 'package:integration_test/integration_test_driver.dart'; -import 'package:path/path.dart' as path; - -import 'package:flutter_driver/flutter_driver.dart'; +import "package:flutter_driver/flutter_driver.dart"; +import "package:integration_test/integration_test_driver_extended.dart"; +import "package:path/path.dart" as path; Future main() async { - integrationDriver( - responseDataCallback: (Map? data) async { + await runZoned( + _drive, + zoneSpecification: ZoneSpecification( + print: (self, parent, zone, line) { + if (!line.startsWith("result {")) { + parent.print(zone, line); + } + }, + ), + ); +} + +Future _drive() async { + await integrationDriver( + onScreenshot: (name, bytes, [args]) async { + await fs.directory(_screenshotDirectory).create(recursive: true); + await fs.file(path.join(_screenshotDirectory, "$name.png")).writeAsBytes(bytes); + + return true; + }, + responseDataCallback: (data) async { + data?.remove("screenshots"); + await fs.directory(_destinationDirectory).create(recursive: true); final file = fs.file( path.join( _destinationDirectory, - '$_testOutputFilename.json', + "$_testOutputFilename.json", ), ); @@ -28,6 +49,7 @@ String _encodeJson(Map? jsonObject) { return _prettyEncoder.convert(jsonObject); } -const _prettyEncoder = JsonEncoder.withIndent(' '); -const _testOutputFilename = 'integration_response_data'; -const _destinationDirectory = 'integration_test'; +const _prettyEncoder = JsonEncoder.withIndent(" "); +const _testOutputFilename = "integration_response_data"; +const _destinationDirectory = "build/integration_test"; +const _screenshotDirectory = "build/integration_test_screenshots"; diff --git a/tool/utils/secret_key.dart b/tool/utils/secret_key.dart index d4024cc51..6128637df 100644 --- a/tool/utils/secret_key.dart +++ b/tool/utils/secret_key.dart @@ -50,7 +50,7 @@ class SecretKey { SecretKey('meldTestApiKey', () => ''), SecretKey('meldTestPublicKey', () => ''), SecretKey('moneroTestWalletSeeds', () => ''), - SecretKey('moneroLegacyTestWalletSeeds ', () => ''), + SecretKey('moneroLegacyTestWalletSeeds', () => ''), SecretKey('bitcoinTestWalletSeeds', () => ''), SecretKey('ethereumTestWalletSeeds', () => ''), SecretKey('litecoinTestWalletSeeds', () => ''), @@ -60,9 +60,14 @@ class SecretKey { SecretKey('arbitrumTestWalletSeeds', () => ''), SecretKey('bscTestWalletSeeds', () => ''), SecretKey('solanaTestWalletSeeds', () => ''), + SecretKey('solanaTestWalletSeeds2', () => ''), SecretKey('tronTestWalletSeeds', () => ''), SecretKey('nanoTestWalletSeeds', () => ''), SecretKey('wowneroTestWalletSeeds', () => ''), + SecretKey('zanoTestWalletSeeds', () => ''), + SecretKey('decredTestWalletSeeds', () => ''), + SecretKey('dogeTestWalletSeeds', () => ''), + SecretKey('zcashTestWalletSeeds', () => ''), SecretKey('moneroTestWalletReceiveAddress', () => ''), SecretKey('bitcoinTestWalletReceiveAddress', () => ''), SecretKey('ethereumTestWalletReceiveAddress', () => ''), @@ -76,6 +81,10 @@ class SecretKey { SecretKey('tronTestWalletReceiveAddress', () => ''), SecretKey('nanoTestWalletReceiveAddress', () => ''), SecretKey('wowneroTestWalletReceiveAddress', () => ''), + SecretKey('zanoTestWalletReceiveAddress', () => ''), + SecretKey('decredTestWalletReceiveAddress', () => ''), + SecretKey('dogeTestWalletReceiveAddress', () => ''), + SecretKey('zcashTestWalletReceiveAddress', () => ''), SecretKey('etherScanApiKey', () => ''), SecretKey('polygonScanApiKey', () => ''), SecretKey('letsExchangeBearerToken', () => ''), @@ -83,6 +92,7 @@ class SecretKey { SecretKey('stealthExBearerToken', () => ''), SecretKey('stealthExAdditionalFeePercent', () => ''), SecretKey('moneroTestWalletBlockHeight', () => ''), + SecretKey('zcashTestWalletRestoreHeight', () => ''), SecretKey('chainflipApiKey', () => ''), SecretKey('chainflipAffiliateFee', () => ''), SecretKey('kryptonimApiKey', () => ''), @@ -100,7 +110,7 @@ class SecretKey { SecretKey('etherScanApiKey', () => ''), SecretKey('polygonScanApiKey', () => ''), SecretKey('moralisApiKey', () => ''), - SecretKey('nowNodesApiKey ', () => ''), + SecretKey('nowNodesApiKey', () => ''), SecretKey('blinkApiKey', () => ''), ];