From cc3bc5aaf518e6344a4f99b9bed4a31b9fb1333f Mon Sep 17 00:00:00 2001 From: johnny9 <985648+johnny9@users.noreply.github.com> Date: Sat, 14 Mar 2026 07:34:51 -0400 Subject: [PATCH 1/7] Add wallet migration GUI flow and test --- qml/bitcoin_qml.qrc | 1 + qml/pages/wallet/DesktopWallets.qml | 38 +++-- qml/pages/wallet/ImportWalletMigration.qml | 161 +++++++++++++++++++ qml/pages/wallet/WalletBadge.qml | 1 + qml/pages/wallet/WalletSelect.qml | 1 + test/functional/qml_driver.py | 11 ++ test/functional/qml_test_wallet_migration.py | 101 ++++++++++++ 7 files changed, 303 insertions(+), 11 deletions(-) create mode 100644 qml/pages/wallet/ImportWalletMigration.qml create mode 100644 test/functional/qml_test_wallet_migration.py diff --git a/qml/bitcoin_qml.qrc b/qml/bitcoin_qml.qrc index 4945da9d1a..1e8fc33063 100644 --- a/qml/bitcoin_qml.qrc +++ b/qml/bitcoin_qml.qrc @@ -107,6 +107,7 @@ pages/wallet/CreatePassword.qml pages/wallet/ImportWalletOptions.qml pages/wallet/ImportWalletSuccess.qml + pages/wallet/ImportWalletMigration.qml pages/wallet/CreateWalletWizard.qml pages/wallet/DesktopWallets.qml pages/wallet/MultipleSendReview.qml diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index 4f4dbf72af..f95bcee24d 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -22,12 +22,38 @@ Page { signal addWallet() signal sendTransaction(bool multipleRecipientsEnabled) + function handleWalletBadgeClicked() { + if (!walletController.initialized) { + return + } + + walletListModel.listWalletDir() + if (walletController.noWalletsFound) { + root.addWallet() + } else { + walletSelect.opened ? walletSelect.close() : walletSelect.open() + } + } + + Component { + id: walletMigrationPage + ImportWalletMigration { + onBack: root.StackView.view.pop() + onCancel: root.StackView.view.pop() + onNext: root.StackView.view.pop() + } + } + Connections { target: walletController function onOpenWalletSettingsRequested() { settingsTabButton.checked = true nodeSettings.openWalletSettings() } + function onWalletMigrationRequired(walletPath) { + walletSelect.close() + root.StackView.view.push(walletMigrationPage, { "walletPath": walletPath }) + } } header: NavigationBar2 { @@ -42,17 +68,7 @@ Page { loading: !walletController.initialized noWalletLoaded: !walletController.isWalletLoaded noWalletsFound: walletController.noWalletsFound - - onClicked: { - if (walletController.initialized) { - walletListModel.listWalletDir() - if (walletController.noWalletsFound) { - root.addWallet() - } else { - walletSelect.opened ? walletSelect.close() : walletSelect.open() - } - } - } + onClicked: root.handleWalletBadgeClicked() WalletSelect { id: walletSelect diff --git a/qml/pages/wallet/ImportWalletMigration.qml b/qml/pages/wallet/ImportWalletMigration.qml new file mode 100644 index 0000000000..51d7affea3 --- /dev/null +++ b/qml/pages/wallet/ImportWalletMigration.qml @@ -0,0 +1,161 @@ +// Copyright (c) 2026 The Bitcoin Core developers +// Distributed under the MIT software license, see the accompanying +// file COPYING or http://www.opensource.org/licenses/mit-license.php. + +import QtQuick 2.15 +import QtQuick.Controls 2.15 +import QtQuick.Layouts 1.15 + +import "../../controls" +import "../../components" +import "../settings" + +Page { + id: root + objectName: "importWalletMigration" + required property string walletPath + signal back + signal cancel + signal next + + property string phase: "intro" + readonly property bool introPhase: phase === "intro" + readonly property bool updatingPhase: phase === "updating" + readonly property bool successPhase: phase === "success" + readonly property bool failedPhase: phase === "failed" + + function startMigration() { + phase = "updating" + walletController.migrateWallet(walletPath) + } + + Component.onCompleted: { + walletController.clearWalletMigrationStatus() + phase = "intro" + } + + background: null + + header: NavigationBar2 { + leftItem: NavButton { + visible: !root.updatingPhase + enabled: visible + iconSource: "image://images/caret-left" + text: qsTr("Back") + onClicked: { + walletController.clearWalletMigrationStatus() + root.back() + } + } + rightItem: NavButton { + visible: root.introPhase || root.failedPhase + enabled: visible + text: qsTr("Cancel") + onClicked: { + walletController.clearWalletMigrationStatus() + root.cancel() + } + } + } + + Connections { + target: walletController + function onWalletMigrationSucceeded() { + root.phase = "success" + } + function onWalletMigrationFailed() { + root.phase = "failed" + } + } + + ColumnLayout { + width: Math.min(parent.width, 450) + anchors.horizontalCenter: parent.horizontalCenter + + Image { + Layout.alignment: Qt.AlignCenter + Layout.topMargin: 20 + source: { + if (root.successPhase) { + return "image://images/circle-green-check" + } + if (root.failedPhase) { + return "image://images/circle-red-cross" + } + return "image://images/pending" + } + sourceSize.width: 60 + sourceSize.height: 60 + } + + Header { + Layout.topMargin: 18 + Layout.fillWidth: true + Layout.leftMargin: 20 + Layout.rightMargin: 20 + header: { + if (root.successPhase) { + return qsTr("Update completed") + } + if (root.failedPhase) { + return qsTr("Update failed") + } + if (root.updatingPhase) { + return qsTr("Updating wallet") + } + return qsTr("Wallet update required") + } + headerBold: true + description: { + if (root.successPhase) { + return qsTr("You can use your wallet as usual.") + } + if (root.failedPhase) { + return walletController.walletMigrationError.length > 0 + ? walletController.walletMigrationError + : qsTr("The wallet could not be updated.") + } + if (root.updatingPhase) { + return qsTr("Updating your wallet now. This may take a moment.") + } + return qsTr("This wallet uses an outdated file format and needs to be updated. This does not impact security or any previously made transactions.") + } + } + + ContinueButton { + id: actionButton + objectName: "walletMigrationActionButton" + Layout.preferredWidth: Math.min(335, parent.width - 2 * Layout.leftMargin) + Layout.topMargin: 30 + Layout.leftMargin: 20 + Layout.rightMargin: 20 + Layout.alignment: Qt.AlignCenter + enabled: !root.updatingPhase + text: { + if (root.successPhase) { + return qsTr("Next") + } + if (root.failedPhase) { + return qsTr("Back to overview") + } + if (root.updatingPhase) { + return qsTr("Updating wallet...") + } + return qsTr("Update wallet") + } + onClicked: { + if (root.successPhase) { + walletController.clearWalletMigrationStatus() + root.next() + return + } + if (root.failedPhase) { + walletController.clearWalletMigrationStatus() + root.cancel() + return + } + root.startMigration() + } + } + } +} diff --git a/qml/pages/wallet/WalletBadge.qml b/qml/pages/wallet/WalletBadge.qml index 7a7652a0f0..1345091c16 100644 --- a/qml/pages/wallet/WalletBadge.qml +++ b/qml/pages/wallet/WalletBadge.qml @@ -12,6 +12,7 @@ import "../../controls" Button { id: root + objectName: "walletBadge" property color bgActiveColor: Theme.color.neutral2 property color textColor: Theme.color.neutral7 diff --git a/qml/pages/wallet/WalletSelect.qml b/qml/pages/wallet/WalletSelect.qml index 2492045fd8..b525f4c6f3 100644 --- a/qml/pages/wallet/WalletSelect.qml +++ b/qml/pages/wallet/WalletSelect.qml @@ -78,6 +78,7 @@ Popup { delegate: WalletBadge { required property string name; + objectName: "walletSelectItem_" + name width: 220 height: 32 diff --git a/test/functional/qml_driver.py b/test/functional/qml_driver.py index 182bcdc439..d47b0dcbc7 100644 --- a/test/functional/qml_driver.py +++ b/test/functional/qml_driver.py @@ -250,6 +250,17 @@ def settle( f"Timed out waiting for stack views to become idle: {last_busy}" ) + def wait_for_object(self, object_name, timeout_ms=5000): + """Block until an object with the given name exists.""" + deadline = time.time() + (timeout_ms / 1000) + while time.time() < deadline: + if any(obj.get("objectName") == object_name for obj in self.list_objects()): + return + time.sleep(0.1) + raise QmlDriverError( + f"wait_for_object({object_name!r}) failed: Object not found before timeout" + ) + # ── Transport layer ────────────────────────────────────────────── def _send(self, cmd): diff --git a/test/functional/qml_test_wallet_migration.py b/test/functional/qml_test_wallet_migration.py new file mode 100644 index 0000000000..baf7604386 --- /dev/null +++ b/test/functional/qml_test_wallet_migration.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +# Copyright (c) 2026 The Bitcoin Core developers +# Distributed under the MIT software license, see the accompanying +# file COPYING or http://www.opensource.org/licenses/mit-license.php. +"""End-to-end GUI test for selector-driven legacy wallet migration.""" + +import os +import shutil +import sys + +from qml_test_harness import dump_qml_tree +from qml_wallet_test_lib import WalletFlowHarness, find_legacy_bitcoind, rpc_call + + +def run_test(): + harness = WalletFlowHarness("qml_wallet_migration", port_offset=50) + gui = None + try: + legacy_binary = find_legacy_bitcoind() + if not legacy_binary: + print("SKIPPED: legacy bitcoind not found.") + print("Set BITCOIND_LEGACY or provide releases/v28.0/bin/bitcoind to exercise this flow.") + return 77 + + harness.start_source_node( + binary=legacy_binary, + extra_args=["-deprecatedrpc=create_bdb"], + ) + wallet_name = "legacy_flow" + try: + rpc_call( + harness.source_rpc_port, + "createwallet", + { + "wallet_name": wallet_name, + "descriptors": False, + }, + ) + except RuntimeError as err: + print(f"SKIPPED: could not create a legacy wallet fixture: {err}") + return 77 + + rpc_call(harness.source_rpc_port, "unloadwallet", [wallet_name]) + source_wallet_path = os.path.join(harness.source_wallets_path, wallet_name) + target_wallet_path = os.path.join(harness.gui_wallets_path, wallet_name) + os.makedirs(os.path.dirname(target_wallet_path), exist_ok=True) + shutil.copytree(source_wallet_path, target_wallet_path) + harness.stop_source_node() + + harness.start_gui() + gui = harness.driver + gui.wait_for_property("walletBadge", "loading", False, timeout_ms=20000) + gui.wait_for_property("walletBadge", "visible", True, timeout_ms=10000) + + wallet_dir = rpc_call(harness.gui_rpc_port, "listwalletdir")["wallets"] + legacy_entry = next((entry for entry in wallet_dir if entry["name"] == wallet_name), None) + assert legacy_entry is not None, f"Expected {wallet_name} to be present in listwalletdir" + assert legacy_entry["warnings"] == [ + "This wallet is a legacy wallet and will need to be migrated with migratewallet before it can be loaded" + ], f"Expected migration warning for {wallet_name}" + + gui.click("walletBadge") + gui.wait_for_property("walletSelectPopup", "opened", True, timeout_ms=5000) + gui.wait_for_property("walletSelectList", "count", 1, timeout_ms=5000) + gui.wait_for_object(f"walletSelectItem_{wallet_name}", timeout_ms=5000) + gui.click(f"walletSelectItem_{wallet_name}") + + gui.wait_for_page("importWalletMigration", timeout_ms=10000) + gui.wait_for_property("walletMigrationActionButton", "text", "Update wallet", timeout_ms=5000) + gui.click("walletMigrationActionButton") + gui.wait_for_property("walletMigrationActionButton", "text", "Next", timeout_ms=30000) + gui.click("walletMigrationActionButton") + + gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000) + wallet_dat_path = os.path.join(target_wallet_path, "wallet.dat") + with open(wallet_dat_path, "rb") as wallet_file: + assert wallet_file.read(16) == b"SQLite format 3\x00", "Migrated wallet should be SQLite" + + wallet_info = rpc_call(harness.gui_rpc_port, "getwalletinfo", wallet=wallet_name) + assert wallet_info["descriptors"] is True, "Migrated wallet should be descriptor based" + assert wallet_info["format"] == "sqlite", "Migrated wallet should be stored as sqlite" + + print("Migration flow passed.") + return 0 + except Exception as err: # noqa: BLE001 - preserve failure context for functional test output + print(f"\nFAILED: {err}", file=sys.stderr) + import traceback + traceback.print_exc() + gui_output = harness.process_output(harness.gui_process) + if gui_output: + print("\n--- GUI process output ---", file=sys.stderr) + print(gui_output, file=sys.stderr) + if gui is not None: + dump_qml_tree(gui) + return 1 + finally: + harness.stop() + + +if __name__ == "__main__": + sys.exit(run_test()) From 84c48a611811452379da6d4c4b9e3900d602306b Mon Sep 17 00:00:00 2001 From: johnny9 <985648+johnny9@users.noreply.github.com> Date: Mon, 30 Mar 2026 16:33:56 -0400 Subject: [PATCH 2/7] qml: ignore stale wallet migration navigation --- qml/pages/wallet/DesktopWallets.qml | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index f95bcee24d..19c9a24a1f 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -22,6 +22,16 @@ Page { signal addWallet() signal sendTransaction(bool multipleRecipientsEnabled) + function handleWalletMigrationRequired(walletPath) { + const stackView = root.StackView.view + if (!stackView || stackView.currentItem !== root) { + return + } + + walletSelect.close() + stackView.push(walletMigrationPage, { "walletPath": walletPath }) + } + function handleWalletBadgeClicked() { if (!walletController.initialized) { return @@ -51,8 +61,7 @@ Page { nodeSettings.openWalletSettings() } function onWalletMigrationRequired(walletPath) { - walletSelect.close() - root.StackView.view.push(walletMigrationPage, { "walletPath": walletPath }) + root.handleWalletMigrationRequired(walletPath) } } From b0c0e7078b006c203ef6781f6efb10734ac66132 Mon Sep 17 00:00:00 2001 From: johnny9 <985648+johnny9@users.noreply.github.com> Date: Mon, 30 Mar 2026 23:12:34 -0400 Subject: [PATCH 3/7] ci: run gui functional wallet tests with legacy bitcoind wallets --- .github/workflows/gui-functional-tests.yml | 48 +++++++++++++++++++++- 1 file changed, 47 insertions(+), 1 deletion(-) diff --git a/.github/workflows/gui-functional-tests.yml b/.github/workflows/gui-functional-tests.yml index 7fee1ac3f8..c51c73071f 100644 --- a/.github/workflows/gui-functional-tests.yml +++ b/.github/workflows/gui-functional-tests.yml @@ -16,6 +16,8 @@ on: jobs: qml-functional-tests: runs-on: ubuntu-24.04 + env: + LEGACY_BITCOIND_VERSION: "28.0" steps: - name: Checkout @@ -27,7 +29,7 @@ jobs: run: | sudo apt-get update sudo apt-get install -y \ - build-essential cmake pkgconf python3 \ + build-essential cmake curl pkgconf python3 \ libevent-dev libboost-dev libsqlite3-dev libgl-dev libqrencode-dev \ qt6-qpa-plugins \ qt6-base-dev qt6-tools-dev qt6-l10n-tools qt6-tools-dev-tools \ @@ -37,6 +39,47 @@ jobs: qml6-module-qtquick-layouts qml6-module-qtquick-controls \ qml6-module-qtquick-dialogs qml6-module-qtquick-templates + - name: Restore legacy bitcoind cache + uses: actions/cache/restore@v4 + id: legacy-bitcoind-cache + with: + path: releases/v${{ env.LEGACY_BITCOIND_VERSION }} + key: ${{ runner.os }}-legacy-bitcoind-v${{ env.LEGACY_BITCOIND_VERSION }} + + - name: Install legacy bitcoind + run: | + archive="bitcoin-${LEGACY_BITCOIND_VERSION}-x86_64-linux-gnu.tar.gz" + base_url="https://bitcoincore.org/bin/bitcoin-core-${LEGACY_BITCOIND_VERSION}" + legacy_dir="${GITHUB_WORKSPACE}/releases/v${LEGACY_BITCOIND_VERSION}" + + mkdir -p "${legacy_dir}" + + if [ ! -x "${legacy_dir}/bin/bitcoind" ]; then + workdir="$(mktemp -d)" + trap 'rm -rf "${workdir}"' EXIT + + curl -fsSLo "${workdir}/${archive}" "${base_url}/${archive}" + curl -fsSLo "${workdir}/SHA256SUMS" "${base_url}/SHA256SUMS" + ( + cd "${workdir}" + grep " ${archive}$" "SHA256SUMS" | sha256sum -c - + ) + + tar -xzf "${workdir}/${archive}" \ + --strip-components=1 \ + -C "${legacy_dir}" \ + "bitcoin-${LEGACY_BITCOIND_VERSION}/bin" + fi + + "${legacy_dir}/bin/bitcoind" --version | head -n 1 + + - name: Save legacy bitcoind cache + uses: actions/cache/save@v4 + if: github.event_name != 'pull_request' && steps.legacy-bitcoind-cache.outputs.cache-hit != 'true' + with: + path: releases/v${{ env.LEGACY_BITCOIND_VERSION }} + key: ${{ runner.os }}-legacy-bitcoind-v${{ env.LEGACY_BITCOIND_VERSION }} + - name: Configure run: | cmake -B build \ @@ -53,6 +96,7 @@ jobs: QT_QUICK_BACKEND: software LIBGL_ALWAYS_SOFTWARE: "1" run: | + export BITCOIND_LEGACY="${GITHUB_WORKSPACE}/releases/v${LEGACY_BITCOIND_VERSION}/bin/bitcoind" python3 test/functional/qml_test_bridge_sanity.py python3 test/functional/qml_test_onboarding.py python3 test/functional/qml_test_external_signer.py @@ -60,3 +104,5 @@ jobs: python3 test/functional/qml_test_proxy.py python3 test/functional/qml_test_debug_log.py python3 test/functional/qml_test_settings_display.py + python3 test/functional/qml_test_wallet_migration.py + python3 test/functional/qml_test_wallet_restore.py From 04cd68a2df852adea2325a24c861a57cf95a40ff Mon Sep 17 00:00:00 2001 From: johnny9 <985648+johnny9@users.noreply.github.com> Date: Fri, 8 May 2026 04:50:30 -0400 Subject: [PATCH 4/7] qml: address wallet migration review feedback --- qml/pages/wallet/DesktopWallets.qml | 1 - qml/pages/wallet/ImportWalletMigration.qml | 16 +++------------- test/functional/qml_test_wallet_migration.py | 2 +- 3 files changed, 4 insertions(+), 15 deletions(-) diff --git a/qml/pages/wallet/DesktopWallets.qml b/qml/pages/wallet/DesktopWallets.qml index 19c9a24a1f..fd3a488bc6 100644 --- a/qml/pages/wallet/DesktopWallets.qml +++ b/qml/pages/wallet/DesktopWallets.qml @@ -49,7 +49,6 @@ Page { id: walletMigrationPage ImportWalletMigration { onBack: root.StackView.view.pop() - onCancel: root.StackView.view.pop() onNext: root.StackView.view.pop() } } diff --git a/qml/pages/wallet/ImportWalletMigration.qml b/qml/pages/wallet/ImportWalletMigration.qml index 51d7affea3..75aa0f1749 100644 --- a/qml/pages/wallet/ImportWalletMigration.qml +++ b/qml/pages/wallet/ImportWalletMigration.qml @@ -15,7 +15,6 @@ Page { objectName: "importWalletMigration" required property string walletPath signal back - signal cancel signal next property string phase: "intro" @@ -47,15 +46,6 @@ Page { root.back() } } - rightItem: NavButton { - visible: root.introPhase || root.failedPhase - enabled: visible - text: qsTr("Cancel") - onClicked: { - walletController.clearWalletMigrationStatus() - root.cancel() - } - } } Connections { @@ -95,7 +85,7 @@ Page { Layout.rightMargin: 20 header: { if (root.successPhase) { - return qsTr("Update completed") + return qsTr("Wallet successfully migrated") } if (root.failedPhase) { return qsTr("Update failed") @@ -133,7 +123,7 @@ Page { enabled: !root.updatingPhase text: { if (root.successPhase) { - return qsTr("Next") + return qsTr("Done") } if (root.failedPhase) { return qsTr("Back to overview") @@ -151,7 +141,7 @@ Page { } if (root.failedPhase) { walletController.clearWalletMigrationStatus() - root.cancel() + root.back() return } root.startMigration() diff --git a/test/functional/qml_test_wallet_migration.py b/test/functional/qml_test_wallet_migration.py index baf7604386..a527088be5 100644 --- a/test/functional/qml_test_wallet_migration.py +++ b/test/functional/qml_test_wallet_migration.py @@ -68,7 +68,7 @@ def run_test(): gui.wait_for_page("importWalletMigration", timeout_ms=10000) gui.wait_for_property("walletMigrationActionButton", "text", "Update wallet", timeout_ms=5000) gui.click("walletMigrationActionButton") - gui.wait_for_property("walletMigrationActionButton", "text", "Next", timeout_ms=30000) + gui.wait_for_property("walletMigrationActionButton", "text", "Done", timeout_ms=30000) gui.click("walletMigrationActionButton") gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000) From f45c56207aad684c85d581bb6c33a59e13dca4df Mon Sep 17 00:00:00 2001 From: johnny9 <985648+johnny9@users.noreply.github.com> Date: Mon, 11 May 2026 13:13:23 -0400 Subject: [PATCH 5/7] qml, test: add wallet migration checkpoints --- test/functional/qml_test_wallet_migration.py | 84 +++++++++++++++++++- 1 file changed, 82 insertions(+), 2 deletions(-) diff --git a/test/functional/qml_test_wallet_migration.py b/test/functional/qml_test_wallet_migration.py index a527088be5..e723d8de99 100644 --- a/test/functional/qml_test_wallet_migration.py +++ b/test/functional/qml_test_wallet_migration.py @@ -4,16 +4,73 @@ # file COPYING or http://www.opensource.org/licenses/mit-license.php. """End-to-end GUI test for selector-driven legacy wallet migration.""" +import argparse import os +import re import shutil import sys +from datetime import datetime from qml_test_harness import dump_qml_tree from qml_wallet_test_lib import WalletFlowHarness, find_legacy_bitcoind, rpc_call -def run_test(): +def parse_args(): + parser = argparse.ArgumentParser( + description="Wallet migration GUI functional test", + add_help=True, + ) + parser.add_argument( + "--save-screenshots", + action="store_true", + help="Save a PNG at each GUI checkpoint under test/artifacts/", + ) + return parser.parse_args() + + +def make_screenshot_root(): + repo_root = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "..")) + artifacts_root = os.path.join(repo_root, "test", "artifacts") + timestamp = datetime.now().strftime("%Y%m%d-%H%M%S") + screenshot_root = os.path.join(artifacts_root, f"qml_test_wallet_migration-{timestamp}") + os.makedirs(screenshot_root, exist_ok=True) + return screenshot_root + + +class CheckpointRecorder: + def __init__(self, save_screenshots, screenshot_root): + self.save_screenshots = save_screenshots + self.screenshot_root = screenshot_root + self.index = 0 + + def _sanitize_label(self, label): + return re.sub(r"[^a-z0-9]+", "-", label.lower()).strip("-") or "checkpoint" + + def checkpoint(self, label, gui=None): + if gui is not None: + gui.settle() + + self.index += 1 + prefix = f"[qml_test_wallet_migration] checkpoint {self.index:02d}" + print(f"{prefix}: {label}") + if gui is None: + return + + if not self.save_screenshots: + return + + filename = f"{self.index:02d}-{self._sanitize_label(label)}.png" + screenshot_path = os.path.join(self.screenshot_root, filename) + screenshot = gui.save_screenshot(screenshot_path) + print( + f"{prefix}: screenshot saved to {screenshot['path']} " + f"({screenshot['width']}x{screenshot['height']})" + ) + + +def run_test(*, save_screenshots=False, screenshot_root=None): harness = WalletFlowHarness("qml_wallet_migration", port_offset=50) + checkpoints = CheckpointRecorder(save_screenshots, screenshot_root) gui = None try: legacy_binary = find_legacy_bitcoind() @@ -22,10 +79,12 @@ def run_test(): print("Set BITCOIND_LEGACY or provide releases/v28.0/bin/bitcoind to exercise this flow.") return 77 + checkpoints.checkpoint(f"legacy bitcoind located: {legacy_binary}") harness.start_source_node( binary=legacy_binary, extra_args=["-deprecatedrpc=create_bdb"], ) + checkpoints.checkpoint("legacy source node started") wallet_name = "legacy_flow" try: rpc_call( @@ -40,17 +99,20 @@ def run_test(): print(f"SKIPPED: could not create a legacy wallet fixture: {err}") return 77 + checkpoints.checkpoint("legacy wallet fixture created") rpc_call(harness.source_rpc_port, "unloadwallet", [wallet_name]) source_wallet_path = os.path.join(harness.source_wallets_path, wallet_name) target_wallet_path = os.path.join(harness.gui_wallets_path, wallet_name) os.makedirs(os.path.dirname(target_wallet_path), exist_ok=True) shutil.copytree(source_wallet_path, target_wallet_path) harness.stop_source_node() + checkpoints.checkpoint(f"legacy wallet fixture copied to GUI datadir: {target_wallet_path}") harness.start_gui() gui = harness.driver gui.wait_for_property("walletBadge", "loading", False, timeout_ms=20000) gui.wait_for_property("walletBadge", "visible", True, timeout_ms=10000) + checkpoints.checkpoint("GUI launched", gui) wallet_dir = rpc_call(harness.gui_rpc_port, "listwalletdir")["wallets"] legacy_entry = next((entry for entry in wallet_dir if entry["name"] == wallet_name), None) @@ -58,20 +120,26 @@ def run_test(): assert legacy_entry["warnings"] == [ "This wallet is a legacy wallet and will need to be migrated with migratewallet before it can be loaded" ], f"Expected migration warning for {wallet_name}" + print(f"[qml_test_wallet_migration] legacy wallet dir entry: {legacy_entry}") + checkpoints.checkpoint("legacy wallet warning verified", gui) gui.click("walletBadge") gui.wait_for_property("walletSelectPopup", "opened", True, timeout_ms=5000) gui.wait_for_property("walletSelectList", "count", 1, timeout_ms=5000) gui.wait_for_object(f"walletSelectItem_{wallet_name}", timeout_ms=5000) + checkpoints.checkpoint("legacy wallet visible in selector", gui) gui.click(f"walletSelectItem_{wallet_name}") gui.wait_for_page("importWalletMigration", timeout_ms=10000) gui.wait_for_property("walletMigrationActionButton", "text", "Update wallet", timeout_ms=5000) + checkpoints.checkpoint("migration confirmation page displayed", gui) gui.click("walletMigrationActionButton") gui.wait_for_property("walletMigrationActionButton", "text", "Done", timeout_ms=30000) + checkpoints.checkpoint("wallet migration completed", gui) gui.click("walletMigrationActionButton") gui.wait_for_property("walletBadge", "text", wallet_name, timeout_ms=20000) + checkpoints.checkpoint("migrated wallet loaded", gui) wallet_dat_path = os.path.join(target_wallet_path, "wallet.dat") with open(wallet_dat_path, "rb") as wallet_file: assert wallet_file.read(16) == b"SQLite format 3\x00", "Migrated wallet should be SQLite" @@ -79,6 +147,8 @@ def run_test(): wallet_info = rpc_call(harness.gui_rpc_port, "getwalletinfo", wallet=wallet_name) assert wallet_info["descriptors"] is True, "Migrated wallet should be descriptor based" assert wallet_info["format"] == "sqlite", "Migrated wallet should be stored as sqlite" + print(f"[qml_test_wallet_migration] migrated wallet info: {wallet_info}") + checkpoints.checkpoint("migration RPC verification passed", gui) print("Migration flow passed.") return 0 @@ -86,6 +156,11 @@ def run_test(): print(f"\nFAILED: {err}", file=sys.stderr) import traceback traceback.print_exc() + if gui is not None: + try: + checkpoints.checkpoint("failure state", gui) + except Exception as screenshot_err: # noqa: BLE001 - preserve original failure context + print(f"[qml_test_wallet_migration] failed to save failure screenshot: {screenshot_err}", file=sys.stderr) gui_output = harness.process_output(harness.gui_process) if gui_output: print("\n--- GUI process output ---", file=sys.stderr) @@ -98,4 +173,9 @@ def run_test(): if __name__ == "__main__": - sys.exit(run_test()) + args = parse_args() + screenshot_root = None + if args.save_screenshots: + screenshot_root = make_screenshot_root() + print(f"Checkpoint screenshots will be saved under: {screenshot_root}") + sys.exit(run_test(save_screenshots=args.save_screenshots, screenshot_root=screenshot_root)) From 596fc76df48afc9830079d7a8c904cba4e2ef26d Mon Sep 17 00:00:00 2001 From: johnny9 Date: Wed, 13 May 2026 22:36:59 -0400 Subject: [PATCH 6/7] test: isolate QSettings in QML harnesses Run launched GUI processes with a temporary XDG_CONFIG_HOME so QSettings written by one functional test do not leak into later tests. When a harness reuses an existing datadir for restart coverage, keep it on the same temporary config root so settings persistence is still exercised. --- test/functional/qml_test_harness.py | 6 ++++++ test/functional/qml_wallet_test_lib.py | 3 +++ 2 files changed, 9 insertions(+) diff --git a/test/functional/qml_test_harness.py b/test/functional/qml_test_harness.py index a7c489ba5d..cde9a01183 100755 --- a/test/functional/qml_test_harness.py +++ b/test/functional/qml_test_harness.py @@ -93,6 +93,7 @@ def __init__(self, socket_path=None, extra_args=None, reset_settings=True, datad self.driver = None self.extra_args = extra_args or [] self.reset_settings = reset_settings + self.config_home = None if self.external: self.socket_path = socket_path @@ -105,10 +106,12 @@ def __init__(self, socket_path=None, extra_args=None, reset_settings=True, datad self.tmpdir = None self.datadir = datadir self.socket_path = os.path.join(datadir, "test_bridge.sock") + self.config_home = os.path.join(os.path.dirname(datadir), "config") else: self.tmpdir = tempfile.mkdtemp(prefix="qml_test_bridge_") self.datadir = setup_datadir(self.tmpdir) self.socket_path = os.path.join(self.tmpdir, "test_bridge.sock") + self.config_home = os.path.join(self.tmpdir, "config") def start(self): """Launch bitcoin-core-app or attach to an existing instance.""" @@ -120,6 +123,9 @@ def start(self): env = dict(os.environ) env["QT_QPA_PLATFORM"] = "offscreen" + if self.config_home: + os.makedirs(self.config_home, exist_ok=True) + env["XDG_CONFIG_HOME"] = self.config_home args = [ self.gui_binary, diff --git a/test/functional/qml_wallet_test_lib.py b/test/functional/qml_wallet_test_lib.py index 7c5010eb21..43f3e801f2 100644 --- a/test/functional/qml_wallet_test_lib.py +++ b/test/functional/qml_wallet_test_lib.py @@ -163,6 +163,7 @@ def __init__(self, name, port_offset): self.socket_path = os.path.join(self.tmpdir, "test_bridge.sock") self.gui_datadir = os.path.join(self.tmpdir, "gui_node") self.source_datadir = os.path.join(self.tmpdir, "source_node") + self.config_home = os.path.join(self.tmpdir, "config") self.gui_process = None self.source_process = None self.driver = None @@ -211,6 +212,8 @@ def stop_source_node(self): def start_gui(self, reset_gui_settings=False, extra_args=None, cwd=None): env = dict(os.environ) env["QT_QPA_PLATFORM"] = "offscreen" + os.makedirs(self.config_home, exist_ok=True) + env["XDG_CONFIG_HOME"] = self.config_home args = [ self.gui_binary, f"-datadir={self.gui_datadir}", From 09ba06cbc35b27ca94ad1c1240f7538638f059b9 Mon Sep 17 00:00:00 2001 From: johnny9 Date: Thu, 14 May 2026 00:05:11 -0400 Subject: [PATCH 7/7] qml: detect legacy wallets before loading --- qml/models/walletlistmodel.cpp | 24 +++++-- qml/models/walletlistmodel.h | 6 +- qml/pages/wallet/WalletSelect.qml | 6 +- qml/walletqmlcontroller.cpp | 68 +++++++++++-------- qml/walletqmlcontroller.h | 8 +-- test/qml/qml_tests_main.cpp | 12 +++- test/test_walletqmlcontroller.cpp | 108 ++++++++++++++++++++++++++++++ 7 files changed, 186 insertions(+), 46 deletions(-) diff --git a/qml/models/walletlistmodel.cpp b/qml/models/walletlistmodel.cpp index def378b7d9..9c77b53051 100644 --- a/qml/models/walletlistmodel.cpp +++ b/qml/models/walletlistmodel.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -6,7 +6,7 @@ #include -#include +#include WalletListModel::WalletListModel(interfaces::Node& node, QObject *parent) : QAbstractListModel(parent) @@ -16,17 +16,24 @@ WalletListModel::WalletListModel(interfaces::Node& node, QObject *parent) void WalletListModel::listWalletDir() { - QSet existing_names; + QHash existing_rows; for (int i = 0; i < rowCount(); ++i) { QModelIndex index = this->index(i, 0); QString name = data(index, NameRole).toString(); - existing_names.insert(name); + existing_rows.insert(name, i); } - for (const auto& [path, info] : m_node.walletLoader().listWalletDir()) { + for (const auto& [path, format] : m_node.walletLoader().listWalletDir()) { QString qname = QString::fromStdString(path); - if (!existing_names.contains(qname)) { - addItem({ qname }); + QString qformat = QString::fromStdString(format); + if (existing_rows.contains(qname)) { + const int row = existing_rows.value(qname); + if (m_items[row].format != qformat) { + m_items[row].format = qformat; + Q_EMIT dataChanged(index(row, 0), index(row, 0), {FormatRole}); + } + } else { + addItem({ qname, qformat }); } } } @@ -47,6 +54,8 @@ QVariant WalletListModel::data(const QModelIndex &index, int role) const case Qt::DisplayRole: case NameRole: return item.name; + case FormatRole: + return item.format; default: return QVariant(); } @@ -56,6 +65,7 @@ QHash WalletListModel::roleNames() const { QHash roles; roles[NameRole] = "name"; + roles[FormatRole] = "format"; return roles; } diff --git a/qml/models/walletlistmodel.h b/qml/models/walletlistmodel.h index c36cb2b4fc..2adf482439 100644 --- a/qml/models/walletlistmodel.h +++ b/qml/models/walletlistmodel.h @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -22,7 +22,8 @@ class WalletListModel : public QAbstractListModel ~WalletListModel() = default; enum Roles { - NameRole = Qt::UserRole + 1 + NameRole = Qt::UserRole + 1, + FormatRole }; int rowCount(const QModelIndex &parent = QModelIndex()) const override; @@ -35,6 +36,7 @@ public Q_SLOTS: private: struct Item { QString name; + QString format; }; void addItem(const Item &item); diff --git a/qml/pages/wallet/WalletSelect.qml b/qml/pages/wallet/WalletSelect.qml index b525f4c6f3..768d63f28d 100644 --- a/qml/pages/wallet/WalletSelect.qml +++ b/qml/pages/wallet/WalletSelect.qml @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -78,8 +78,8 @@ Popup { delegate: WalletBadge { required property string name; + required property string format; objectName: "walletSelectItem_" + name - width: 220 height: 32 text: name @@ -88,7 +88,7 @@ Popup { showBalance: false showIcon: false onClicked: { - walletController.setSelectedWallet(name) + walletController.setSelectedWallet(name, format) root.close() } } diff --git a/qml/walletqmlcontroller.cpp b/qml/walletqmlcontroller.cpp index f824cba666..63dd2a5129 100644 --- a/qml/walletqmlcontroller.cpp +++ b/qml/walletqmlcontroller.cpp @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -39,16 +39,6 @@ QString JoinWarnings(const std::vector& warnings) return lines.join('\n'); } -bool NeedsWalletMigration(const bilingual_str& error) -{ - const QString original = QString::fromStdString(error.original); - const QString translated = QString::fromStdString(error.translated); - return (original.contains("legacy wallet", Qt::CaseInsensitive) || - translated.contains("legacy wallet", Qt::CaseInsensitive)) && - (original.contains("migrat", Qt::CaseInsensitive) || - translated.contains("migrat", Qt::CaseInsensitive)); -} - bool IsBackupLikeFile(const QFileInfo& file_info) { const QString file_name = file_info.fileName().toLower(); @@ -105,7 +95,7 @@ WalletQmlController::~WalletQmlController() delete m_empty_wallet; } -void WalletQmlController::setSelectedWallet(QString path) +void WalletQmlController::setSelectedWallet(QString path, QString wallet_format) { if (!m_wallets.empty()) { for (WalletQmlModel* wallet : m_wallets) { @@ -118,7 +108,7 @@ void WalletQmlController::setSelectedWallet(QString path) } } - startWalletLoad(path); + startWalletLoad(path, wallet_format); } WalletQmlModel* WalletQmlController::selectedWallet() const @@ -422,21 +412,28 @@ QString WalletQmlController::inferWalletLoadTarget(const QString& normalized_pat return {}; } -QString WalletQmlController::resolveManagedWalletReference(const QString& path) const +QString WalletQmlController::resolveManagedWalletReference(const QString& path, QString* wallet_format) const { + if (wallet_format) { + wallet_format->clear(); + } + if (path.isEmpty()) { return {}; } const QString candidate = QDir::cleanPath(path); + const auto wallet_dir_entries = m_node.walletLoader().listWalletDir(); // Wallet selector entries come from listWalletDir(), which reports wallet // names relative to -walletdir. Accept those names directly before trying // to interpret the value as a filesystem path. - for (const auto& [wallet_path, info] : m_node.walletLoader().listWalletDir()) { - Q_UNUSED(info); + for (const auto& [wallet_path, format] : wallet_dir_entries) { const QString listed_wallet = QDir::cleanPath(QString::fromStdString(wallet_path)); if (listed_wallet == candidate) { + if (wallet_format) { + *wallet_format = QString::fromStdString(format); + } return listed_wallet; } } @@ -446,7 +443,23 @@ QString WalletQmlController::resolveManagedWalletReference(const QString& path) return {}; } - return inferWalletLoadTarget(normalized_path); + const QString load_target = inferWalletLoadTarget(normalized_path); + if (load_target.isEmpty()) { + return {}; + } + + const QString clean_load_target = QDir::cleanPath(load_target); + for (const auto& [wallet_path, format] : wallet_dir_entries) { + const QString listed_wallet = QDir::cleanPath(QString::fromStdString(wallet_path)); + if (listed_wallet == clean_load_target) { + if (wallet_format) { + *wallet_format = QString::fromStdString(format); + } + break; + } + } + + return load_target; } QString WalletQmlController::inferRestoreWalletName(const QString& normalized_path) const @@ -625,7 +638,7 @@ void WalletQmlController::setNoWalletsFound(bool no_wallets_found) } } -void WalletQmlController::startWalletLoad(const QString& path) +void WalletQmlController::startWalletLoad(const QString& path, const QString& wallet_format) { clearWalletMigrationStatus(); clearWalletLoadStatus(); @@ -639,12 +652,20 @@ void WalletQmlController::startWalletLoad(const QString& path) // Wallet selection can arrive either as a wallet name from listWalletDir() // or as a concrete path under -walletdir. Resolve both into the wallet // reference expected by loadWallet() and migrateWallet(). - const QString load_target = resolveManagedWalletReference(path); + QString load_target_format = wallet_format; + const QString load_target = load_target_format.isEmpty() + ? resolveManagedWalletReference(path, &load_target_format) + : QDir::cleanPath(path); if (load_target.isEmpty()) { setWalletLoadError(tr("The selected wallet is not available in the wallet directory.")); return; } + if (load_target_format == "bdb") { + Q_EMIT walletMigrationRequired(load_target); + return; + } + m_wallet_load_requested = true; m_pending_wallet_load_action = WalletLoadAction::Load; setWalletLoadInProgress(true); @@ -659,17 +680,10 @@ void WalletQmlController::startWalletLoad(const QString& path) if (!wallet) { const bilingual_str result_error = util::ErrorString(wallet); const QString error = QString::fromStdString(result_error.translated); - const bool migration_required = !load_target.isEmpty() && NeedsWalletMigration(result_error); - QMetaObject::invokeMethod(this, [this, error, warnings, migration_required, load_target]() { + QMetaObject::invokeMethod(this, [this, error, warnings]() { m_wallet_load_requested = false; m_pending_wallet_load_action = WalletLoadAction::None; setWalletLoadInProgress(false); - if (migration_required) { - clearWalletLoadStatus(); - Q_EMIT walletMigrationRequired(load_target); - return; - } - setWalletLoadWarnings(warnings); setWalletLoadError(error.isEmpty() ? tr("Wallet could not be opened.") : error); }); diff --git a/qml/walletqmlcontroller.h b/qml/walletqmlcontroller.h index 228b88757f..4523bfa12b 100644 --- a/qml/walletqmlcontroller.h +++ b/qml/walletqmlcontroller.h @@ -1,4 +1,4 @@ -// Copyright (c) 2024 The Bitcoin Core developers +// Copyright (c) 2024-2026 The Bitcoin Core developers // Distributed under the MIT software license, see the accompanying // file COPYING or http://www.opensource.org/licenses/mit-license.php. @@ -43,7 +43,7 @@ class WalletQmlController : public QObject explicit WalletQmlController(interfaces::Node& node, QObject *parent = nullptr); ~WalletQmlController(); - Q_INVOKABLE void setSelectedWallet(QString path); + Q_INVOKABLE void setSelectedWallet(QString path, QString wallet_format = QString()); Q_INVOKABLE bool createSingleSigWallet(const QString &name, const QString &passphrase); Q_INVOKABLE bool createExternalSignerWallet(const QString& name); Q_INVOKABLE void importWallet(const QString& path); @@ -108,9 +108,9 @@ public Q_SLOTS: void handleLoadWallet(std::unique_ptr wallet); void startWalletImport(const QString& path); - void startWalletLoad(const QString& path); + void startWalletLoad(const QString& path, const QString& wallet_format = QString()); void startWalletMigration(const QString& path); - QString resolveManagedWalletReference(const QString& path) const; + QString resolveManagedWalletReference(const QString& path, QString* wallet_format = nullptr) const; QString inferWalletLoadTarget(const QString& normalized_path) const; QString inferRestoreWalletName(const QString& normalized_path) const; QString describeImportedWalletKeyScheme(interfaces::Wallet& wallet) const; diff --git a/test/qml/qml_tests_main.cpp b/test/qml/qml_tests_main.cpp index b4fcc94264..eb0cf8a1e2 100644 --- a/test/qml/qml_tests_main.cpp +++ b/test/qml/qml_tests_main.cpp @@ -734,8 +734,9 @@ class MockWalletController : public QObject m_selected_wallet = wallet; Q_EMIT selectedWalletChanged(); } - Q_INVOKABLE void setSelectedWallet(const QString& name) + Q_INVOKABLE void setSelectedWallet(const QString& name, const QString& format = QString()) { + Q_UNUSED(format); if (m_last_selected_wallet_name == name) return; m_last_selected_wallet_name = name; Q_EMIT lastSelectedWalletNameChanged(); @@ -961,7 +962,8 @@ class MockWalletListModel : public QAbstractListModel public: enum Roles { - NameRole = Qt::UserRole + 1 + NameRole = Qt::UserRole + 1, + FormatRole }; int rowCount(const QModelIndex& parent = QModelIndex{}) const override @@ -974,12 +976,16 @@ class MockWalletListModel : public QAbstractListModel { if (!index.isValid() || index.row() < 0 || index.row() >= m_wallet_names.size()) return {}; if (role == NameRole) return m_wallet_names.at(index.row()); + if (role == FormatRole) return QStringLiteral("sqlite"); return {}; } QHash roleNames() const override { - return {{NameRole, "name"}}; + return { + {NameRole, "name"}, + {FormatRole, "format"}, + }; } Q_INVOKABLE void listWalletDir() {} diff --git a/test/test_walletqmlcontroller.cpp b/test/test_walletqmlcontroller.cpp index 983e5f9290..2ed9df6c25 100644 --- a/test/test_walletqmlcontroller.cpp +++ b/test/test_walletqmlcontroller.cpp @@ -7,7 +7,10 @@ #include #include +#include +#include #include +#include #include #include @@ -35,6 +38,88 @@ std::vector> MakeSigners(std::initia } return signers; } + +std::unique_ptr MakeNoopHandler() +{ + return interfaces::MakeCleanupHandler([] {}); +} + +class FakeWalletLoader : public interfaces::WalletLoader +{ +public: + int handle_load_wallet_calls{0}; + int get_wallets_calls{0}; + int list_wallet_dir_calls{0}; + int load_wallet_calls{0}; + + std::function>()> get_wallets_fn = [] { + return std::vector>{}; + }; + std::vector> wallet_dir_entries; + + void registerRpcs() override {} + bool verify() override { return true; } + bool load() override { return true; } + void start(CScheduler&) override {} + void stop() override {} + void setMockTime(int64_t) override {} + void schedulerMockForward(std::chrono::seconds) override {} + util::Result> createWallet( + const std::string&, + const SecureString&, + uint64_t, + std::vector&) override + { + return util::Error{Untranslated("Unexpected createWallet call")}; + } + util::Result> loadWallet(const std::string&, std::vector&) override + { + ++load_wallet_calls; + return util::Error{Untranslated("Unexpected loadWallet call")}; + } + std::string getWalletDir() override { return {}; } + util::Result> restoreWallet(const fs::path&, const std::string&, std::vector&) override + { + return util::Error{Untranslated("Unexpected restoreWallet call")}; + } + util::Result migrateWallet(const std::string&, const SecureString&) override + { + return util::Error{Untranslated("Unexpected migrateWallet call")}; + } + bool isEncrypted(const std::string&) override { return false; } + std::vector> listWalletDir() override + { + ++list_wallet_dir_calls; + return wallet_dir_entries; + } + std::vector> getWallets() override + { + ++get_wallets_calls; + return get_wallets_fn(); + } + std::unique_ptr handleLoadWallet(LoadWalletFn) override + { + ++handle_load_wallet_calls; + return MakeNoopHandler(); + } +}; + +void ExpectControllerInitialization(MockNode& node, FakeWalletLoader& loader) +{ + using ::testing::_; + using ::testing::AtLeast; + using ::testing::Invoke; + using ::testing::Return; + using ::testing::ReturnRef; + + ON_CALL(node, walletLoader()).WillByDefault(ReturnRef(loader)); + EXPECT_CALL(node, walletLoader()).Times(AtLeast(3)).WillRepeatedly(ReturnRef(loader)); + EXPECT_CALL(node, getPersistentSetting(_)).WillOnce(Return(common::SettingsValue{})); + EXPECT_CALL(node, forceSetting(_, _)).Times(1); + EXPECT_CALL(node, listExternalSigners()).WillOnce(Invoke([] { + return std::vector>{}; + })); +} } // namespace class WalletQmlControllerTests : public QObject @@ -45,6 +130,7 @@ private Q_SLOTS: void externalSignerCreationRequiresConfiguredPath(); void externalSignerCreationRequiresExactlyOneSigner(); void externalSignerSuggestionUsesSignerName(); + void initializedControllerSignalsMigrationForLegacyWallet(); }; void WalletQmlControllerTests::externalSignerCreationRequiresConfiguredPath() @@ -111,6 +197,28 @@ void WalletQmlControllerTests::externalSignerSuggestionUsesSignerName() QCOMPARE(controller.suggestedExternalSignerWalletName(), QString("Coldcard_Mk4")); } +void WalletQmlControllerTests::initializedControllerSignalsMigrationForLegacyWallet() +{ + using ::testing::StrictMock; + + StrictMock node; + FakeWalletLoader loader; + loader.wallet_dir_entries = {{"legacy_wallet", "bdb"}}; + ExpectControllerInitialization(node, loader); + + WalletQmlController controller(node); + controller.initialize(); + + QSignalSpy migration_spy(&controller, &WalletQmlController::walletMigrationRequired); + + controller.setSelectedWallet("legacy_wallet", "bdb"); + QCOMPARE(migration_spy.count(), 1); + QCOMPARE(migration_spy.takeFirst().at(0).toString(), QString{"legacy_wallet"}); + QCOMPARE(loader.load_wallet_calls, 0); + QVERIFY(!controller.walletLoadInProgress()); + QVERIFY(controller.walletLoadError().isEmpty()); +} + #ifdef BITCOINQML_NO_TEST_MAIN #include BITCOINQML_REGISTER_QT_TEST(WalletQmlControllerTests)