Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
48 changes: 47 additions & 1 deletion .github/workflows/gui-functional-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ on:
jobs:
qml-functional-tests:
runs-on: ubuntu-24.04
env:
LEGACY_BITCOIND_VERSION: "28.0"

steps:
- name: Checkout
Expand All @@ -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 \
Expand All @@ -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 \
Expand All @@ -53,10 +96,13 @@ 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
python3 test/functional/qml_test_peers.py
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
1 change: 1 addition & 0 deletions qml/bitcoin_qml.qrc
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
<file>pages/wallet/CreatePassword.qml</file>
<file>pages/wallet/ImportWalletOptions.qml</file>
<file>pages/wallet/ImportWalletSuccess.qml</file>
<file>pages/wallet/ImportWalletMigration.qml</file>
<file>pages/wallet/CreateWalletWizard.qml</file>
<file>pages/wallet/DesktopWallets.qml</file>
<file>pages/wallet/MultipleSendReview.qml</file>
Expand Down
46 changes: 35 additions & 11 deletions qml/pages/wallet/DesktopWallets.qml
Original file line number Diff line number Diff line change
Expand Up @@ -22,12 +22,46 @@ 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
}

walletListModel.listWalletDir()
if (walletController.noWalletsFound) {
root.addWallet()
} else {
walletSelect.opened ? walletSelect.close() : walletSelect.open()
}
}

Component {
id: walletMigrationPage
ImportWalletMigration {
onBack: root.StackView.view.pop()
onNext: root.StackView.view.pop()
}
}

Connections {
target: walletController
function onOpenWalletSettingsRequested() {
settingsTabButton.checked = true
nodeSettings.openWalletSettings()
}
function onWalletMigrationRequired(walletPath) {
root.handleWalletMigrationRequired(walletPath)
}
}

header: NavigationBar2 {
Expand All @@ -42,17 +76,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
Expand Down
151 changes: 151 additions & 0 deletions qml/pages/wallet/ImportWalletMigration.qml
Original file line number Diff line number Diff line change
@@ -0,0 +1,151 @@
// 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 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()
}
}
}

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("Wallet successfully migrated")
}
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("Done")
}
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.back()
return
}
root.startMigration()
}
}
}
}
1 change: 1 addition & 0 deletions qml/pages/wallet/WalletBadge.qml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import "../../controls"

Button {
id: root
objectName: "walletBadge"

property color bgActiveColor: Theme.color.neutral2
property color textColor: Theme.color.neutral7
Expand Down
1 change: 1 addition & 0 deletions qml/pages/wallet/WalletSelect.qml
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,7 @@ Popup {

delegate: WalletBadge {
required property string name;
objectName: "walletSelectItem_" + name

width: 220
height: 32
Expand Down
11 changes: 11 additions & 0 deletions test/functional/qml_driver.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
6 changes: 6 additions & 0 deletions test/functional/qml_test_harness.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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."""
Expand All @@ -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,
Expand Down
Loading
Loading