From 08b3d6d50b3d87d7f6e6ca37a9f4b812fb40c90d Mon Sep 17 00:00:00 2001 From: johnny9 <985648+johnny9@users.noreply.github.com> Date: Mon, 9 Mar 2026 06:41:47 -0400 Subject: [PATCH 1/3] Introduce qt test bridge --- CMakeLists.txt | 3 +- src/qt/CMakeLists.txt | 13 +- src/qt/bitcoin.cpp | 16 + src/qt/bitcoin.h | 4 + src/qt/bitcoingui.cpp | 9 + src/qt/sendcoinsdialog.cpp | 6 + src/qt/testbridge.cpp | 853 +++++++++++++++++++++++++++ src/qt/testbridge.h | 96 +++ src/qt/walletframe.cpp | 5 + src/qt/walletview.cpp | 5 + test/functional/gui_wallet_send.py | 198 +++++++ test/functional/test_framework/qt.py | 197 +++++++ test/functional/test_runner.py | 1 + 13 files changed, 1404 insertions(+), 2 deletions(-) create mode 100644 src/qt/testbridge.cpp create mode 100644 src/qt/testbridge.h create mode 100644 test/functional/gui_wallet_send.py create mode 100644 test/functional/test_framework/qt.py diff --git a/CMakeLists.txt b/CMakeLists.txt index 518e02776d4..3b76b65bdc2 100644 --- a/CMakeLists.txt +++ b/CMakeLists.txt @@ -102,6 +102,7 @@ include(CMakeDependentOption) option(BUILD_BITCOIN_BIN "Build bitcoin executable." ON) option(BUILD_DAEMON "Build bitcoind executable." ON) option(BUILD_GUI "Build bitcoin-qt executable." OFF) +option(ENABLE_TEST_AUTOMATION "Enable test automation bridge for Qt widget UI testing." OFF) option(BUILD_CLI "Build bitcoin-cli executable." ON) option(BUILD_TESTS "Build test_bitcoin and other unit test executables." ON) @@ -161,7 +162,7 @@ endif() cmake_dependent_option(BUILD_GUI_TESTS "Build test_bitcoin-qt executable." ON "BUILD_GUI;BUILD_TESTS" OFF) if(BUILD_GUI) set(qt_components Core Gui Widgets LinguistTools) - if(ENABLE_WALLET) + if(ENABLE_WALLET OR ENABLE_TEST_AUTOMATION) list(APPEND qt_components Network) endif() if(WITH_DBUS) diff --git a/src/qt/CMakeLists.txt b/src/qt/CMakeLists.txt index 58fa08b56ab..1811fd1ecf2 100644 --- a/src/qt/CMakeLists.txt +++ b/src/qt/CMakeLists.txt @@ -130,6 +130,14 @@ add_library(bitcoinqt STATIC EXCLUDE_FROM_ALL ${BITCOIN_QRC} ${BITCOIN_LOCALE_QRC} ) +if(ENABLE_TEST_AUTOMATION) + target_sources(bitcoinqt + PRIVATE + testbridge.cpp + testbridge.h + ) + target_compile_definitions(bitcoinqt PUBLIC ENABLE_TEST_AUTOMATION) +endif() target_compile_definitions(bitcoinqt PUBLIC QT_NO_KEYWORDS @@ -223,10 +231,13 @@ if(ENABLE_WALLET) target_link_libraries(bitcoinqt PRIVATE bitcoin_wallet - Qt6::Network ) endif() +if(ENABLE_WALLET OR ENABLE_TEST_AUTOMATION) + target_link_libraries(bitcoinqt PRIVATE Qt6::Network) +endif() + if(WITH_DBUS) target_link_libraries(bitcoinqt PRIVATE Qt6::DBus) endif() diff --git a/src/qt/bitcoin.cpp b/src/qt/bitcoin.cpp index 92c815fe70e..0fdde1b18e8 100644 --- a/src/qt/bitcoin.cpp +++ b/src/qt/bitcoin.cpp @@ -28,6 +28,9 @@ #include #include #include +#ifdef ENABLE_TEST_AUTOMATION +#include +#endif #include #include #include @@ -262,6 +265,16 @@ void BitcoinApplication::createWindow(const NetworkStyle *networkStyle) window = new BitcoinGUI(node(), platformStyle, networkStyle, nullptr); connect(window, &BitcoinGUI::quitRequested, this, &BitcoinApplication::requestShutdown); +#ifdef ENABLE_TEST_AUTOMATION + if (gArgs.IsArgSet("-test-automation")) { + QString socket_path = QString::fromStdString(gArgs.GetArg("-test-automation", "")); + if (socket_path.isEmpty()) { + socket_path = QString::fromStdString((gArgs.GetDataDirNet() / "test_bridge.sock").utf8string()); + } + m_test_bridge = std::make_unique(window, socket_path); + } +#endif + pollShutdownTimer = new QTimer(window); connect(pollShutdownTimer, &QTimer::timeout, [this]{ if (!QApplication::activeModalWidget()) { @@ -475,6 +488,9 @@ static void SetupUIArgs(ArgsManager& argsman) argsman.AddArg("-resetguisettings", "Reset all settings changed in the GUI", ArgsManager::ALLOW_ANY, OptionsCategory::GUI); argsman.AddArg("-splash", strprintf("Show splash screen on startup (default: %u)", DEFAULT_SPLASHSCREEN), ArgsManager::ALLOW_ANY, OptionsCategory::GUI); argsman.AddArg("-uiplatform", strprintf("Select platform to customize UI for (one of windows, macosx, other; default: %s)", BitcoinGUI::DEFAULT_UIPLATFORM), ArgsManager::ALLOW_ANY | ArgsManager::DEBUG_ONLY, OptionsCategory::GUI); +#ifdef ENABLE_TEST_AUTOMATION + argsman.AddArg("-test-automation=", "Enable test automation bridge on the given local socket path", ArgsManager::ALLOW_ANY, OptionsCategory::GUI); +#endif } int GuiMain(int argc, char* argv[]) diff --git a/src/qt/bitcoin.h b/src/qt/bitcoin.h index abc478bd456..3f2d30a14a6 100644 --- a/src/qt/bitcoin.h +++ b/src/qt/bitcoin.h @@ -23,6 +23,7 @@ class OptionsModel; class PaymentServer; class PlatformStyle; class SplashScreen; +class TestBridge; class WalletController; class WalletModel; namespace interfaces { @@ -98,6 +99,9 @@ public Q_SLOTS: #ifdef ENABLE_WALLET PaymentServer* paymentServer{nullptr}; WalletController* m_wallet_controller{nullptr}; +#endif +#ifdef ENABLE_TEST_AUTOMATION + std::unique_ptr m_test_bridge; #endif const PlatformStyle* platformStyle{nullptr}; std::unique_ptr shutdownWindow; diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 3100a055bb7..33d08c8aef9 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -95,6 +95,8 @@ BitcoinGUI::BitcoinGUI(interfaces::Node& node, const PlatformStyle *_platformSty platformStyle(_platformStyle), m_network_style(networkStyle) { + setObjectName("mainWindow"); + QSettings settings; if (!restoreGeometry(settings.value("MainWindowGeometry").toByteArray())) { // Restore failed (perhaps missing setting), center the window @@ -111,12 +113,15 @@ BitcoinGUI::BitcoinGUI(interfaces::Node& node, const PlatformStyle *_platformSty updateWindowTitle(); rpcConsole = new RPCConsole(node, _platformStyle, nullptr); + rpcConsole->setObjectName("rpcConsole"); helpMessageDialog = new HelpMessageDialog(this, false); + helpMessageDialog->setObjectName("helpMessageDialog"); #ifdef ENABLE_WALLET if(enableWallet) { /** Create wallet frame and make it the central widget */ walletFrame = new WalletFrame(_platformStyle, this); + walletFrame->setObjectName("walletFrame"); connect(walletFrame, &WalletFrame::createWalletButtonClicked, this, &BitcoinGUI::createWallet); connect(walletFrame, &WalletFrame::message, [this](const QString& title, const QString& message, unsigned int style) { this->message(title, message, style); @@ -256,6 +261,7 @@ void BitcoinGUI::createActions() connect(modalOverlay, &ModalOverlay::triggered, tabGroup, &QActionGroup::setEnabled); overviewAction = new QAction(platformStyle->SingleColorIcon(":/icons/overview"), tr("&Overview"), this); + overviewAction->setObjectName("overviewAction"); overviewAction->setStatusTip(tr("Show general overview of wallet")); overviewAction->setToolTip(overviewAction->statusTip()); overviewAction->setCheckable(true); @@ -263,6 +269,7 @@ void BitcoinGUI::createActions() tabGroup->addAction(overviewAction); sendCoinsAction = new QAction(platformStyle->SingleColorIcon(":/icons/send"), tr("&Send"), this); + sendCoinsAction->setObjectName("sendCoinsAction"); sendCoinsAction->setStatusTip(tr("Send coins to a Bitcoin address")); sendCoinsAction->setToolTip(sendCoinsAction->statusTip()); sendCoinsAction->setCheckable(true); @@ -270,6 +277,7 @@ void BitcoinGUI::createActions() tabGroup->addAction(sendCoinsAction); receiveCoinsAction = new QAction(platformStyle->SingleColorIcon(":/icons/receiving_addresses"), tr("&Receive"), this); + receiveCoinsAction->setObjectName("receiveCoinsAction"); receiveCoinsAction->setStatusTip(tr("Request payments (generates QR codes and bitcoin: URIs)")); receiveCoinsAction->setToolTip(receiveCoinsAction->statusTip()); receiveCoinsAction->setCheckable(true); @@ -277,6 +285,7 @@ void BitcoinGUI::createActions() tabGroup->addAction(receiveCoinsAction); historyAction = new QAction(platformStyle->SingleColorIcon(":/icons/history"), tr("&Transactions"), this); + historyAction->setObjectName("historyAction"); historyAction->setStatusTip(tr("Browse transaction history")); historyAction->setToolTip(historyAction->statusTip()); historyAction->setCheckable(true); diff --git a/src/qt/sendcoinsdialog.cpp b/src/qt/sendcoinsdialog.cpp index d4a63987f47..9726dcad1a8 100644 --- a/src/qt/sendcoinsdialog.cpp +++ b/src/qt/sendcoinsdialog.cpp @@ -589,6 +589,7 @@ void SendCoinsDialog::accept() SendCoinsEntry *SendCoinsDialog::addEntry() { SendCoinsEntry *entry = new SendCoinsEntry(platformStyle, this); + entry->setObjectName(QStringLiteral("sendCoinsEntry_%1").arg(ui->entries->count())); entry->setModel(model); ui->entries->addWidget(entry); connect(entry, &SendCoinsEntry::removeEntry, this, &SendCoinsDialog::removeEntry); @@ -1055,6 +1056,7 @@ void SendCoinsDialog::coinControlUpdateLabels() SendConfirmationDialog::SendConfirmationDialog(const QString& title, const QString& text, const QString& informative_text, const QString& detailed_text, int _secDelay, bool enable_send, bool always_show_unsigned, QWidget* parent) : QMessageBox(parent), secDelay(_secDelay), m_enable_send(enable_send) { + setObjectName("sendConfirmationDialog"); setIcon(QMessageBox::Question); setWindowTitle(title); // On macOS, the window title is ignored (as required by the macOS Guidelines). setText(text); @@ -1064,10 +1066,14 @@ SendConfirmationDialog::SendConfirmationDialog(const QString& title, const QStri if (always_show_unsigned || !enable_send) addButton(QMessageBox::Save); setDefaultButton(QMessageBox::Cancel); yesButton = button(QMessageBox::Yes); + yesButton->setObjectName("sendConfirmButton"); if (confirmButtonText.isEmpty()) { confirmButtonText = yesButton->text(); } m_psbt_button = button(QMessageBox::Save); + if (m_psbt_button) { + m_psbt_button->setObjectName("createUnsignedButton"); + } updateButtons(); connect(&countDownTimer, &QTimer::timeout, this, &SendConfirmationDialog::countDown); } diff --git a/src/qt/testbridge.cpp b/src/qt/testbridge.cpp new file mode 100644 index 00000000000..b4f04af022e --- /dev/null +++ b/src/qt/testbridge.cpp @@ -0,0 +1,853 @@ +// 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. + +#include + +#include +#include +#include +#ifdef ENABLE_WALLET +#include +#include +#endif // ENABLE_WALLET + +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include +#include + +namespace { +QString ObjectLabel(const QObject* object) +{ + if (!object) return {}; + return object->objectName().isEmpty() ? QString::fromLatin1(object->metaObject()->className()) + : object->objectName(); +} + +QString WindowLabel(const QWidget* window) +{ + return ObjectLabel(window); +} + +bool WindowMatches(const QWidget* window, const QString& name) +{ + if (!window || name.isEmpty()) return false; + return window->objectName() == name || QString::fromLatin1(window->metaObject()->className()) == name; +} + +bool IsVisibleObject(const QObject* object) +{ + if (const auto* widget = qobject_cast(object)) return widget->isVisible(); + return true; +} + +template +bool InvokeMethodByName(QObject* object, const char* signature, Fn&& invoker) +{ + const int index = object->metaObject()->indexOfMethod(signature); + if (index < 0) return false; + return invoker(object->metaObject()->method(index)); +} + +QObject* FindObjectInSubtree(QObject* root, const QString& object_name) +{ + if (!root || object_name.isEmpty()) return nullptr; + if (root->objectName() == object_name) return root; + + const QList matches = root->findChildren(object_name); + if (matches.isEmpty()) return nullptr; + + for (QObject* match : matches) { + if (IsVisibleObject(match)) return match; + } + return matches.first(); +} + +QLineEdit* SpinBoxLineEdit(QAbstractSpinBox* spin_box) +{ + return spin_box ? spin_box->findChild() : nullptr; +} + +bool WriteTextToObject(QObject* object, const QString& text) +{ + if (auto* amount_field = qobject_cast(object)) { + CAmount amount{0}; + if (!BitcoinUnits::parse(BitcoinUnit::BTC, text, &amount)) return false; + amount_field->setValue(amount); + return true; + } + + if (auto* line_edit = qobject_cast(object)) { + line_edit->setText(text); + InvokeMethodByName(line_edit, "textEdited(QString)", [&](const QMetaMethod& method) { + return method.invoke(line_edit, Qt::DirectConnection, Q_ARG(QString, text)); + }); + InvokeMethodByName(line_edit, "editingFinished()", [&](const QMetaMethod& method) { + return method.invoke(line_edit, Qt::DirectConnection); + }); + return true; + } + + if (auto* combo_box = qobject_cast(object)) { + if (combo_box->isEditable()) { + combo_box->setEditText(text); + } else { + const int index = combo_box->findText(text); + if (index >= 0) { + combo_box->setCurrentIndex(index); + } else { + combo_box->setCurrentText(text); + } + } + return true; + } + + if (auto* text_edit = qobject_cast(object)) { + text_edit->setPlainText(text); + return true; + } + + if (auto* plain_text_edit = qobject_cast(object)) { + plain_text_edit->setPlainText(text); + return true; + } + + if (auto* spin_box = qobject_cast(object)) { + QLineEdit* line_edit = SpinBoxLineEdit(spin_box); + if (!line_edit) return false; + line_edit->setText(text); + spin_box->interpretText(); + InvokeMethodByName(spin_box, "editingFinished()", [&](const QMetaMethod& method) { + return method.invoke(spin_box, Qt::DirectConnection); + }); + return true; + } + + if (auto* child_spin_box = object->findChild()) { + QLineEdit* line_edit = SpinBoxLineEdit(child_spin_box); + if (!line_edit) return false; + line_edit->setText(text); + child_spin_box->interpretText(); + InvokeMethodByName(child_spin_box, "editingFinished()", [&](const QMetaMethod& method) { + return method.invoke(child_spin_box, Qt::DirectConnection); + }); + return true; + } + + if (object->property("text").isValid()) { + object->setProperty("text", text); + InvokeMethodByName(object, "textEdited(QString)", [&](const QMetaMethod& method) { + return method.invoke(object, Qt::DirectConnection, Q_ARG(QString, text)); + }); + InvokeMethodByName(object, "editingFinished()", [&](const QMetaMethod& method) { + return method.invoke(object, Qt::DirectConnection); + }); + return true; + } + + return false; +} + +std::optional ReadTextFromObject(QObject* object) +{ + if (!object) return std::nullopt; + + if (auto* line_edit = qobject_cast(object)) return line_edit->text(); + if (auto* label = qobject_cast(object)) return label->text(); + if (auto* button = qobject_cast(object)) return button->text(); + if (auto* combo_box = qobject_cast(object)) return combo_box->currentText(); + if (auto* text_edit = qobject_cast(object)) return text_edit->toPlainText(); + if (auto* plain_text_edit = qobject_cast(object)) return plain_text_edit->toPlainText(); + if (auto* amount_field = qobject_cast(object)) { + return BitcoinUnits::format(BitcoinUnit::BTC, amount_field->value(), false, BitcoinUnits::SeparatorStyle::NEVER); + } + if (auto* spin_box = qobject_cast(object)) { + if (QLineEdit* line_edit = SpinBoxLineEdit(spin_box)) return line_edit->text(); + } + if (auto* action = qobject_cast(object)) return action->text(); + + if (const QVariant text_value = object->property("text"); text_value.isValid()) { + return text_value.toString(); + } + + if (auto* child_spin_box = object->findChild()) { + if (QLineEdit* line_edit = SpinBoxLineEdit(child_spin_box)) return line_edit->text(); + } + + return std::nullopt; +} +} // namespace + +TestBridge::TestBridge(BitcoinGUI* gui, const QString& socket_path, QObject* parent) + : QObject(parent), m_gui(gui), m_server(new QLocalServer(this)) +{ + QLocalServer::removeServer(socket_path); + + if (!m_server->listen(socket_path)) { + qWarning("TestBridge: failed to listen on %s: %s", + qPrintable(socket_path), + qPrintable(m_server->errorString())); + return; + } + + connect(m_server, &QLocalServer::newConnection, this, &TestBridge::handleNewConnection); + qInfo("TestBridge: listening on %s", qPrintable(socket_path)); +} + +TestBridge::~TestBridge() +{ + for (auto* client : m_clients) { + client->disconnectFromServer(); + client->deleteLater(); + } + m_server->close(); +} + +void TestBridge::handleNewConnection() +{ + while (QLocalSocket* client = m_server->nextPendingConnection()) { + m_clients.push_back(client); + m_read_buffers.insert(client, QByteArray{}); + connect(client, &QLocalSocket::readyRead, this, &TestBridge::handleClientData); + connect(client, &QLocalSocket::disconnected, this, &TestBridge::handleClientDisconnected); + qInfo("TestBridge: client connected"); + } +} + +void TestBridge::handleClientData() +{ + if (m_processing_client_data) { + m_pending_client_data = true; + return; + } + + QScopedValueRollback processing_guard(m_processing_client_data, true); + + do { + m_pending_client_data = false; + std::vector> clients_snapshot; + clients_snapshot.reserve(m_clients.size()); + for (QLocalSocket* client : m_clients) { + clients_snapshot.emplace_back(client); + } + for (const QPointer& client : clients_snapshot) { + if (!client) continue; + processClientCommands(client.data()); + } + } while (m_pending_client_data); +} + +void TestBridge::processClientCommands(QLocalSocket* client) +{ + if (!client) return; + + QByteArray& read_buffer = m_read_buffers[client]; + if (client->bytesAvailable() > 0) { + read_buffer.append(client->readAll()); + } + + int newline_pos; + while ((newline_pos = read_buffer.indexOf('\n')) != -1) { + QByteArray line = read_buffer.left(newline_pos); + read_buffer.remove(0, newline_pos + 1); + + if (line.trimmed().isEmpty()) continue; + + QByteArray response = processCommand(line); + if (client->state() != QLocalSocket::ConnectedState) { + return; + } + response.append('\n'); + client->write(response); + client->flush(); + } +} + +void TestBridge::handleClientDisconnected() +{ + auto* client = qobject_cast(sender()); + if (!client) return; + + const auto it = std::find(m_clients.begin(), m_clients.end(), client); + if (it != m_clients.end()) { + m_clients.erase(it); + } + m_read_buffers.remove(client); + client->deleteLater(); + qInfo("TestBridge: client disconnected"); +} + +QWidget* TestBridge::defaultWindow() const +{ + if (QWidget* modal = QApplication::activeModalWidget()) return modal; + if (QWidget* active = QApplication::activeWindow()) return active; + return m_gui; +} + +std::vector TestBridge::listWindows() const +{ + std::vector windows; + windows.reserve(QApplication::topLevelWidgets().size()); + + QWidget* active_window = QApplication::activeWindow(); + for (QWidget* widget : QApplication::topLevelWidgets()) { + if (!widget || !widget->isWindow() || qobject_cast(widget)) continue; + + windows.push_back(WindowEntry{ + .window_name = WindowLabel(widget), + .class_name = QString::fromLatin1(widget->metaObject()->className()), + .title = widget->windowTitle(), + .active = widget == active_window, + .visible = widget->isVisible(), + }); + } + + return windows; +} + +QWidget* TestBridge::findWindow(const QString& name) const +{ + if (name.isEmpty()) return defaultWindow(); + + QWidget* default_window = defaultWindow(); + if (WindowMatches(default_window, name)) return default_window; + + for (QWidget* widget : QApplication::topLevelWidgets()) { + if (!widget || !widget->isWindow() || qobject_cast(widget)) continue; + if (WindowMatches(widget, name)) return widget; + } + + return nullptr; +} + +QWidget* TestBridge::currentView(QWidget* window) const +{ + if (!window) return nullptr; + +#ifdef ENABLE_WALLET + if (auto* gui = qobject_cast(window)) { + if (auto* wallet_frame = qobject_cast(gui->centralWidget())) { + if (auto* wallet_view = wallet_frame->currentWalletView()) { + if (QWidget* current = wallet_view->currentWidget()) return current; + return wallet_view; + } + return wallet_frame; + } + } +#endif // ENABLE_WALLET + + return window; +} + +QObject* TestBridge::findObject(const QString& object_name, const QString& window_name) const +{ + QWidget* window = findWindow(window_name); + if (!window) return nullptr; + + if (QObject* match = FindObjectInSubtree(currentView(window), object_name)) { + return match; + } + return FindObjectInSubtree(window, object_name); +} + +void TestBridge::collectNamedObjects(QObject* root, std::vector& results, QSet& visited, int depth) const +{ + if (!root || visited.contains(root)) return; + visited.insert(root); + + if (!root->objectName().isEmpty()) { + results.push_back(NamedObjectEntry{ + .object_name = root->objectName(), + .class_name = QString::fromLatin1(root->metaObject()->className()), + .depth = depth, + .visible = IsVisibleObject(root), + }); + } + + for (QObject* child : root->children()) { + collectNamedObjects(child, results, visited, depth + 1); + } +} + +QByteArray TestBridge::processCommand(const QByteArray& json_cmd) +{ + QJsonParseError parse_error; + const QJsonDocument doc = QJsonDocument::fromJson(json_cmd, &parse_error); + if (doc.isNull()) { + return errorResponse(QStringLiteral("JSON parse error: %1").arg(parse_error.errorString())); + } + + const QJsonObject obj = doc.object(); + const QString cmd = obj.value(QStringLiteral("cmd")).toString(); + const QString window_name = obj.value(QStringLiteral("window")).toString(); + + if (cmd == QLatin1String("list_windows")) { + return cmdListWindows(); + } else if (cmd == QLatin1String("get_active_window")) { + return cmdGetActiveWindow(); + } else if (cmd == QLatin1String("get_current_view") || cmd == QLatin1String("get_current_page")) { + return cmdGetCurrentView(window_name); + } else if (cmd == QLatin1String("get_property")) { + return cmdGetProperty( + window_name, + obj.value(QStringLiteral("objectName")).toString(), + obj.value(QStringLiteral("prop")).toString()); + } else if (cmd == QLatin1String("click")) { + return cmdClick(window_name, obj.value(QStringLiteral("objectName")).toString()); + } else if (cmd == QLatin1String("set_text")) { + return cmdSetText( + window_name, + obj.value(QStringLiteral("objectName")).toString(), + obj.value(QStringLiteral("text")).toString()); + } else if (cmd == QLatin1String("wait_for_window")) { + return cmdWaitForWindow( + obj.value(QStringLiteral("window")).toString(), + obj.value(QStringLiteral("timeout")).toInt(5000)); + } else if (cmd == QLatin1String("wait_for_view") || cmd == QLatin1String("wait_for_page")) { + return cmdWaitForView( + window_name, + obj.value(QStringLiteral("view")).toString(obj.value(QStringLiteral("page")).toString()), + obj.value(QStringLiteral("timeout")).toInt(5000)); + } else if (cmd == QLatin1String("wait_for_property")) { + return cmdWaitForProperty( + window_name, + obj.value(QStringLiteral("objectName")).toString(), + obj.value(QStringLiteral("prop")).toString(), + obj.value(QStringLiteral("timeout")).toInt(5000), + obj.value(QStringLiteral("value")), + obj.contains(QStringLiteral("value")), + obj.value(QStringLiteral("contains")).toString(), + obj.value(QStringLiteral("nonEmpty")).toBool(false)); + } else if (cmd == QLatin1String("get_text")) { + return cmdGetText(window_name, obj.value(QStringLiteral("objectName")).toString()); + } else if (cmd == QLatin1String("save_screenshot")) { + return cmdSaveScreenshot(window_name, obj.value(QStringLiteral("path")).toString()); + } else if (cmd == QLatin1String("list_objects")) { + return cmdListObjects(window_name); + } + + return errorResponse(QStringLiteral("Unknown command: %1").arg(cmd)); +} + +QByteArray TestBridge::cmdListWindows() +{ + const auto windows = listWindows(); + + QJsonArray arr; + for (const auto& entry : windows) { + QJsonObject json_entry; + json_entry[QStringLiteral("window")] = entry.window_name; + json_entry[QStringLiteral("className")] = entry.class_name; + json_entry[QStringLiteral("title")] = entry.title; + json_entry[QStringLiteral("active")] = entry.active; + json_entry[QStringLiteral("visible")] = entry.visible; + arr.append(json_entry); + } + + QJsonObject resp; + resp[QStringLiteral("windows")] = arr; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::cmdGetActiveWindow() +{ + QWidget* window = defaultWindow(); + if (!window) { + return errorResponse(QStringLiteral("No active window")); + } + + QJsonObject resp; + resp[QStringLiteral("window")] = WindowLabel(window); + resp[QStringLiteral("className")] = QString::fromLatin1(window->metaObject()->className()); + resp[QStringLiteral("title")] = window->windowTitle(); + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::cmdGetCurrentView(const QString& window_name) +{ + QWidget* window = findWindow(window_name); + if (!window) { + return errorResponse(QStringLiteral("Window not found: %1").arg(window_name)); + } + + QWidget* view = currentView(window); + if (!view) { + return errorResponse(QStringLiteral("Could not determine current view for window: %1").arg(WindowLabel(window))); + } + + QJsonObject resp; + resp[QStringLiteral("window")] = WindowLabel(window); + resp[QStringLiteral("view")] = ObjectLabel(view); + resp[QStringLiteral("className")] = QString::fromLatin1(view->metaObject()->className()); + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::cmdGetProperty(const QString& window_name, const QString& object_name, const QString& prop) +{ + if (object_name.isEmpty() || prop.isEmpty()) { + return errorResponse(QStringLiteral("objectName and prop are required")); + } + + QObject* object = findObject(object_name, window_name); + if (!object) { + return errorResponse(QStringLiteral("Object not found: %1").arg(object_name)); + } + + const QVariant value = object->property(prop.toLatin1().constData()); + if (!value.isValid()) { + return errorResponse(QStringLiteral("Property not found: %1.%2").arg(object_name, prop)); + } + + QJsonObject resp; + resp[QStringLiteral("value")] = QJsonValue::fromVariant(value); + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::cmdClick(const QString& window_name, const QString& object_name) +{ + if (object_name.isEmpty()) { + return errorResponse(QStringLiteral("objectName is required")); + } + + QObject* object = findObject(object_name, window_name); + if (!object) { + return errorResponse(QStringLiteral("Object not found: %1").arg(object_name)); + } + + auto okResponse = []() { + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + }; + + auto queueInvoke = [](QObject* target, auto&& fn) { + QTimer::singleShot(0, target, std::forward(fn)); + }; + + if (auto* action = qobject_cast(object)) { + queueInvoke(action, [action] { action->trigger(); }); + return okResponse(); + } + + if (auto* button = qobject_cast(object)) { + if (!button->isEnabled()) { + return errorResponse(QStringLiteral("Object is disabled: %1").arg(object_name)); + } + queueInvoke(button, [button] { button->click(); }); + return okResponse(); + } + + if (InvokeMethodByName(object, "click()", [&](const QMetaMethod& method) { + queueInvoke(object, [object, method] { method.invoke(object, Qt::DirectConnection); }); + return true; + })) { + return okResponse(); + } + + if (InvokeMethodByName(object, "trigger()", [&](const QMetaMethod& method) { + queueInvoke(object, [object, method] { method.invoke(object, Qt::DirectConnection); }); + return true; + })) { + return okResponse(); + } + + auto* widget = qobject_cast(object); + if (!widget) { + return errorResponse(QStringLiteral("Cannot click object: %1").arg(object_name)); + } + if (!widget->isVisible() || !widget->isEnabled()) { + return errorResponse(QStringLiteral("Object is not clickable: %1").arg(object_name)); + } + + const QPoint local_pos = widget->rect().center(); + const QPoint global_pos = widget->mapToGlobal(local_pos); + queueInvoke(widget, [widget, local_pos, global_pos] { + QMouseEvent press(QEvent::MouseButtonPress, QPointF(local_pos), QPointF(global_pos), + Qt::LeftButton, Qt::LeftButton, Qt::NoModifier); + QMouseEvent release(QEvent::MouseButtonRelease, QPointF(local_pos), QPointF(global_pos), + Qt::LeftButton, Qt::NoButton, Qt::NoModifier); + QCoreApplication::sendEvent(widget, &press); + QCoreApplication::sendEvent(widget, &release); + }); + return okResponse(); +} + +QByteArray TestBridge::cmdSetText(const QString& window_name, const QString& object_name, const QString& text) +{ + if (object_name.isEmpty()) { + return errorResponse(QStringLiteral("objectName is required")); + } + + QObject* object = findObject(object_name, window_name); + if (!object) { + return errorResponse(QStringLiteral("Object not found: %1").arg(object_name)); + } + + if (!WriteTextToObject(object, text)) { + return errorResponse(QStringLiteral("Object %1 does not support text entry").arg(object_name)); + } + + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::cmdWaitForWindow(const QString& window_name, int timeout_ms) +{ + if (window_name.isEmpty()) { + return errorResponse(QStringLiteral("window is required")); + } + + auto conditionMatched = [&]() { + QWidget* window = findWindow(window_name); + return window && window->isVisible(); + }; + + if (conditionMatched()) { + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + } + + QEventLoop wait_loop; + QTimer timeout_timer; + timeout_timer.setSingleShot(true); + timeout_timer.setInterval(std::max(0, timeout_ms)); + + QTimer poll_timer; + poll_timer.setInterval(50); + + QObject::connect(&timeout_timer, &QTimer::timeout, &wait_loop, &QEventLoop::quit); + QObject::connect(&poll_timer, &QTimer::timeout, &wait_loop, [&]() { + if (conditionMatched()) wait_loop.quit(); + }); + + timeout_timer.start(); + poll_timer.start(); + wait_loop.exec(); + + if (conditionMatched()) { + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + } + + return errorResponse(QStringLiteral("Timed out waiting for window: %1").arg(window_name)); +} + +QByteArray TestBridge::cmdWaitForView(const QString& window_name, const QString& view_name, int timeout_ms) +{ + if (view_name.isEmpty()) { + return errorResponse(QStringLiteral("view is required")); + } + + auto conditionMatched = [&]() { + QWidget* view = currentView(findWindow(window_name)); + return view && ObjectLabel(view) == view_name && view->isVisible(); + }; + + if (conditionMatched()) { + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + } + + QEventLoop wait_loop; + QTimer timeout_timer; + timeout_timer.setSingleShot(true); + timeout_timer.setInterval(std::max(0, timeout_ms)); + + QTimer poll_timer; + poll_timer.setInterval(50); + + QObject::connect(&timeout_timer, &QTimer::timeout, &wait_loop, &QEventLoop::quit); + QObject::connect(&poll_timer, &QTimer::timeout, &wait_loop, [&]() { + if (conditionMatched()) wait_loop.quit(); + }); + + timeout_timer.start(); + poll_timer.start(); + wait_loop.exec(); + + if (conditionMatched()) { + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + } + + return errorResponse(QStringLiteral("Timed out waiting for view: %1").arg(view_name)); +} + +QByteArray TestBridge::cmdWaitForProperty(const QString& window_name, const QString& object_name, const QString& prop, int timeout_ms, const QJsonValue& expected, bool has_expected, const QString& contains, bool non_empty) +{ + if (object_name.isEmpty() || prop.isEmpty()) { + return errorResponse(QStringLiteral("objectName and prop are required")); + } + + auto conditionMatched = [&](QVariant* out_value) { + QObject* object = findObject(object_name, window_name); + if (!object) return false; + + const QVariant value = object->property(prop.toLatin1().constData()); + if (!value.isValid()) return false; + + bool matched = true; + if (!contains.isEmpty()) { + matched = value.toString().contains(contains); + } else if (non_empty) { + matched = !value.toString().trimmed().isEmpty(); + } else if (has_expected) { + matched = (QJsonValue::fromVariant(value) == expected); + } + + if (!matched) return false; + if (out_value) *out_value = value; + return true; + }; + + QVariant matched_value; + if (conditionMatched(&matched_value)) { + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + resp[QStringLiteral("value")] = QJsonValue::fromVariant(matched_value); + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + } + + QEventLoop wait_loop; + QTimer timeout_timer; + timeout_timer.setSingleShot(true); + timeout_timer.setInterval(std::max(0, timeout_ms)); + + QTimer poll_timer; + poll_timer.setInterval(50); + + QObject::connect(&timeout_timer, &QTimer::timeout, &wait_loop, &QEventLoop::quit); + QObject::connect(&poll_timer, &QTimer::timeout, &wait_loop, [&]() { + if (conditionMatched(nullptr)) wait_loop.quit(); + }); + + timeout_timer.start(); + poll_timer.start(); + wait_loop.exec(); + + if (conditionMatched(&matched_value)) { + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + resp[QStringLiteral("value")] = QJsonValue::fromVariant(matched_value); + return QJsonDocument(resp).toJson(QJsonDocument::Compact); + } + + return errorResponse(QStringLiteral("Timed out waiting for property: %1.%2").arg(object_name, prop)); +} + +QByteArray TestBridge::cmdGetText(const QString& window_name, const QString& object_name) +{ + if (object_name.isEmpty()) { + return errorResponse(QStringLiteral("objectName is required")); + } + + QObject* object = findObject(object_name, window_name); + if (!object) { + return errorResponse(QStringLiteral("Object not found: %1").arg(object_name)); + } + + const auto text = ReadTextFromObject(object); + if (!text.has_value()) { + return errorResponse(QStringLiteral("Object %1 has no readable text").arg(object_name)); + } + + QJsonObject resp; + resp[QStringLiteral("text")] = *text; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::cmdSaveScreenshot(const QString& window_name, const QString& path) +{ + if (path.isEmpty()) { + return errorResponse(QStringLiteral("path is required")); + } + + QWidget* window = findWindow(window_name); + if (!window) { + return errorResponse(QStringLiteral("Window not found: %1").arg(window_name)); + } + + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + QThread::msleep(50); + QCoreApplication::processEvents(QEventLoop::AllEvents, 50); + + const QImage image = window->grab().toImage(); + if (image.isNull()) { + return errorResponse(QStringLiteral("Failed to capture screenshot")); + } + + if (!image.save(path, "PNG")) { + return errorResponse(QStringLiteral("Failed to save screenshot: %1").arg(path)); + } + + QJsonObject resp; + resp[QStringLiteral("ok")] = true; + resp[QStringLiteral("path")] = path; + resp[QStringLiteral("width")] = image.width(); + resp[QStringLiteral("height")] = image.height(); + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::cmdListObjects(const QString& window_name) +{ + QWidget* window = findWindow(window_name); + if (!window) { + return errorResponse(QStringLiteral("Window not found: %1").arg(window_name)); + } + + std::vector objects; + QSet visited; + collectNamedObjects(window, objects, visited, 0); + + QJsonArray arr; + for (const auto& entry : objects) { + QJsonObject json_entry; + json_entry[QStringLiteral("objectName")] = entry.object_name; + json_entry[QStringLiteral("className")] = entry.class_name; + json_entry[QStringLiteral("depth")] = entry.depth; + json_entry[QStringLiteral("visible")] = entry.visible; + arr.append(json_entry); + } + + QJsonObject resp; + resp[QStringLiteral("window")] = WindowLabel(window); + resp[QStringLiteral("objects")] = arr; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} + +QByteArray TestBridge::errorResponse(const QString& message) +{ + QJsonObject resp; + resp[QStringLiteral("error")] = message; + return QJsonDocument(resp).toJson(QJsonDocument::Compact); +} diff --git a/src/qt/testbridge.h b/src/qt/testbridge.h new file mode 100644 index 00000000000..0f35124c901 --- /dev/null +++ b/src/qt/testbridge.h @@ -0,0 +1,96 @@ +// 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. + +#ifndef BITCOIN_QT_TESTBRIDGE_H +#define BITCOIN_QT_TESTBRIDGE_H + +#include +#include +#include +#include +#include +#include +#include +#include + +#include + +class BitcoinGUI; +class QWidget; + +/// Exposes the Qt Widgets object tree to external test scripts over a local +/// socket. Enabled only when compiled with ENABLE_TEST_AUTOMATION and launched +/// with --test-automation=. +/// +/// Supported commands (JSON over newline-delimited stream): +/// {"cmd": "list_windows"} +/// {"cmd": "get_active_window"} +/// {"cmd": "get_current_view", "window": ""} +/// {"cmd": "get_property", "window": "", "objectName": "", "prop": ""} +/// {"cmd": "click", "window": "", "objectName": ""} +/// {"cmd": "set_text", "window": "", "objectName": "", "text": ""} +/// {"cmd": "wait_for_window", "window": "", "timeout": } +/// {"cmd": "wait_for_view", "window": "", "view": "", "timeout": } +/// {"cmd": "wait_for_property", "window": "", "objectName": "", "prop": "", ...} +/// {"cmd": "get_text", "window": "", "objectName": ""} +/// {"cmd": "save_screenshot", "window": "", "path": ""} +/// {"cmd": "list_objects", "window": ""} +class TestBridge : public QObject +{ +public: + explicit TestBridge(BitcoinGUI* gui, const QString& socket_path, QObject* parent = nullptr); + ~TestBridge() override; + +private: + void handleNewConnection(); + void handleClientData(); + void handleClientDisconnected(); + struct NamedObjectEntry { + QString object_name; + QString class_name; + int depth; + bool visible; + }; + + struct WindowEntry { + QString window_name; + QString class_name; + QString title; + bool active; + bool visible; + }; + + QWidget* defaultWindow() const; + QWidget* findWindow(const QString& name) const; + QObject* findObject(const QString& object_name, const QString& window_name) const; + QWidget* currentView(QWidget* window) const; + void collectNamedObjects(QObject* root, std::vector& results, QSet& visited, int depth) const; + std::vector listWindows() const; + QByteArray processCommand(const QByteArray& json_cmd); + void processClientCommands(QLocalSocket* client); + + QByteArray cmdListWindows(); + QByteArray cmdGetActiveWindow(); + QByteArray cmdGetCurrentView(const QString& window_name); + QByteArray cmdGetProperty(const QString& window_name, const QString& object_name, const QString& prop); + QByteArray cmdClick(const QString& window_name, const QString& object_name); + QByteArray cmdSetText(const QString& window_name, const QString& object_name, const QString& text); + QByteArray cmdWaitForWindow(const QString& window_name, int timeout_ms); + QByteArray cmdWaitForView(const QString& window_name, const QString& view_name, int timeout_ms); + QByteArray cmdWaitForProperty(const QString& window_name, const QString& object_name, const QString& prop, int timeout_ms, const QJsonValue& expected, bool has_expected, const QString& contains, bool non_empty); + QByteArray cmdGetText(const QString& window_name, const QString& object_name); + QByteArray cmdSaveScreenshot(const QString& window_name, const QString& path); + QByteArray cmdListObjects(const QString& window_name); + + static QByteArray errorResponse(const QString& message); + + BitcoinGUI* m_gui; + QLocalServer* m_server; + std::vector m_clients; + QHash m_read_buffers; + bool m_processing_client_data{false}; + bool m_pending_client_data{false}; +}; + +#endif // BITCOIN_QT_TESTBRIDGE_H diff --git a/src/qt/walletframe.cpp b/src/qt/walletframe.cpp index 579c033dc95..18841fd9094 100644 --- a/src/qt/walletframe.cpp +++ b/src/qt/walletframe.cpp @@ -31,15 +31,19 @@ WalletFrame::WalletFrame(const PlatformStyle* _platformStyle, QWidget* parent) platformStyle(_platformStyle), m_size_hint(OverviewPage{platformStyle, nullptr}.sizeHint()) { + setObjectName("walletFrame"); + // Leave HBox hook for adding a list view later QHBoxLayout *walletFrameLayout = new QHBoxLayout(this); setContentsMargins(0,0,0,0); walletStack = new QStackedWidget(this); + walletStack->setObjectName("walletStack"); walletFrameLayout->setContentsMargins(0,0,0,0); walletFrameLayout->addWidget(walletStack); // hbox for no wallet QGroupBox* no_wallet_group = new QGroupBox(walletStack); + no_wallet_group->setObjectName("noWalletView"); QVBoxLayout* no_wallet_layout = new QVBoxLayout(no_wallet_group); QLabel *noWallet = new QLabel(tr("No wallet has been loaded.\nGo to File > Open Wallet to load a wallet.\n- OR -")); @@ -48,6 +52,7 @@ WalletFrame::WalletFrame(const PlatformStyle* _platformStyle, QWidget* parent) // A button for create wallet dialog QPushButton* create_wallet_button = new QPushButton(tr("Create a new wallet"), walletStack); + create_wallet_button->setObjectName("createWalletButton"); connect(create_wallet_button, &QPushButton::clicked, this, &WalletFrame::createWalletButtonClicked); no_wallet_layout->addWidget(create_wallet_button, 0, Qt::AlignHCenter | Qt::AlignTop); no_wallet_group->setLayout(no_wallet_layout); diff --git a/src/qt/walletview.cpp b/src/qt/walletview.cpp index 9f6084c6169..c6b687daa77 100644 --- a/src/qt/walletview.cpp +++ b/src/qt/walletview.cpp @@ -35,12 +35,14 @@ WalletView::WalletView(WalletModel* wallet_model, const PlatformStyle* _platform platformStyle(_platformStyle) { assert(walletModel); + setObjectName("walletView"); // Create tabs overviewPage = new OverviewPage(platformStyle); overviewPage->setWalletModel(walletModel); transactionsPage = new QWidget(this); + transactionsPage->setObjectName("TransactionsPage"); QVBoxLayout *vbox = new QVBoxLayout(); QHBoxLayout *hbox_buttons = new QHBoxLayout(); transactionView = new TransactionView(platformStyle, this); @@ -48,6 +50,7 @@ WalletView::WalletView(WalletModel* wallet_model, const PlatformStyle* _platform vbox->addWidget(transactionView); QPushButton *exportButton = new QPushButton(tr("&Export"), this); + exportButton->setObjectName("transactionsExportButton"); exportButton->setToolTip(tr("Export the data in the current tab to a file")); if (platformStyle->getImagesOnButtons()) { exportButton->setIcon(platformStyle->SingleColorIcon(":/icons/export")); @@ -64,9 +67,11 @@ WalletView::WalletView(WalletModel* wallet_model, const PlatformStyle* _platform sendCoinsPage->setModel(walletModel); usedSendingAddressesPage = new AddressBookPage(platformStyle, AddressBookPage::ForEditing, AddressBookPage::SendingTab, this); + usedSendingAddressesPage->setObjectName("UsedSendingAddressesPage"); usedSendingAddressesPage->setModel(walletModel->getAddressTableModel()); usedReceivingAddressesPage = new AddressBookPage(platformStyle, AddressBookPage::ForEditing, AddressBookPage::ReceivingTab, this); + usedReceivingAddressesPage->setObjectName("UsedReceivingAddressesPage"); usedReceivingAddressesPage->setModel(walletModel->getAddressTableModel()); addWidget(overviewPage); diff --git a/test/functional/gui_wallet_send.py b/test/functional/gui_wallet_send.py new file mode 100644 index 00000000000..e02f5113167 --- /dev/null +++ b/test/functional/gui_wallet_send.py @@ -0,0 +1,198 @@ +#!/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 send transaction test via bitcoin-qt and the test bridge.""" + +from decimal import Decimal +import os +import re + +from test_framework.qt import ( + QtDriver, + bitcoin_qt_supports_test_automation, + find_bitcoin_qt_binary, +) +from test_framework.test_framework import BitcoinTestFramework, SkipTest +from test_framework.test_node import TestNode +from test_framework.util import ( + assert_equal, + get_datadir_path, +) + + +GUI_NODE_INDEX = 2 + + +def parse_display_amount(text): + cleaned = re.sub(r"[^0-9.\-]", "", text) + return Decimal(cleaned) if cleaned else Decimal("0") + + +class WalletSendGuiTest(BitcoinTestFramework): + def set_test_params(self): + self.num_nodes = 3 + self.noban_tx_relay = True + self.supports_cli = False + self.uses_wallet = True + self.extra_args = [ + ["-fallbackfee=0.0002", "-walletrbf=1"], + ["-fallbackfee=0.0002", "-walletrbf=1"], + ["-fallbackfee=0.0002", "-walletrbf=1"], + ] + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + try: + self.gui_binary = find_bitcoin_qt_binary(self.config) + except FileNotFoundError as e: + raise SkipTest(str(e)) + if not bitcoin_qt_supports_test_automation(self.gui_binary): + raise SkipTest("bitcoin-qt was not built with -DENABLE_TEST_AUTOMATION=ON") + + def setup_nodes(self): + self.socket_path = os.path.join(self.options.tmpdir, "qt_bridge.sock") + self.screenshot_dir = os.path.join(self.options.tmpdir, "qt_screenshots") + self.screenshot_index = 0 + + self.add_nodes(2, self.extra_args[:2]) + + gui_binaries = self.get_binaries() + gui_binaries.paths.bitcoind = self.gui_binary + gui_node = TestNode( + GUI_NODE_INDEX, + get_datadir_path(self.options.tmpdir, GUI_NODE_INDEX), + chain=self.chain, + rpchost=None, + timewait=self.rpc_timeout, + timeout_factor=self.options.timeout_factor, + binaries=gui_binaries, + coverage_dir=self.options.coveragedir, + cwd=self.options.tmpdir, + extra_args=self.extra_args[GUI_NODE_INDEX] + [f"-test-automation={self.socket_path}"], + use_cli=self.options.usecli, + start_perf=self.options.perf, + v2transport=self.options.v2transport, + uses_wallet=self.uses_wallet, + ) + self.nodes.append(gui_node) + + self.start_node(0) + self.start_node(1) + self.start_node(GUI_NODE_INDEX, env={"QT_QPA_PLATFORM": "offscreen"}) + + if self.uses_wallet: + self.import_deterministic_coinbase_privkeys() + + if not self.setup_clean_chain: + for node in self.nodes: + assert_equal(node.getblockchaininfo()["blocks"], 199) + block_hash = self.generate(self.nodes[0], 1, sync_fun=self.no_op)[0] + block = self.nodes[0].getblock(blockhash=block_hash, verbosity=0) + for node in self.nodes: + node.submitblock(block) + + def dump_gui_state(self, gui): + try: + self.log.info("GUI windows: %s", gui.list_windows()) + except Exception as e: + self.log.info("Failed to list GUI windows: %s", e) + try: + active_window = gui.get_active_window() + self.log.info("Active GUI window: %s", active_window) + if active_window == "QMessageBox": + self.log.info("Active message box text: %s", gui.get_text("qt_msgbox_label", window=active_window)) + except Exception as e: + self.log.info("Failed to inspect active GUI window: %s", e) + try: + self.log.info("Main window objects: %s", gui.list_objects(window="mainWindow")) + except Exception as e: + self.log.info("Failed to list main window objects: %s", e) + self.capture_screenshot(gui, "failure_active_window") + self.capture_screenshot(gui, "failure_main_window", window="mainWindow") + + def capture_screenshot(self, gui, step_name, *, window=None): + os.makedirs(self.screenshot_dir, exist_ok=True) + self.screenshot_index += 1 + safe_name = re.sub(r"[^a-z0-9_]+", "_", step_name.lower()).strip("_") + target_window = window + if target_window is None: + try: + target_window = gui.get_active_window() + except Exception: + target_window = "mainWindow" + path = os.path.join(self.screenshot_dir, f"{self.screenshot_index:02d}_{safe_name}.png") + try: + gui.save_screenshot(path, window=target_window) + self.log.info("Saved GUI screenshot %s (%s)", path, target_window) + except Exception as e: + self.log.info("Failed to save GUI screenshot %s: %s", path, e) + + def run_test(self): + gui_wallet = self.nodes[GUI_NODE_INDEX].get_wallet_rpc(self.default_wallet_name) + recipient_wallet = self.nodes[1].get_wallet_rpc(self.default_wallet_name) + amount = Decimal("1.25") + amount_sats = int(amount * Decimal(100_000_000)) + recipient_address = recipient_wallet.getnewaddress() + + gui = QtDriver(self.socket_path, timeout=60) + try: + gui.wait_for_window("mainWindow", timeout_ms=60000) + gui.wait_for_property("sendCoinsAction", "enabled", window="mainWindow", value=True, timeout_ms=60000) + self.capture_screenshot(gui, "main_window_ready", window="mainWindow") + + self.log.info("Open the Send view and submit a transaction through bitcoin-qt") + gui.click("sendCoinsAction", window="mainWindow") + gui.wait_for_view("SendCoinsDialog", window="mainWindow", timeout_ms=10000) + self.wait_until( + lambda: parse_display_amount(gui.get_text("labelBalance", window="mainWindow")) > amount, + timeout=30, + ) + self.capture_screenshot(gui, "send_view_open", window="mainWindow") + gui.set_text("payTo", recipient_address, window="mainWindow") + gui.set_text("payAmount", str(amount), window="mainWindow") + self.wait_until(lambda: gui.get_text("payTo", window="mainWindow") == recipient_address, timeout=5) + self.wait_until(lambda: gui.get_property("payAmount", "value", window="mainWindow") == amount_sats, timeout=5) + self.capture_screenshot(gui, "send_form_filled", window="mainWindow") + gui.click("sendButton", window="mainWindow") + + self.wait_until(lambda: gui.get_active_window() != "mainWindow", timeout=10) + active_window = gui.get_active_window() + if active_window != "sendConfirmationDialog": + message_text = "" + if active_window == "QMessageBox": + message_text = gui.get_text("qt_msgbox_label", window=active_window) + raise AssertionError(f"Unexpected modal window {active_window}: {message_text}") + self.capture_screenshot(gui, "confirmation_dialog_shown", window="sendConfirmationDialog") + gui.wait_for_property("sendConfirmButton", "enabled", window="sendConfirmationDialog", value=True, timeout_ms=10000) + self.capture_screenshot(gui, "confirmation_dialog_enabled", window="sendConfirmationDialog") + gui.click("sendConfirmButton", window="sendConfirmationDialog") + + self.wait_until( + lambda: recipient_wallet.getreceivedbyaddress(recipient_address, 0) == amount, + timeout=30, + ) + self.capture_screenshot(gui, "transaction_broadcast", window="mainWindow") + self.sync_mempools([self.nodes[0], self.nodes[1], self.nodes[GUI_NODE_INDEX]]) + + txid = gui_wallet.listtransactions(count=1)[0]["txid"] + self.log.info("GUI broadcast transaction %s", txid) + self.wait_until(lambda: txid in self.nodes[0].getrawmempool(), timeout=30) + + self.generate(self.nodes[0], 1) + self.sync_blocks([self.nodes[0], self.nodes[1], self.nodes[GUI_NODE_INDEX]]) + self.wait_until( + lambda: recipient_wallet.gettransaction(txid)["confirmations"] > 0, + timeout=30, + ) + assert_equal(recipient_wallet.getreceivedbyaddress(recipient_address, 1), amount) + self.capture_screenshot(gui, "transaction_confirmed", window="mainWindow") + except Exception: + self.dump_gui_state(gui) + raise + finally: + gui.close() + + +if __name__ == "__main__": + WalletSendGuiTest(__file__).main() diff --git a/test/functional/test_framework/qt.py b/test/functional/test_framework/qt.py new file mode 100644 index 00000000000..808de6c6425 --- /dev/null +++ b/test/functional/test_framework/qt.py @@ -0,0 +1,197 @@ +#!/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. +"""Driver helpers for the Qt Widgets test automation bridge.""" + +import json +import os +import socket +import subprocess +import time + + +class QtDriverError(Exception): + """Raised when the Qt test bridge returns an error response.""" + + +class QtDriver: + """Drive bitcoin-qt via the Qt Widgets test bridge.""" + + def __init__(self, socket_path, timeout=30): + self.socket_path = socket_path + self.timeout = timeout + self.sock = None + self._connect() + + def _connect(self): + deadline = time.time() + self.timeout + last_err = None + while time.time() < deadline: + try: + self.sock = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + self.sock.settimeout(self.timeout) + self.sock.connect(self.socket_path) + return + except (ConnectionRefusedError, FileNotFoundError, OSError) as e: + last_err = e + if self.sock: + self.sock.close() + self.sock = None + time.sleep(0.25) + raise QtDriverError(f"Could not connect to test bridge at {self.socket_path}: {last_err}") + + def close(self): + if self.sock: + self.sock.close() + self.sock = None + + def __enter__(self): + return self + + def __exit__(self, *_exc_info): + self.close() + + def list_windows(self): + return self._expect(self._send({"cmd": "list_windows"}), "list_windows")["windows"] + + def get_active_window(self): + return self._expect(self._send({"cmd": "get_active_window"}), "get_active_window")["window"] + + def get_current_view(self, window=None): + cmd = {"cmd": "get_current_view"} + if window is not None: + cmd["window"] = window + return self._expect(self._send(cmd), "get_current_view")["view"] + + def click(self, object_name, *, window=None): + cmd = {"cmd": "click", "objectName": object_name} + if window is not None: + cmd["window"] = window + self._expect(self._send(cmd), f"click({object_name!r})") + + def set_text(self, object_name, text, *, window=None): + cmd = {"cmd": "set_text", "objectName": object_name, "text": text} + if window is not None: + cmd["window"] = window + self._expect(self._send(cmd), f"set_text({object_name!r})") + + def get_text(self, object_name, *, window=None): + cmd = {"cmd": "get_text", "objectName": object_name} + if window is not None: + cmd["window"] = window + return self._expect(self._send(cmd), f"get_text({object_name!r})")["text"] + + def get_property(self, object_name, prop, *, window=None): + cmd = {"cmd": "get_property", "objectName": object_name, "prop": prop} + if window is not None: + cmd["window"] = window + return self._expect(self._send(cmd), f"get_property({object_name!r}, {prop!r})")["value"] + + def wait_for_window(self, window_name, timeout_ms=5000): + self._expect( + self._send({"cmd": "wait_for_window", "window": window_name, "timeout": timeout_ms}), + f"wait_for_window({window_name!r})", + ) + + def wait_for_view(self, view_name, *, window=None, timeout_ms=5000): + cmd = {"cmd": "wait_for_view", "view": view_name, "timeout": timeout_ms} + if window is not None: + cmd["window"] = window + self._expect(self._send(cmd), f"wait_for_view({view_name!r})") + + def wait_for_property(self, object_name, prop, *, window=None, timeout_ms=5000, value=None, contains=None, non_empty=False): + cmd = { + "cmd": "wait_for_property", + "objectName": object_name, + "prop": prop, + "timeout": timeout_ms, + } + if window is not None: + cmd["window"] = window + if value is not None: + cmd["value"] = value + if contains is not None: + cmd["contains"] = contains + if non_empty: + cmd["nonEmpty"] = True + return self._expect(self._send(cmd), f"wait_for_property({object_name!r}, {prop!r})").get("value") + + def list_objects(self, *, window=None): + cmd = {"cmd": "list_objects"} + if window is not None: + cmd["window"] = window + return self._expect(self._send(cmd), "list_objects")["objects"] + + def save_screenshot(self, path, *, window=None): + cmd = {"cmd": "save_screenshot", "path": path} + if window is not None: + cmd["window"] = window + return self._expect(self._send(cmd), f"save_screenshot({path!r})") + + def _send(self, cmd): + payload = json.dumps(cmd) + "\n" + self.sock.sendall(payload.encode("utf-8")) + return self._recv() + + def _recv(self): + buf = b"" + while True: + chunk = self.sock.recv(4096) + if not chunk: + raise QtDriverError("Connection closed by test bridge") + buf += chunk + if b"\n" in buf: + line, _ = buf.split(b"\n", 1) + return json.loads(line) + + @staticmethod + def _expect(response, operation): + if "error" in response: + raise QtDriverError(f"{operation} failed: {response['error']}") + return response + + +def find_bitcoin_qt_binary(config): + env_path = os.getenv("BITCOIN_QT") + if env_path and os.path.isfile(env_path): + return env_path + + builddir = config["environment"]["BUILDDIR"] + exeext = config["environment"]["EXEEXT"] + default_path = os.path.join(builddir, "bin", f"bitcoin-qt{exeext}") + if os.path.isfile(default_path): + return default_path + + raise FileNotFoundError( + "Cannot find bitcoin-qt binary. Set BITCOIN_QT or build bitcoin-qt with " + "-DENABLE_TEST_AUTOMATION=ON." + ) + + +def bitcoin_qt_supports_test_automation(binary_path): + try: + result = subprocess.run( + ["strings", binary_path], + check=False, + capture_output=True, + text=True, + timeout=20, + ) + if "-test-automation=" in result.stdout: + return True + except OSError: + pass + + env = dict(os.environ) + env.setdefault("QT_QPA_PLATFORM", "minimal") + result = subprocess.run( + [binary_path, "-help"], + check=False, + capture_output=True, + text=True, + timeout=20, + env=env, + ) + output = result.stdout + result.stderr + return "-test-automation=" in output diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index a4052f91024..7ec970d7d25 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -90,6 +90,7 @@ 'feature_pruning.py', 'feature_dbcrash.py', 'feature_index_prune.py', + 'gui_wallet_send.py', ] # Special script to run each bench sanity check From 2e6279f7a32cd48ce164b17120a2b75a44df4774 Mon Sep 17 00:00:00 2001 From: johnny9 <985648+johnny9@users.noreply.github.com> Date: Tue, 10 Mar 2026 10:01:07 -0400 Subject: [PATCH 2/3] Add gui functional test coverage --- src/qt/askpassphrasedialog.cpp | 40 ++++-- src/qt/bitcoingui.cpp | 7 + src/qt/createwalletdialog.cpp | 3 + src/qt/sendcoinsdialog.cpp | 2 + src/qt/walletcontroller.cpp | 4 + test/functional/gui_node_window.py | 35 +++++ test/functional/gui_settings_persistence.py | 59 ++++++++ test/functional/gui_wallet_lifecycle.py | 60 +++++++++ test/functional/gui_wallet_receive.py | 64 +++++++++ test/functional/gui_wallet_security.py | 87 ++++++++++++ test/functional/gui_wallet_send.py | 122 +---------------- test/functional/gui_wallet_send_advanced.py | 56 ++++++++ test/functional/test_framework/qt.py | 142 ++++++++++++++++++++ test/functional/test_runner.py | 6 + 14 files changed, 561 insertions(+), 126 deletions(-) create mode 100644 test/functional/gui_node_window.py create mode 100644 test/functional/gui_settings_persistence.py create mode 100644 test/functional/gui_wallet_lifecycle.py create mode 100644 test/functional/gui_wallet_receive.py create mode 100644 test/functional/gui_wallet_security.py create mode 100644 test/functional/gui_wallet_send_advanced.py diff --git a/src/qt/askpassphrasedialog.cpp b/src/qt/askpassphrasedialog.cpp index 5c9f9f13208..480c8f04d6e 100644 --- a/src/qt/askpassphrasedialog.cpp +++ b/src/qt/askpassphrasedialog.cpp @@ -13,6 +13,7 @@ #include #include +#include #include AskPassphraseDialog::AskPassphraseDialog(Mode _mode, QWidget *parent, SecureString* passphrase_out) : @@ -59,6 +60,8 @@ AskPassphraseDialog::AskPassphraseDialog(Mode _mode, QWidget *parent, SecureStri break; } textChanged(); + ui->buttonBox->button(QDialogButtonBox::Ok)->setObjectName("passphraseOkButton"); + ui->buttonBox->button(QDialogButtonBox::Cancel)->setObjectName("passphraseCancelButton"); connect(ui->toggleShowPasswordButton, &QPushButton::toggled, this, &AskPassphraseDialog::toggleShowPassword); connect(ui->passEdit1, &QLineEdit::textChanged, this, &AskPassphraseDialog::textChanged); connect(ui->passEdit2, &QLineEdit::textChanged, this, &AskPassphraseDialog::textChanged); @@ -105,8 +108,11 @@ void AskPassphraseDialog::accept() tr("Confirm wallet encryption"), tr("Warning: If you encrypt your wallet and lose your passphrase, you will LOSE ALL OF YOUR BITCOINS!") + "

" + tr("Are you sure you wish to encrypt your wallet?"), QMessageBox::Cancel | QMessageBox::Yes, this); + msgBoxConfirm.setObjectName("encryptWalletConfirmDialog"); msgBoxConfirm.button(QMessageBox::Yes)->setText(tr("Continue")); msgBoxConfirm.button(QMessageBox::Cancel)->setText(tr("Back")); + msgBoxConfirm.button(QMessageBox::Yes)->setObjectName("encryptWalletContinueButton"); + msgBoxConfirm.button(QMessageBox::Cancel)->setObjectName("encryptWalletBackButton"); msgBoxConfirm.setDefaultButton(QMessageBox::Cancel); QMessageBox::StandardButton retval = (QMessageBox::StandardButton)msgBoxConfirm.exec(); if(retval == QMessageBox::Yes) @@ -133,15 +139,21 @@ void AskPassphraseDialog::accept() } else { assert(model != nullptr); if (model->setWalletEncrypted(newpass1)) { - QMessageBox::warning(this, tr("Wallet encrypted"), - "" + - tr("Your wallet is now encrypted. ") + encryption_reminder + - "

" + - tr("IMPORTANT: Any previous backups you have made of your wallet file " - "should be replaced with the newly generated, encrypted wallet file. " - "For security reasons, previous backups of the unencrypted wallet file " - "will become useless as soon as you start using the new, encrypted wallet.") + - "
"); + QMessageBox msgBoxEncrypted(QMessageBox::Warning, + tr("Wallet encrypted"), + "" + + tr("Your wallet is now encrypted. ") + encryption_reminder + + "

" + + tr("IMPORTANT: Any previous backups you have made of your wallet file " + "should be replaced with the newly generated, encrypted wallet file. " + "For security reasons, previous backups of the unencrypted wallet file " + "will become useless as soon as you start using the new, encrypted wallet.") + + "
", + QMessageBox::Ok, + this); + msgBoxEncrypted.setObjectName("walletEncryptedDialog"); + msgBoxEncrypted.button(QMessageBox::Ok)->setObjectName("walletEncryptedOkButton"); + msgBoxEncrypted.exec(); } else { QMessageBox::critical(this, tr("Wallet encryption failed"), tr("Wallet encryption failed due to an internal error. Your wallet was not encrypted.")); @@ -191,8 +203,14 @@ void AskPassphraseDialog::accept() { if(model->changePassphrase(oldpass, newpass1)) { - QMessageBox::information(this, tr("Wallet encrypted"), - tr("Wallet passphrase was successfully changed.")); + QMessageBox msgBoxChanged(QMessageBox::Information, + tr("Wallet encrypted"), + tr("Wallet passphrase was successfully changed."), + QMessageBox::Ok, + this); + msgBoxChanged.setObjectName("passphraseChangedDialog"); + msgBoxChanged.button(QMessageBox::Ok)->setObjectName("passphraseChangedOkButton"); + msgBoxChanged.exec(); QDialog::accept(); // Success } else diff --git a/src/qt/bitcoingui.cpp b/src/qt/bitcoingui.cpp index 33d08c8aef9..3020da4947f 100644 --- a/src/qt/bitcoingui.cpp +++ b/src/qt/bitcoingui.cpp @@ -320,14 +320,17 @@ void BitcoinGUI::createActions() optionsAction->setStatusTip(tr("Modify configuration options for %1").arg(CLIENT_NAME)); optionsAction->setMenuRole(QAction::PreferencesRole); optionsAction->setEnabled(false); + optionsAction->setObjectName("optionsAction"); encryptWalletAction = new QAction(tr("&Encrypt Wallet…"), this); encryptWalletAction->setStatusTip(tr("Encrypt the private keys that belong to your wallet")); encryptWalletAction->setCheckable(true); + encryptWalletAction->setObjectName("encryptWalletAction"); backupWalletAction = new QAction(tr("&Backup Wallet…"), this); backupWalletAction->setStatusTip(tr("Backup wallet to another location")); changePassphraseAction = new QAction(tr("&Change Passphrase…"), this); changePassphraseAction->setStatusTip(tr("Change the passphrase used for wallet encryption")); + changePassphraseAction->setObjectName("changePassphraseAction"); signMessageAction = new QAction(tr("Sign &message…"), this); signMessageAction->setStatusTip(tr("Sign messages with your Bitcoin addresses to prove you own them")); verifyMessageAction = new QAction(tr("&Verify message…"), this); @@ -354,14 +357,17 @@ void BitcoinGUI::createActions() m_open_wallet_action = new QAction(tr("Open Wallet"), this); m_open_wallet_action->setEnabled(false); m_open_wallet_action->setStatusTip(tr("Open a wallet")); + m_open_wallet_action->setObjectName("openWalletAction"); m_open_wallet_menu = new QMenu(this); m_close_wallet_action = new QAction(tr("Close Wallet…"), this); m_close_wallet_action->setStatusTip(tr("Close wallet")); + m_close_wallet_action->setObjectName("closeWalletAction"); m_create_wallet_action = new QAction(tr("Create Wallet…"), this); m_create_wallet_action->setEnabled(false); m_create_wallet_action->setStatusTip(tr("Create a new wallet")); + m_create_wallet_action->setObjectName("createWalletAction"); //: Name of the menu item that restores wallet from a backup file. m_restore_wallet_action = new QAction(tr("Restore Wallet…"), this); @@ -385,6 +391,7 @@ void BitcoinGUI::createActions() m_mask_values_action->setShortcut(QKeySequence(Qt::CTRL | Qt::SHIFT | Qt::Key_M)); m_mask_values_action->setStatusTip(tr("Mask the values in the Overview tab")); m_mask_values_action->setCheckable(true); + m_mask_values_action->setObjectName("maskValuesAction"); connect(quitAction, &QAction::triggered, this, &BitcoinGUI::quitRequested); connect(aboutAction, &QAction::triggered, this, &BitcoinGUI::aboutClicked); diff --git a/src/qt/createwalletdialog.cpp b/src/qt/createwalletdialog.cpp index 59c6f51a27e..11b88f99627 100644 --- a/src/qt/createwalletdialog.cpp +++ b/src/qt/createwalletdialog.cpp @@ -10,6 +10,7 @@ #include +#include #include CreateWalletDialog::CreateWalletDialog(QWidget* parent) : @@ -19,6 +20,8 @@ CreateWalletDialog::CreateWalletDialog(QWidget* parent) : ui->setupUi(this); ui->buttonBox->button(QDialogButtonBox::Ok)->setText(tr("Create")); ui->buttonBox->button(QDialogButtonBox::Ok)->setEnabled(false); + ui->buttonBox->button(QDialogButtonBox::Ok)->setObjectName("createWalletOkButton"); + ui->buttonBox->button(QDialogButtonBox::Cancel)->setObjectName("createWalletCancelButton"); ui->wallet_name_line_edit->setFocus(Qt::ActiveWindowFocusReason); connect(ui->wallet_name_line_edit, &QLineEdit::textEdited, [this](const QString& text) { diff --git a/src/qt/sendcoinsdialog.cpp b/src/qt/sendcoinsdialog.cpp index 9726dcad1a8..2153a0b8c34 100644 --- a/src/qt/sendcoinsdialog.cpp +++ b/src/qt/sendcoinsdialog.cpp @@ -408,6 +408,8 @@ void SendCoinsDialog::presentPSBT(PartiallySignedTransaction& psbtx) msgBox.setStandardButtons(QMessageBox::Save | QMessageBox::Discard); msgBox.setDefaultButton(QMessageBox::Discard); msgBox.setObjectName("psbt_copied_message"); + msgBox.button(QMessageBox::Save)->setObjectName("psbtSaveButton"); + msgBox.button(QMessageBox::Discard)->setObjectName("psbtDiscardButton"); switch (msgBox.exec()) { case QMessageBox::Save: { QString selectedFilter; diff --git a/src/qt/walletcontroller.cpp b/src/qt/walletcontroller.cpp index 1d6153d49a1..32266f9ec5c 100644 --- a/src/qt/walletcontroller.cpp +++ b/src/qt/walletcontroller.cpp @@ -23,6 +23,7 @@ #include #include +#include #include #include #include @@ -90,11 +91,14 @@ void WalletController::removeWallet(WalletModel* wallet_model) void WalletController::closeWallet(WalletModel* wallet_model, QWidget* parent) { QMessageBox box(parent); + box.setObjectName("closeWalletConfirmDialog"); box.setWindowTitle(tr("Close wallet")); box.setText(tr("Are you sure you wish to close the wallet %1?").arg(GUIUtil::HtmlEscape(wallet_model->getDisplayName()))); box.setInformativeText(tr("Closing the wallet for too long can result in having to resync the entire chain if pruning is enabled.")); box.setStandardButtons(QMessageBox::Yes|QMessageBox::Cancel); box.setDefaultButton(QMessageBox::Yes); + box.button(QMessageBox::Yes)->setObjectName("closeWalletConfirmButton"); + box.button(QMessageBox::Cancel)->setObjectName("closeWalletCancelButton"); if (box.exec() != QMessageBox::Yes) return; removeWallet(wallet_model); diff --git a/test/functional/gui_node_window.py b/test/functional/gui_node_window.py new file mode 100644 index 00000000000..9fab8c8421e --- /dev/null +++ b/test/functional/gui_node_window.py @@ -0,0 +1,35 @@ +#!/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. +"""Node window smoke test via bitcoin-qt and the test bridge.""" + +from test_framework.qt import QtWidgetsTestFramework + + +class NodeWindowGuiTest(QtWidgetsTestFramework): + def set_test_params(self): + super().set_test_params() + + def run_test(self): + gui = self.create_gui_driver() + try: + self.wait_for_main_window(gui) + gui.wait_for_property("openRPCConsoleAction", "enabled", window="mainWindow", value=True, timeout_ms=60000) + gui.click("openRPCConsoleAction", window="mainWindow") + gui.wait_for_window("rpcConsole", timeout_ms=10000) + gui.wait_for_property("clientVersion", "text", window="rpcConsole", non_empty=True, timeout_ms=10000) + gui.wait_for_property("networkName", "text", window="rpcConsole", contains="regtest", timeout_ms=10000) + gui.wait_for_property("numberOfConnections", "text", window="rpcConsole", non_empty=True, timeout_ms=10000) + gui.wait_for_property("numberOfBlocks", "text", window="rpcConsole", contains="200", timeout_ms=10000) + gui.wait_for_property("mempoolNumberTxs", "text", window="rpcConsole", non_empty=True, timeout_ms=10000) + self.capture_screenshot(gui, "node_window", window="rpcConsole") + except Exception: + self.dump_gui_state(gui) + raise + finally: + gui.close() + + +if __name__ == "__main__": + NodeWindowGuiTest(__file__).main() diff --git a/test/functional/gui_settings_persistence.py b/test/functional/gui_settings_persistence.py new file mode 100644 index 00000000000..2ebe1f82dfd --- /dev/null +++ b/test/functional/gui_settings_persistence.py @@ -0,0 +1,59 @@ +#!/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. +"""Settings persistence test via bitcoin-qt and the test bridge.""" + +from test_framework.qt import QtWidgetsTestFramework + + +class SettingsPersistenceGuiTest(QtWidgetsTestFramework): + def set_test_params(self): + super().set_test_params() + + def run_test(self): + gui = self.create_gui_driver() + try: + self.wait_for_main_window(gui) + gui.click("sendCoinsAction", window="mainWindow") + gui.wait_for_view("SendCoinsDialog", window="mainWindow", timeout_ms=10000) + self.wait_until( + lambda: gui.get_property("frameCoinControl", "visible", window="mainWindow") is False, + timeout=10, + ) + self.capture_screenshot(gui, "coin_control_disabled", window="mainWindow") + + gui.click("optionsAction", window="mainWindow") + gui.wait_for_window("OptionsDialog", timeout_ms=10000) + if not gui.get_property("coinControlFeatures", "checked", window="OptionsDialog"): + gui.click("coinControlFeatures", window="OptionsDialog") + gui.click("okButton", window="OptionsDialog") + self.wait_until( + lambda: gui.get_property("frameCoinControl", "visible", window="mainWindow") is True, + timeout=10, + ) + self.capture_screenshot(gui, "coin_control_enabled", window="mainWindow") + + gui = self.restart_gui(gui) + self.wait_for_main_window(gui) + gui.click("sendCoinsAction", window="mainWindow") + gui.wait_for_view("SendCoinsDialog", window="mainWindow", timeout_ms=10000) + self.wait_until( + lambda: gui.get_property("frameCoinControl", "visible", window="mainWindow") is True, + timeout=10, + ) + + gui.click("optionsAction", window="mainWindow") + gui.wait_for_window("OptionsDialog", timeout_ms=10000) + assert gui.get_property("coinControlFeatures", "checked", window="OptionsDialog") + self.capture_screenshot(gui, "coin_control_persisted", window="OptionsDialog") + gui.click("cancelButton", window="OptionsDialog") + except Exception: + self.dump_gui_state(gui) + raise + finally: + gui.close() + + +if __name__ == "__main__": + SettingsPersistenceGuiTest(__file__).main() diff --git a/test/functional/gui_wallet_lifecycle.py b/test/functional/gui_wallet_lifecycle.py new file mode 100644 index 00000000000..4b5692f2b48 --- /dev/null +++ b/test/functional/gui_wallet_lifecycle.py @@ -0,0 +1,60 @@ +#!/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. +"""Wallet create, close, and reload lifecycle test via bitcoin-qt.""" + +from test_framework.qt import GUI_NODE_INDEX, QtWidgetsTestFramework + + +class WalletLifecycleGuiTest(QtWidgetsTestFramework): + def set_test_params(self): + super().set_test_params() + + def get_gui_node_extra_args(self): + return super().get_gui_node_extra_args() + ["-nowallet"] + + def should_import_gui_wallet(self): + return False + + def run_test(self): + wallet_name = "gui_lifecycle_wallet" + + gui = self.create_gui_driver() + try: + self.wait_for_main_window(gui) + gui.wait_for_property("createWalletButton", "visible", window="mainWindow", value=True, timeout_ms=10000) + self.capture_screenshot(gui, "no_wallet_view", window="mainWindow") + + gui.click("createWalletButton", window="mainWindow") + gui.wait_for_window("CreateWalletDialog", timeout_ms=10000) + gui.set_text("wallet_name_line_edit", wallet_name, window="CreateWalletDialog") + gui.wait_for_property("createWalletOkButton", "enabled", window="CreateWalletDialog", value=True, timeout_ms=5000) + self.capture_screenshot(gui, "create_wallet_dialog", window="CreateWalletDialog") + gui.click("createWalletOkButton", window="CreateWalletDialog") + + self.wait_until(lambda: wallet_name in self.nodes[GUI_NODE_INDEX].listwallets(), timeout=30) + gui.wait_for_view("OverviewPage", window="mainWindow", timeout_ms=30000) + self.capture_screenshot(gui, "wallet_created", window="mainWindow") + + gui.wait_for_property("closeWalletAction", "enabled", window="mainWindow", value=True, timeout_ms=10000) + gui.click("closeWalletAction", window="mainWindow") + gui.wait_for_window("closeWalletConfirmDialog", timeout_ms=10000) + gui.click("closeWalletConfirmButton", window="closeWalletConfirmDialog") + self.wait_until(lambda: wallet_name not in self.nodes[GUI_NODE_INDEX].listwallets(), timeout=30) + gui.wait_for_property("createWalletButton", "visible", window="mainWindow", value=True, timeout_ms=10000) + self.capture_screenshot(gui, "wallet_closed", window="mainWindow") + + self.nodes[GUI_NODE_INDEX].loadwallet(wallet_name) + self.wait_until(lambda: wallet_name in self.nodes[GUI_NODE_INDEX].listwallets(), timeout=30) + gui.wait_for_view("OverviewPage", window="mainWindow", timeout_ms=30000) + self.capture_screenshot(gui, "wallet_reloaded", window="mainWindow") + except Exception: + self.dump_gui_state(gui) + raise + finally: + gui.close() + + +if __name__ == "__main__": + WalletLifecycleGuiTest(__file__).main() diff --git a/test/functional/gui_wallet_receive.py b/test/functional/gui_wallet_receive.py new file mode 100644 index 00000000000..dce9b224963 --- /dev/null +++ b/test/functional/gui_wallet_receive.py @@ -0,0 +1,64 @@ +#!/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 receive transaction test via bitcoin-qt and the test bridge.""" + +from decimal import Decimal + +from test_framework.qt import GUI_NODE_INDEX, QtWidgetsTestFramework +from test_framework.util import assert_equal + + +class WalletReceiveGuiTest(QtWidgetsTestFramework): + def set_test_params(self): + super().set_test_params() + + def run_test(self): + gui_wallet = self.nodes[GUI_NODE_INDEX].get_wallet_rpc(self.default_wallet_name) + sender_wallet = self.nodes[1].get_wallet_rpc(self.default_wallet_name) + amount = Decimal("0.75") + label = "gui receive" + message = "functional test payment" + + gui = self.create_gui_driver() + try: + self.wait_for_main_window(gui) + self.capture_screenshot(gui, "main_window_ready", window="mainWindow") + + gui.click("receiveCoinsAction", window="mainWindow") + gui.wait_for_view("ReceiveCoinsDialog", window="mainWindow", timeout_ms=10000) + gui.set_text("reqLabel", label, window="mainWindow") + gui.set_text("reqMessage", message, window="mainWindow") + gui.set_text("reqAmount", str(amount), window="mainWindow") + self.capture_screenshot(gui, "receive_form_filled", window="mainWindow") + gui.click("receiveButton", window="mainWindow") + + gui.wait_for_window("ReceiveRequestDialog", timeout_ms=10000) + gui.wait_for_property("address_content", "text", window="ReceiveRequestDialog", non_empty=True, timeout_ms=10000) + address = gui.get_text("address_content", window="ReceiveRequestDialog") + assert_equal(gui.get_text("label_content", window="ReceiveRequestDialog"), label) + assert_equal(gui.get_text("message_content", window="ReceiveRequestDialog"), message) + assert str(amount) in gui.get_text("amount_content", window="ReceiveRequestDialog") + self.capture_screenshot(gui, "receive_request_dialog", window="ReceiveRequestDialog") + + txid = sender_wallet.sendtoaddress(address, amount) + self.sync_mempools([self.nodes[0], self.nodes[1], self.nodes[GUI_NODE_INDEX]]) + self.generate(self.nodes[0], 1) + self.sync_blocks([self.nodes[0], self.nodes[1], self.nodes[GUI_NODE_INDEX]]) + + self.wait_until(lambda: gui_wallet.getreceivedbyaddress(address, 1) == amount, timeout=30) + self.wait_until(lambda: gui_wallet.gettransaction(txid)["confirmations"] > 0, timeout=30) + + gui.click("historyAction", window="mainWindow") + gui.wait_for_view("TransactionsPage", window="mainWindow", timeout_ms=10000) + self.capture_screenshot(gui, "transaction_history_after_receive", window="mainWindow") + except Exception: + self.dump_gui_state(gui) + raise + finally: + gui.close() + + +if __name__ == "__main__": + WalletReceiveGuiTest(__file__).main() diff --git a/test/functional/gui_wallet_security.py b/test/functional/gui_wallet_security.py new file mode 100644 index 00000000000..486bbe9ef07 --- /dev/null +++ b/test/functional/gui_wallet_security.py @@ -0,0 +1,87 @@ +#!/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. +"""Wallet encryption and unlock-flow test via bitcoin-qt.""" + +from decimal import Decimal + +from test_framework.qt import GUI_NODE_INDEX, QtWidgetsTestFramework +from test_framework.util import assert_equal, assert_raises_rpc_error + + +class WalletSecurityGuiTest(QtWidgetsTestFramework): + def set_test_params(self): + super().set_test_params() + + def run_test(self): + gui_wallet = self.nodes[GUI_NODE_INDEX].get_wallet_rpc(self.default_wallet_name) + recipient_address = self.nodes[1].get_wallet_rpc(self.default_wallet_name).getnewaddress() + old_passphrase = "gui security old passphrase" + new_passphrase = "gui security new passphrase" + amount = Decimal("0.1") + + gui = self.create_gui_driver() + try: + self.wait_for_main_window(gui) + gui.wait_for_property("encryptWalletAction", "enabled", window="mainWindow", value=True, timeout_ms=60000) + gui.click("encryptWalletAction", window="mainWindow") + gui.wait_for_window("AskPassphraseDialog", timeout_ms=10000) + gui.set_text("passEdit2", old_passphrase, window="AskPassphraseDialog") + gui.set_text("passEdit3", old_passphrase, window="AskPassphraseDialog") + gui.wait_for_property("passphraseOkButton", "enabled", window="AskPassphraseDialog", value=True, timeout_ms=5000) + self.capture_screenshot(gui, "encrypt_wallet_dialog", window="AskPassphraseDialog") + gui.click("passphraseOkButton", window="AskPassphraseDialog") + + gui.wait_for_window("encryptWalletConfirmDialog", timeout_ms=10000) + self.capture_screenshot(gui, "encrypt_wallet_confirm", window="encryptWalletConfirmDialog") + gui.click("encryptWalletContinueButton", window="encryptWalletConfirmDialog") + + gui.wait_for_window("walletEncryptedDialog", timeout_ms=10000) + self.capture_screenshot(gui, "wallet_encrypted_message", window="walletEncryptedDialog") + gui.click("walletEncryptedOkButton", window="walletEncryptedDialog") + + self.wait_until( + lambda: gui.get_property("changePassphraseAction", "enabled", window="mainWindow") is True, + timeout=10, + ) + assert gui.get_property("encryptWalletAction", "checked", window="mainWindow") + + gui.click("changePassphraseAction", window="mainWindow") + gui.wait_for_window("AskPassphraseDialog", timeout_ms=10000) + gui.set_text("passEdit1", old_passphrase, window="AskPassphraseDialog") + gui.set_text("passEdit2", new_passphrase, window="AskPassphraseDialog") + gui.set_text("passEdit3", new_passphrase, window="AskPassphraseDialog") + gui.wait_for_property("passphraseOkButton", "enabled", window="AskPassphraseDialog", value=True, timeout_ms=5000) + self.capture_screenshot(gui, "change_passphrase_dialog", window="AskPassphraseDialog") + gui.click("passphraseOkButton", window="AskPassphraseDialog") + + gui.wait_for_window("passphraseChangedDialog", timeout_ms=10000) + self.capture_screenshot(gui, "passphrase_changed_message", window="passphraseChangedDialog") + gui.click("passphraseChangedOkButton", window="passphraseChangedDialog") + + assert_raises_rpc_error(-14, "wallet passphrase entered was incorrect", gui_wallet.walletpassphrase, old_passphrase, 1) + gui_wallet.walletpassphrase(new_passphrase, 1) + gui_wallet.walletlock() + + gui.click("sendCoinsAction", window="mainWindow") + gui.wait_for_view("SendCoinsDialog", window="mainWindow", timeout_ms=10000) + gui.set_text("payTo", recipient_address, window="mainWindow") + gui.set_text("payAmount", str(amount), window="mainWindow") + self.capture_screenshot(gui, "encrypted_send_form", window="mainWindow") + gui.click("sendButton", window="mainWindow") + + gui.wait_for_window("AskPassphraseDialog", timeout_ms=10000) + self.capture_screenshot(gui, "unlock_wallet_dialog", window="AskPassphraseDialog") + gui.click("passphraseCancelButton", window="AskPassphraseDialog") + self.wait_until(lambda: gui.get_active_window() == "mainWindow", timeout=10) + assert_equal(self.nodes[0].getrawmempool(), []) + except Exception: + self.dump_gui_state(gui) + raise + finally: + gui.close() + + +if __name__ == "__main__": + WalletSecurityGuiTest(__file__).main() diff --git a/test/functional/gui_wallet_send.py b/test/functional/gui_wallet_send.py index e02f5113167..f31a632e298 100644 --- a/test/functional/gui_wallet_send.py +++ b/test/functional/gui_wallet_send.py @@ -5,128 +5,20 @@ """End-to-end send transaction test via bitcoin-qt and the test bridge.""" from decimal import Decimal -import os -import re from test_framework.qt import ( - QtDriver, - bitcoin_qt_supports_test_automation, - find_bitcoin_qt_binary, + GUI_NODE_INDEX, + QtWidgetsTestFramework, + parse_display_amount, ) -from test_framework.test_framework import BitcoinTestFramework, SkipTest -from test_framework.test_node import TestNode from test_framework.util import ( assert_equal, - get_datadir_path, ) -GUI_NODE_INDEX = 2 - - -def parse_display_amount(text): - cleaned = re.sub(r"[^0-9.\-]", "", text) - return Decimal(cleaned) if cleaned else Decimal("0") - - -class WalletSendGuiTest(BitcoinTestFramework): +class WalletSendGuiTest(QtWidgetsTestFramework): def set_test_params(self): - self.num_nodes = 3 - self.noban_tx_relay = True - self.supports_cli = False - self.uses_wallet = True - self.extra_args = [ - ["-fallbackfee=0.0002", "-walletrbf=1"], - ["-fallbackfee=0.0002", "-walletrbf=1"], - ["-fallbackfee=0.0002", "-walletrbf=1"], - ] - - def skip_test_if_missing_module(self): - self.skip_if_no_wallet() - try: - self.gui_binary = find_bitcoin_qt_binary(self.config) - except FileNotFoundError as e: - raise SkipTest(str(e)) - if not bitcoin_qt_supports_test_automation(self.gui_binary): - raise SkipTest("bitcoin-qt was not built with -DENABLE_TEST_AUTOMATION=ON") - - def setup_nodes(self): - self.socket_path = os.path.join(self.options.tmpdir, "qt_bridge.sock") - self.screenshot_dir = os.path.join(self.options.tmpdir, "qt_screenshots") - self.screenshot_index = 0 - - self.add_nodes(2, self.extra_args[:2]) - - gui_binaries = self.get_binaries() - gui_binaries.paths.bitcoind = self.gui_binary - gui_node = TestNode( - GUI_NODE_INDEX, - get_datadir_path(self.options.tmpdir, GUI_NODE_INDEX), - chain=self.chain, - rpchost=None, - timewait=self.rpc_timeout, - timeout_factor=self.options.timeout_factor, - binaries=gui_binaries, - coverage_dir=self.options.coveragedir, - cwd=self.options.tmpdir, - extra_args=self.extra_args[GUI_NODE_INDEX] + [f"-test-automation={self.socket_path}"], - use_cli=self.options.usecli, - start_perf=self.options.perf, - v2transport=self.options.v2transport, - uses_wallet=self.uses_wallet, - ) - self.nodes.append(gui_node) - - self.start_node(0) - self.start_node(1) - self.start_node(GUI_NODE_INDEX, env={"QT_QPA_PLATFORM": "offscreen"}) - - if self.uses_wallet: - self.import_deterministic_coinbase_privkeys() - - if not self.setup_clean_chain: - for node in self.nodes: - assert_equal(node.getblockchaininfo()["blocks"], 199) - block_hash = self.generate(self.nodes[0], 1, sync_fun=self.no_op)[0] - block = self.nodes[0].getblock(blockhash=block_hash, verbosity=0) - for node in self.nodes: - node.submitblock(block) - - def dump_gui_state(self, gui): - try: - self.log.info("GUI windows: %s", gui.list_windows()) - except Exception as e: - self.log.info("Failed to list GUI windows: %s", e) - try: - active_window = gui.get_active_window() - self.log.info("Active GUI window: %s", active_window) - if active_window == "QMessageBox": - self.log.info("Active message box text: %s", gui.get_text("qt_msgbox_label", window=active_window)) - except Exception as e: - self.log.info("Failed to inspect active GUI window: %s", e) - try: - self.log.info("Main window objects: %s", gui.list_objects(window="mainWindow")) - except Exception as e: - self.log.info("Failed to list main window objects: %s", e) - self.capture_screenshot(gui, "failure_active_window") - self.capture_screenshot(gui, "failure_main_window", window="mainWindow") - - def capture_screenshot(self, gui, step_name, *, window=None): - os.makedirs(self.screenshot_dir, exist_ok=True) - self.screenshot_index += 1 - safe_name = re.sub(r"[^a-z0-9_]+", "_", step_name.lower()).strip("_") - target_window = window - if target_window is None: - try: - target_window = gui.get_active_window() - except Exception: - target_window = "mainWindow" - path = os.path.join(self.screenshot_dir, f"{self.screenshot_index:02d}_{safe_name}.png") - try: - gui.save_screenshot(path, window=target_window) - self.log.info("Saved GUI screenshot %s (%s)", path, target_window) - except Exception as e: - self.log.info("Failed to save GUI screenshot %s: %s", path, e) + super().set_test_params() def run_test(self): gui_wallet = self.nodes[GUI_NODE_INDEX].get_wallet_rpc(self.default_wallet_name) @@ -135,9 +27,9 @@ def run_test(self): amount_sats = int(amount * Decimal(100_000_000)) recipient_address = recipient_wallet.getnewaddress() - gui = QtDriver(self.socket_path, timeout=60) + gui = self.create_gui_driver() try: - gui.wait_for_window("mainWindow", timeout_ms=60000) + self.wait_for_main_window(gui) gui.wait_for_property("sendCoinsAction", "enabled", window="mainWindow", value=True, timeout_ms=60000) self.capture_screenshot(gui, "main_window_ready", window="mainWindow") diff --git a/test/functional/gui_wallet_send_advanced.py b/test/functional/gui_wallet_send_advanced.py new file mode 100644 index 00000000000..0cb83026cc6 --- /dev/null +++ b/test/functional/gui_wallet_send_advanced.py @@ -0,0 +1,56 @@ +#!/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. +"""Advanced send flow test covering PSBT creation via bitcoin-qt.""" + +from decimal import Decimal + +from test_framework.qt import GUI_NODE_INDEX, QtWidgetsTestFramework +from test_framework.util import assert_equal + + +class WalletSendAdvancedGuiTest(QtWidgetsTestFramework): + def set_test_params(self): + super().set_test_params() + + def run_test(self): + recipient_address = self.nodes[1].get_wallet_rpc(self.default_wallet_name).getnewaddress() + amount = Decimal("0.5") + amount_sats = int(amount * Decimal(100_000_000)) + + gui = self.create_gui_driver() + try: + self.wait_for_main_window(gui) + gui.click("optionsAction", window="mainWindow") + gui.wait_for_window("OptionsDialog", timeout_ms=10000) + if not gui.get_property("m_enable_psbt_controls", "checked", window="OptionsDialog"): + gui.click("m_enable_psbt_controls", window="OptionsDialog") + gui.click("okButton", window="OptionsDialog") + + gui.click("sendCoinsAction", window="mainWindow") + gui.wait_for_view("SendCoinsDialog", window="mainWindow", timeout_ms=10000) + gui.set_text("payTo", recipient_address, window="mainWindow") + gui.set_text("payAmount", str(amount), window="mainWindow") + self.wait_until(lambda: gui.get_property("payAmount", "value", window="mainWindow") == amount_sats, timeout=5) + self.capture_screenshot(gui, "psbt_send_form", window="mainWindow") + gui.click("sendButton", window="mainWindow") + + gui.wait_for_window("sendConfirmationDialog", timeout_ms=10000) + gui.wait_for_property("createUnsignedButton", "enabled", window="sendConfirmationDialog", value=True, timeout_ms=10000) + self.capture_screenshot(gui, "psbt_confirmation", window="sendConfirmationDialog") + gui.click("createUnsignedButton", window="sendConfirmationDialog") + + gui.wait_for_window("psbt_copied_message", timeout_ms=10000) + self.capture_screenshot(gui, "psbt_created", window="psbt_copied_message") + assert_equal(self.nodes[0].getrawmempool(), []) + gui.click("psbtDiscardButton", window="psbt_copied_message") + except Exception: + self.dump_gui_state(gui) + raise + finally: + gui.close() + + +if __name__ == "__main__": + WalletSendAdvancedGuiTest(__file__).main() diff --git a/test/functional/test_framework/qt.py b/test/functional/test_framework/qt.py index 808de6c6425..89a627b7ed0 100644 --- a/test/functional/test_framework/qt.py +++ b/test/functional/test_framework/qt.py @@ -6,9 +6,18 @@ import json import os +import re import socket import subprocess import time +from decimal import Decimal + +from .test_framework import BitcoinTestFramework, SkipTest +from .test_node import TestNode +from .util import assert_equal, get_datadir_path + + +GUI_NODE_INDEX = 2 class QtDriverError(Exception): @@ -195,3 +204,136 @@ def bitcoin_qt_supports_test_automation(binary_path): ) output = result.stdout + result.stderr return "-test-automation=" in output + + +def parse_display_amount(text): + cleaned = re.sub(r"[^0-9.\-]", "", text) + return Decimal(cleaned) if cleaned else Decimal("0") + + +class QtWidgetsTestFramework(BitcoinTestFramework): + GUI_NODE_INDEX = GUI_NODE_INDEX + + def set_test_params(self): + self.num_nodes = 3 + self.noban_tx_relay = True + self.supports_cli = False + self.uses_wallet = True + common_args = ["-fallbackfee=0.0002", "-walletrbf=1"] + self.extra_args = [list(common_args), list(common_args), list(common_args)] + + def run_test(self): + raise NotImplementedError + + def skip_test_if_missing_module(self): + self.skip_if_no_wallet() + try: + self.gui_binary = find_bitcoin_qt_binary(self.config) + except FileNotFoundError as e: + raise SkipTest(str(e)) + if not bitcoin_qt_supports_test_automation(self.gui_binary): + raise SkipTest("bitcoin-qt was not built with -DENABLE_TEST_AUTOMATION=ON") + + def get_gui_node_extra_args(self): + return list(self.extra_args[self.GUI_NODE_INDEX]) + + def should_import_gui_wallet(self): + return self.uses_wallet + + def gui_start_env(self): + return {"QT_QPA_PLATFORM": "offscreen"} + + def setup_nodes(self): + self.socket_path = os.path.join(self.options.tmpdir, "qt_bridge.sock") + self.screenshot_dir = os.path.join(self.options.tmpdir, "qt_screenshots") + self.screenshot_index = 0 + + self.add_nodes(2, self.extra_args[:2]) + + gui_binaries = self.get_binaries() + gui_binaries.paths.bitcoind = self.gui_binary + gui_node = TestNode( + self.GUI_NODE_INDEX, + get_datadir_path(self.options.tmpdir, self.GUI_NODE_INDEX), + chain=self.chain, + rpchost=None, + timewait=self.rpc_timeout, + timeout_factor=self.options.timeout_factor, + binaries=gui_binaries, + coverage_dir=self.options.coveragedir, + cwd=self.options.tmpdir, + extra_args=self.get_gui_node_extra_args() + [f"-test-automation={self.socket_path}"], + use_cli=self.options.usecli, + start_perf=self.options.perf, + v2transport=self.options.v2transport, + uses_wallet=self.uses_wallet, + ) + self.nodes.append(gui_node) + + self.start_node(0) + self.start_node(1) + self.start_node(self.GUI_NODE_INDEX, env=self.gui_start_env()) + + if self.uses_wallet: + self.init_wallet(node=0) + self.init_wallet(node=1) + if self.should_import_gui_wallet(): + self.init_wallet(node=self.GUI_NODE_INDEX) + + if not self.setup_clean_chain: + for node in self.nodes: + assert_equal(node.getblockchaininfo()["blocks"], 199) + block_hash = self.generate(self.nodes[0], 1, sync_fun=self.no_op)[0] + block = self.nodes[0].getblock(blockhash=block_hash, verbosity=0) + for node in self.nodes: + node.submitblock(block) + + def create_gui_driver(self, *, timeout=60): + return QtDriver(self.socket_path, timeout=timeout) + + def wait_for_main_window(self, gui, *, timeout_ms=60000): + gui.wait_for_window("mainWindow", timeout_ms=timeout_ms) + return gui + + def restart_gui(self, gui=None): + if gui is not None: + gui.close() + self.stop_node(self.GUI_NODE_INDEX) + self.start_node(self.GUI_NODE_INDEX, env=self.gui_start_env()) + return self.create_gui_driver() + + def capture_screenshot(self, gui, step_name, *, window=None): + os.makedirs(self.screenshot_dir, exist_ok=True) + self.screenshot_index += 1 + safe_name = re.sub(r"[^a-z0-9_]+", "_", step_name.lower()).strip("_") + target_window = window + if target_window is None: + try: + target_window = gui.get_active_window() + except Exception: + target_window = "mainWindow" + path = os.path.join(self.screenshot_dir, f"{self.screenshot_index:02d}_{safe_name}.png") + try: + gui.save_screenshot(path, window=target_window) + self.log.info("Saved GUI screenshot %s (%s)", path, target_window) + except Exception as e: + self.log.info("Failed to save GUI screenshot %s: %s", path, e) + + def dump_gui_state(self, gui): + try: + self.log.info("GUI windows: %s", gui.list_windows()) + except Exception as e: + self.log.info("Failed to list GUI windows: %s", e) + try: + active_window = gui.get_active_window() + self.log.info("Active GUI window: %s", active_window) + if active_window == "QMessageBox": + self.log.info("Active message box text: %s", gui.get_text("qt_msgbox_label", window=active_window)) + except Exception as e: + self.log.info("Failed to inspect active GUI window: %s", e) + try: + self.log.info("Main window objects: %s", gui.list_objects(window="mainWindow")) + except Exception as e: + self.log.info("Failed to list main window objects: %s", e) + self.capture_screenshot(gui, "failure_active_window") + self.capture_screenshot(gui, "failure_main_window", window="mainWindow") diff --git a/test/functional/test_runner.py b/test/functional/test_runner.py index 7ec970d7d25..88693e54f4a 100755 --- a/test/functional/test_runner.py +++ b/test/functional/test_runner.py @@ -87,6 +87,12 @@ EXTENDED_SCRIPTS = [ # These tests are not run by default. # Longest test should go first, to favor running tests in parallel + 'gui_wallet_security.py', + 'gui_wallet_lifecycle.py', + 'gui_wallet_send_advanced.py', + 'gui_wallet_receive.py', + 'gui_settings_persistence.py', + 'gui_node_window.py', 'feature_pruning.py', 'feature_dbcrash.py', 'feature_index_prune.py', From 15349fcea6b4b64b774ea3ef4f32fa9aaffa90c7 Mon Sep 17 00:00:00 2001 From: johnny9 Date: Sat, 14 Mar 2026 02:42:12 +0800 Subject: [PATCH 3/3] test: fix gui test lint --- test/functional/gui_wallet_send_advanced.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/test/functional/gui_wallet_send_advanced.py b/test/functional/gui_wallet_send_advanced.py index 0cb83026cc6..4485e076e0f 100644 --- a/test/functional/gui_wallet_send_advanced.py +++ b/test/functional/gui_wallet_send_advanced.py @@ -6,7 +6,7 @@ from decimal import Decimal -from test_framework.qt import GUI_NODE_INDEX, QtWidgetsTestFramework +from test_framework.qt import QtWidgetsTestFramework from test_framework.util import assert_equal