diff --git a/.github/workflows/gui-functional-tests.yml b/.github/workflows/gui-functional-tests.yml index 7fee1ac3f8..ced18d6f25 100644 --- a/.github/workflows/gui-functional-tests.yml +++ b/.github/workflows/gui-functional-tests.yml @@ -55,6 +55,7 @@ jobs: run: | python3 test/functional/qml_test_bridge_sanity.py python3 test/functional/qml_test_onboarding.py + python3 test/functional/qml_test_disablewallet_boot.py python3 test/functional/qml_test_external_signer.py python3 test/functional/qml_test_peers.py python3 test/functional/qml_test_proxy.py diff --git a/qml/bitcoin.cpp b/qml/bitcoin.cpp index e4dd62c1af..4f3f0c8240 100644 --- a/qml/bitcoin.cpp +++ b/qml/bitcoin.cpp @@ -294,18 +294,26 @@ int QmlGuiMain(int argc, char* argv[]) handler_message_box.disconnect(); + AppMode app_mode = SetupAppMode(); +#ifdef ENABLE_WALLET + const bool wallet_enabled = app_mode.walletEnabled(); +#endif + NodeModel node_model{*node}; QmlInitExecutor init_executor{*node}; #ifdef ENABLE_WALLET - WalletQmlController wallet_controller(*node); - if (!gArgs.GetBoolArg("-disablewallet", false)) { - QObject::connect(&init_executor, &QmlInitExecutor::initializeResult, &wallet_controller, &WalletQmlController::initialize); + std::unique_ptr wallet_controller; + if (wallet_enabled) { + wallet_controller = std::make_unique(*node); + QObject::connect(&init_executor, &QmlInitExecutor::initializeResult, wallet_controller.get(), &WalletQmlController::initialize); } #endif QObject::connect(&node_model, &NodeModel::requestedInitialize, &init_executor, &QmlInitExecutor::initialize); QObject::connect(&node_model, &NodeModel::requestedShutdown, [&] { #ifdef ENABLE_WALLET - wallet_controller.unloadWallets(); + if (wallet_controller) { + wallet_controller->unloadWallets(); + } #endif init_executor.shutdown(); }); @@ -329,7 +337,9 @@ int QmlGuiMain(int argc, char* argv[]) qGuiApp->setQuitOnLastWindowClosed(false); QObject::connect(qGuiApp, &QGuiApplication::lastWindowClosed, [&] { #ifdef ENABLE_WALLET - wallet_controller.unloadWallets(); + if (wallet_controller) { + wallet_controller->unloadWallets(); + } #endif node->startShutdown(); }); @@ -366,9 +376,12 @@ int QmlGuiMain(int argc, char* argv[]) engine.rootContext()->setContextProperty("debugLogModel", &debug_log_model); #ifdef ENABLE_WALLET - WalletListModel wallet_list_model{*node, nullptr}; - engine.rootContext()->setContextProperty("walletController", &wallet_controller); - engine.rootContext()->setContextProperty("walletListModel", &wallet_list_model); + std::unique_ptr wallet_list_model; + if (wallet_enabled) { + wallet_list_model = std::make_unique(*node, nullptr); + engine.rootContext()->setContextProperty("walletController", wallet_controller.get()); + engine.rootContext()->setContextProperty("walletListModel", wallet_list_model.get()); + } #endif OptionsQmlModel options_model(*node, !need_onboarding.toBool()); @@ -396,7 +409,6 @@ int QmlGuiMain(int argc, char* argv[]) engine.retranslate(); }); - AppMode app_mode = SetupAppMode(); BuildInfo build_info; Clipboard clipboard; diff --git a/qml/pages/node/NetworkTraffic.qml b/qml/pages/node/NetworkTraffic.qml index 6937d58453..f12c205dda 100644 --- a/qml/pages/node/NetworkTraffic.qml +++ b/qml/pages/node/NetworkTraffic.qml @@ -20,6 +20,7 @@ InformationPage { property alias trafficGraphScale: root.trafficGraphScale } navLeftDetail: NavButton { + objectName: "networkTrafficBackButton" iconSource: "image://images/caret-left" text: qsTr("Back") onClicked: root.back() diff --git a/qml/pages/node/NodeSettings.qml b/qml/pages/node/NodeSettings.qml index 737c19c69e..5466236cb1 100644 --- a/qml/pages/node/NodeSettings.qml +++ b/qml/pages/node/NodeSettings.qml @@ -42,6 +42,7 @@ PageStack { } rightItem: NavButton { id: doneButton + objectName: "nodeSettingsDoneButton" text: qsTr("Done") onClicked: root.doneClicked() } @@ -82,6 +83,7 @@ PageStack { Separator { Layout.fillWidth: true } Setting { id: gotoStorage + objectName: "gotoStorage" Layout.fillWidth: true header: qsTr("Storage") actionItem: CaretRightIcon { @@ -138,6 +140,7 @@ PageStack { Separator { Layout.fillWidth: true } Setting { id: gotoNetworkTraffic + objectName: "settingsNetworkTraffic" Layout.fillWidth: true header: qsTr("Network Traffic") actionItem: CaretRightIcon { diff --git a/qml/pages/node/Peers.qml b/qml/pages/node/Peers.qml index 685b0a6a4d..1b304a3319 100644 --- a/qml/pages/node/Peers.qml +++ b/qml/pages/node/Peers.qml @@ -21,6 +21,7 @@ Page { header: NavigationBar2 { leftItem: NavButton { + objectName: "peersBackButton" iconSource: "image://images/caret-left" text: qsTr("Back") onClicked: root.back() diff --git a/qml/pages/settings/SettingsAbout.qml b/qml/pages/settings/SettingsAbout.qml index 1fcdffdb79..29822640ff 100644 --- a/qml/pages/settings/SettingsAbout.qml +++ b/qml/pages/settings/SettingsAbout.qml @@ -47,6 +47,7 @@ InformationPage { Component { id: backButton NavButton { + objectName: "settingsAboutBack" iconSource: "image://images/caret-left" text: qsTr("Back") onClicked: root.back() diff --git a/qml/pages/settings/SettingsConnection.qml b/qml/pages/settings/SettingsConnection.qml index ae60004055..ebd35f4f47 100644 --- a/qml/pages/settings/SettingsConnection.qml +++ b/qml/pages/settings/SettingsConnection.qml @@ -58,6 +58,7 @@ Page { Component { id: backButton NavButton { + objectName: "settingsConnectionBack" iconSource: "image://images/caret-left" text: qsTr("Back") onClicked: root.back() diff --git a/qml/pages/settings/SettingsDeveloper.qml b/qml/pages/settings/SettingsDeveloper.qml index 9be67d8339..cb1c5ede57 100644 --- a/qml/pages/settings/SettingsDeveloper.qml +++ b/qml/pages/settings/SettingsDeveloper.qml @@ -13,6 +13,7 @@ InformationPage { objectName: "settingsDeveloper" property bool onboarding: false navLeftDetail: NavButton { + objectName: "settingsDeveloperBack" iconSource: "image://images/caret-left" text: qsTr("Back") onClicked: { diff --git a/qml/pages/settings/SettingsDisplay.qml b/qml/pages/settings/SettingsDisplay.qml index 37684cfa62..de1f75b1ef 100644 --- a/qml/pages/settings/SettingsDisplay.qml +++ b/qml/pages/settings/SettingsDisplay.qml @@ -27,6 +27,7 @@ Item { header: NavigationBar2 { leftItem: NavButton { + objectName: "settingsDisplayBack" iconSource: "image://images/caret-left" text: qsTr("Back") onClicked: root.back() diff --git a/qml/pages/settings/SettingsStorage.qml b/qml/pages/settings/SettingsStorage.qml index 2ca56742f3..01cfca1cca 100644 --- a/qml/pages/settings/SettingsStorage.qml +++ b/qml/pages/settings/SettingsStorage.qml @@ -52,6 +52,7 @@ InformationPage { Component { id: backButton NavButton { + objectName: "settingsStorageBack" iconSource: "image://images/caret-left" text: qsTr("Back") onClicked: root.back() diff --git a/qml/test/testbridge.cpp b/qml/test/testbridge.cpp index 1bf990f8b1..1456de63ad 100644 --- a/qml/test/testbridge.cpp +++ b/qml/test/testbridge.cpp @@ -14,6 +14,7 @@ #include #include #include +#include #include #include #include @@ -375,6 +376,8 @@ QByteArray TestBridge::processCommand(const QByteArray& json_cmd) if (cmd == QLatin1String("get_current_page")) { return cmdGetCurrentPage(); + } else if (cmd == QLatin1String("get_context_property")) { + return cmdGetContextProperty(obj.value(QStringLiteral("name")).toString()); } else if (cmd == QLatin1String("get_property")) { return cmdGetProperty( obj.value(QStringLiteral("objectName")).toString(), @@ -457,6 +460,34 @@ QByteArray TestBridge::cmdGetCurrentPage() return errorResponse(QStringLiteral("Could not determine current page; missing mainPageStack/current page item")); } +QByteArray TestBridge::cmdGetContextProperty(const QString& name) +{ + if (name.isEmpty()) { + return errorResponse(QStringLiteral("name is required")); + } + + QVariant value = m_engine->rootContext()->contextProperty(name); + const bool exists = value.isValid() && !value.isNull(); + + QJsonObject resp; + resp[QStringLiteral("exists")] = exists; + if (!exists) { + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + } + + if (QObject* object = value.value()) { + resp[QStringLiteral("isQObject")] = true; + resp[QStringLiteral("className")] = QString::fromLatin1(object->metaObject()->className()); + resp[QStringLiteral("objectName")] = object->objectName(); + } else { + resp[QStringLiteral("isQObject")] = false; + resp[QStringLiteral("typeName")] = QString::fromLatin1(value.typeName()); + resp[QStringLiteral("value")] = QJsonValue::fromVariant(value); + } + + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + QByteArray TestBridge::cmdGetProperty(const QString& object_name, const QString& prop) { if (object_name.isEmpty() || prop.isEmpty()) { diff --git a/qml/test/testbridge.h b/qml/test/testbridge.h index dc3208690d..2753ae472a 100644 --- a/qml/test/testbridge.h +++ b/qml/test/testbridge.h @@ -23,6 +23,7 @@ /// /// Supported commands (JSON over newline-delimited stream): /// {"cmd": "get_current_page"} +/// {"cmd": "get_context_property", "name": ""} /// {"cmd": "get_property", "objectName": "", "prop": ""} /// {"cmd": "click", "objectName": ""} /// {"cmd": "set_text", "objectName": "", "text": ""} @@ -70,6 +71,7 @@ private Q_SLOTS: /// Dispatch individual command handlers. QByteArray cmdGetCurrentPage(); + QByteArray cmdGetContextProperty(const QString& name); QByteArray cmdGetProperty(const QString& object_name, const QString& prop); QByteArray cmdClick(const QString& object_name); QByteArray cmdSetText(const QString& object_name, const QString& text); diff --git a/test/functional/qml_driver.py b/test/functional/qml_driver.py index 182bcdc439..9431f5b45e 100644 --- a/test/functional/qml_driver.py +++ b/test/functional/qml_driver.py @@ -198,6 +198,15 @@ def list_objects(self): raise QmlDriverError(f"list_objects failed: {resp['error']}") return resp["objects"] + def get_context_property(self, name): + """Return metadata for a QQmlEngine root context property.""" + resp = self._send({"cmd": "get_context_property", "name": name}) + if "error" in resp: + raise QmlDriverError( + f"get_context_property({name!r}) failed: {resp['error']}" + ) + return resp + def save_screenshot(self, path): """Save a screenshot of the current QML window to a PNG file. diff --git a/test/functional/qml_test_disablewallet_boot.py b/test/functional/qml_test_disablewallet_boot.py new file mode 100755 index 0000000000..7c271a8585 --- /dev/null +++ b/test/functional/qml_test_disablewallet_boot.py @@ -0,0 +1,299 @@ +#!/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. +"""Smoke test for runtime -disablewallet node-only mode. + +This test walks onboarding with wallet support disabled at runtime and verifies +that the app lands on the node screen, hides wallet settings, and can open each +node settings page. + +This test requires: + - bitcoin-core-app built with -DENABLE_TEST_AUTOMATION=ON +""" + +import argparse +from datetime import datetime +import os +import re +import sys + +from qml_test_harness import ( + QmlTestHarness, + complete_onboarding, + dump_qml_tree, +) + + +POST_ONBOARDING_TIMEOUT_MS = 30000 +SETTINGS_TIMEOUT_MS = 10000 + + +def parse_args(): + parser = argparse.ArgumentParser( + description="Disablewallet node-only GUI functional test", + add_help=True, + ) + parser.add_argument( + "--socket-path", + help="Connect to an already-running bitcoin-core-app instance at " + "this Unix socket path instead of launching a new one.", + ) + 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_disablewallet_boot-{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): + self.index += 1 + prefix = f"[qml_test_disablewallet_boot] checkpoint {self.index:02d}" + print(f"{prefix}: {label}") + if gui is None: + return + + gui.settle() + + 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 wait_for_node_settings_idle(gui): + gui.wait_for_property( + "nodeSettingsStack", + "busy", + False, + timeout_ms=SETTINGS_TIMEOUT_MS, + ) + + +def wait_for_node_settings_root(gui): + gui.wait_for_page("gotoAboutSetting", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + + +def back_to_node_settings_root(gui, back_button): + gui.click(back_button) + wait_for_node_settings_root(gui) + + +def open_node_settings(gui): + gui.click("nodeSettingsButton") + wait_for_node_settings_root(gui) + + +def assert_wallet_ui_absent(gui): + for context_property in ("walletController", "walletListModel"): + value = gui.get_context_property(context_property) + assert value["exists"] is False, ( + f"{context_property} should not be exposed in -disablewallet mode: " + f"{value}" + ) + + objects = {entry["objectName"] for entry in gui.list_objects()} + unexpected = { + "createWalletWizard", + "createWalletIntroPage", + "walletBadge", + "desktopWalletsActivityTab", + "desktopWalletSettingsTabButton", + } & objects + assert not unexpected, ( + "Wallet UI should not be instantiated in -disablewallet mode: " + f"{sorted(unexpected)}" + ) + + wallet_settings_visible = gui.get_property("settingsWallet", "visible") + assert wallet_settings_visible is False, ( + "External Signer settings row should be hidden in -disablewallet mode, " + f"got {wallet_settings_visible!r}" + ) + + +def walk_about_settings(gui, checkpoints): + print(" Opening About settings") + gui.click("gotoAboutSetting") + gui.wait_for_page("settingsAbout", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("about settings opened", gui) + + gui.click("gotoDeveloperSetting") + gui.wait_for_page("settingsDeveloper", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("developer settings opened", gui) + gui.click("settingsDeveloperBack") + gui.wait_for_page("settingsAbout", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + + back_to_node_settings_root(gui, "settingsAboutBack") + checkpoints.checkpoint("returned from about settings", gui) + + +def walk_display_settings(gui, checkpoints): + print(" Opening Display settings") + gui.click("gotoDisplay") + gui.wait_for_page("gotoDisplayUnit", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("display settings opened", gui) + + gui.click("gotoDisplayUnit") + gui.wait_for_page("settingsDisplayUnitPage", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("display unit settings opened", gui) + gui.click("settingsDisplayUnitBack") + gui.wait_for_page("gotoDisplayUnit", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + + gui.click("gotoLanguage") + gui.wait_for_page("settingsLanguagePage", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("language settings opened", gui) + gui.click("settingsLanguageBack") + gui.wait_for_page("gotoLanguage", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + + back_to_node_settings_root(gui, "settingsDisplayBack") + checkpoints.checkpoint("returned from display settings", gui) + + +def walk_storage_settings(gui, checkpoints): + print(" Opening Storage settings") + gui.click("gotoStorage") + gui.wait_for_page("settingsStorageBack", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("storage settings opened", gui) + back_to_node_settings_root(gui, "settingsStorageBack") + checkpoints.checkpoint("returned from storage settings", gui) + + +def walk_connection_settings(gui, checkpoints): + print(" Opening Connection settings") + gui.click("settingsConnection") + gui.wait_for_page("gotoProxy", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("connection settings opened", gui) + + gui.click("gotoProxy") + gui.wait_for_page("settingsProxy", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("proxy settings opened", gui) + gui.click("settingsProxyBack") + gui.wait_for_page("gotoProxy", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + + back_to_node_settings_root(gui, "settingsConnectionBack") + checkpoints.checkpoint("returned from connection settings", gui) + + +def walk_peers_settings(gui, checkpoints): + print(" Opening Peers settings") + gui.click("settingsPeers") + gui.wait_for_page("peers", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("peers settings opened", gui) + back_to_node_settings_root(gui, "peersBackButton") + checkpoints.checkpoint("returned from peers settings", gui) + + +def walk_network_traffic_settings(gui, checkpoints): + print(" Opening Network Traffic settings") + gui.click("settingsNetworkTraffic") + gui.wait_for_page("networkTrafficBackButton", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("network traffic settings opened", gui) + back_to_node_settings_root(gui, "networkTrafficBackButton") + checkpoints.checkpoint("returned from network traffic settings", gui) + + +def walk_debug_log_settings(gui, checkpoints): + print(" Opening Debug Log settings") + gui.click("settingsDebugLog") + gui.wait_for_page("debugLogSearchField", timeout_ms=SETTINGS_TIMEOUT_MS) + wait_for_node_settings_idle(gui) + checkpoints.checkpoint("debug log settings opened", gui) + back_to_node_settings_root(gui, "debugLogBackButton") + checkpoints.checkpoint("returned from debug log settings", gui) + + +def run_tests(): + args = parse_args() + screenshot_root = make_screenshot_root() if args.save_screenshots else None + checkpoints = CheckpointRecorder(args.save_screenshots, screenshot_root) + harness = QmlTestHarness(socket_path=args.socket_path, extra_args=["-disablewallet"]) + try: + harness.start() + gui = harness.driver + checkpoints.checkpoint("GUI launched", gui) + + complete_onboarding(gui) + gui.wait_for_page("nodeSettingsButton", timeout_ms=POST_ONBOARDING_TIMEOUT_MS) + + current_page = gui.get_current_page() + assert current_page == "nodeRunner", ( + f"Expected -disablewallet onboarding to exit to nodeRunner, got {current_page!r}" + ) + print("Reached node-only main screen") + checkpoints.checkpoint("node-only main screen reached", gui) + + open_node_settings(gui) + assert_wallet_ui_absent(gui) + checkpoints.checkpoint("node settings opened without wallet UI", gui) + + walk_about_settings(gui, checkpoints) + walk_display_settings(gui, checkpoints) + walk_storage_settings(gui, checkpoints) + walk_connection_settings(gui, checkpoints) + walk_peers_settings(gui, checkpoints) + walk_network_traffic_settings(gui, checkpoints) + walk_debug_log_settings(gui, checkpoints) + + gui.click("nodeSettingsDoneButton") + gui.wait_for_page("nodeSettingsButton", timeout_ms=SETTINGS_TIMEOUT_MS) + checkpoints.checkpoint("node settings closed", gui) + + except Exception as e: + print(f"\nFAILED: {e}", file=sys.stderr) + import traceback + traceback.print_exc() + if harness.driver: + checkpoints.checkpoint("failure state", harness.driver) + dump_qml_tree(harness.driver) + sys.exit(1) + finally: + harness.stop() + + print("\n" + "=" * 60) + print("Disablewallet node-only boot test PASSED") + print("=" * 60) + + +if __name__ == "__main__": + run_tests()