Skip to content

Commit c8f5618

Browse files
committed
Issue 18: add PSBT operations E2E with clipboard recovery flow
1 parent 4b9128e commit c8f5618

File tree

8 files changed

+389
-14
lines changed

8 files changed

+389
-14
lines changed

doc/test-bridge.md

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -133,6 +133,24 @@ Reads the `text` property from a named QML object.
133133
← {"text": "File not found"}
134134
```
135135

136+
#### `get_clipboard_text`
137+
138+
Reads clipboard text from the GUI process.
139+
140+
```json
141+
→ {"cmd": "get_clipboard_text"}
142+
← {"text": "bitcoin:bc1q..."}
143+
```
144+
145+
#### `set_clipboard_text`
146+
147+
Sets clipboard text in the GUI process.
148+
149+
```json
150+
→ {"cmd": "set_clipboard_text", "text": "cHNidP8..."}
151+
← {"ok": true}
152+
```
153+
136154
#### `wait_for_page`
137155

138156
Blocks until the named QML object exists and is visible, or the timeout
@@ -210,6 +228,8 @@ page = gui.get_current_page()
210228
text = gui.get_text("errorLabel")
211229
visible = gui.get_property("importButton", "visible")
212230
objects = gui.list_objects()
231+
clipboard_text = gui.get_clipboard_text()
232+
gui.set_clipboard_text(clipboard_text)
213233
gui.wait_for_property("walletBadge", "loading", value=False)
214234
gui.save_screenshot("/tmp/qml_debug.png")
215235

@@ -233,14 +253,15 @@ headless GUI instance automatically, or attach to an already-running app.
233253
python3 test/functional/qml_test_bridge_sanity.py
234254
python3 test/functional/qml_test_onboarding.py
235255
python3 test/functional/qml_test_send_receive.py
256+
python3 test/functional/qml_test_psbt_operations.py
236257
```
237258

238-
To save step screenshots during the onboarding/send/receive E2E tests, pass a
239-
directory path:
259+
To save step screenshots during E2E tests, pass a directory path:
240260

241261
```bash
242262
python3 test/functional/qml_test_onboarding.py --screenshot-dir /tmp/qml-shots
243263
python3 test/functional/qml_test_send_receive.py --screenshot-dir /tmp/qml-shots
264+
python3 test/functional/qml_test_psbt_operations.py --screenshot-dir /tmp/qml-shots
244265
```
245266

246267
The harness starts `bitcoin-core-app` with `QT_QPA_PLATFORM=offscreen`,
@@ -273,6 +294,7 @@ and does **not** launch or terminate the application.
273294
| `qml_test_bridge_sanity.py` | Bridge protocol smoke test: list_objects, get_current_page, wait_for_property/get_property, screenshot capture, error handling, wait_for_page timeout |
274295
| `qml_test_onboarding.py` | Walks through the full onboarding flow (Cover → Strengthen → Blockclock → StorageLocation → StorageAmount → Connection) |
275296
| `qml_test_send_receive.py` | Full send/receive E2E: create/fund wallet via RPC, create receive request in QML, send in QML, confirm via RPC, verify Activity update |
297+
| `qml_test_psbt_operations.py` | PSBT operations E2E: export unsigned PSBT from review, assert invalid-clipboard import failure, recover via valid import, sign/finalize, broadcast, verify txid in mempool |
276298

277299
## Prerequisite: `objectName` annotations
278300

@@ -341,6 +363,7 @@ their buttons:
341363
| `test/functional/qml_test_bridge_sanity.py` | Bridge protocol sanity test |
342364
| `test/functional/qml_test_onboarding.py` | Onboarding flow walk-through test |
343365
| `test/functional/qml_test_send_receive.py` | Send/receive end-to-end test |
366+
| `test/functional/qml_test_psbt_operations.py` | PSBT operations end-to-end test |
344367

345368
## Security considerations
346369

qml/pages/wallet/PsbtOperations.qml

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -149,7 +149,10 @@ Page {
149149
return
150150
}
151151
broadcastTxid = ""
152-
root.applyResult(wallet.inspectPsbtFromClipboard(Clipboard.text))
152+
const clipboardText = typeof Clipboard.text === "function"
153+
? Clipboard.text()
154+
: Clipboard.text
155+
root.applyResult(wallet.inspectPsbtFromClipboard(clipboardText))
153156
}
154157
}
155158

qml/test/testbridge.cpp

Lines changed: 39 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -209,6 +209,8 @@ QByteArray TestBridge::processCommand(const QByteArray& json_cmd)
209209
return cmdGetText(obj.value(QStringLiteral("objectName")).toString());
210210
} else if (cmd == QLatin1String("get_clipboard_text")) {
211211
return cmdGetClipboardText();
212+
} else if (cmd == QLatin1String("set_clipboard_text")) {
213+
return cmdSetClipboardText(obj.value(QStringLiteral("text")).toString());
212214
} else if (cmd == QLatin1String("save_screenshot")) {
213215
return cmdSaveScreenshot(obj.value(QStringLiteral("path")).toString());
214216
} else if (cmd == QLatin1String("list_objects")) {
@@ -331,6 +333,22 @@ QByteArray TestBridge::cmdClick(const QString& object_name)
331333
return index >= 0 && meta->method(index).invoke(obj, Qt::DirectConnection);
332334
};
333335

336+
auto try_emit_clicked = [obj](const QMetaObject* meta) {
337+
if (const int clicked_index = meta->indexOfSignal("clicked()"); clicked_index >= 0) {
338+
if (meta->method(clicked_index).invoke(obj, Qt::DirectConnection)) {
339+
return true;
340+
}
341+
}
342+
343+
if (const int clicked_bool_index = meta->indexOfSignal("clicked(bool)"); clicked_bool_index >= 0) {
344+
if (meta->method(clicked_bool_index).invoke(obj, Qt::DirectConnection, Q_ARG(bool, true))) {
345+
return true;
346+
}
347+
}
348+
349+
return false;
350+
};
351+
334352
auto* item = qobject_cast<QQuickItem*>(obj);
335353
const QMetaObject* meta = obj->metaObject();
336354
const QString class_name = QString::fromLatin1(meta->className());
@@ -360,18 +378,17 @@ QByteArray TestBridge::cmdClick(const QString& object_name)
360378
// Some custom controls (for example Setting.qml) route interaction through
361379
// an internal MouseArea and do not react correctly to click().
362380
if (item && class_name.startsWith(QStringLiteral("Setting_"))) {
363-
const int clicked_index = meta->indexOfSignal("clicked()");
364-
if (clicked_index >= 0 && meta->method(clicked_index).invoke(obj, Qt::DirectConnection)) {
381+
if (try_emit_clicked(meta)) {
365382
return return_ok();
366383
}
367384
}
368385

369-
// Qt Quick Controls and gui-qml *Button controls are not reliably invokable
370-
// via click()/toggle() in this harness. Prefer firing clicked(), then fall
371-
// back to pointer synthesis.
372-
if (item && class_name.contains(QStringLiteral("Button_"))) {
373-
const int button_clicked_index = meta->indexOfSignal("clicked()");
374-
if (button_clicked_index >= 0 && meta->method(button_clicked_index).invoke(obj, Qt::DirectConnection)) {
386+
// Qt Quick Controls and gui-qml button-style controls are not reliably
387+
// invokable via click()/toggle() in this harness. Prefer firing clicked(),
388+
// then fall back to pointer synthesis.
389+
if (item && (class_name.contains(QStringLiteral("Button_"))
390+
|| class_name.contains(QStringLiteral("ActionItem_")))) {
391+
if (try_emit_clicked(meta)) {
375392
return return_ok();
376393
}
377394
if (synthesize_click()) {
@@ -394,8 +411,7 @@ QByteArray TestBridge::cmdClick(const QString& object_name)
394411
}
395412

396413
// Non-visual fall-back for plain QObject fixtures.
397-
const int clicked_index = meta->indexOfSignal("clicked()");
398-
if (clicked_index >= 0 && meta->method(clicked_index).invoke(obj, Qt::DirectConnection)) {
414+
if (try_emit_clicked(meta)) {
399415
return return_ok();
400416
}
401417

@@ -548,6 +564,19 @@ QByteArray TestBridge::cmdGetClipboardText()
548564
return QJsonDocument(resp).toJson(QJsonDocument::Compact);
549565
}
550566

567+
QByteArray TestBridge::cmdSetClipboardText(const QString& text)
568+
{
569+
if (QGuiApplication::clipboard() == nullptr) {
570+
return errorResponse(QStringLiteral("Clipboard is unavailable"));
571+
}
572+
573+
QGuiApplication::clipboard()->setText(text);
574+
575+
QJsonObject resp;
576+
resp[QStringLiteral("ok")] = true;
577+
return QJsonDocument(resp).toJson(QJsonDocument::Compact);
578+
}
579+
551580
QByteArray TestBridge::cmdSaveScreenshot(const QString& path)
552581
{
553582
if (path.isEmpty()) {

qml/test/testbridge.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@
2929
/// {"cmd": "wait_for_property", "objectName": "<name>", "prop": "<property>", "timeout": <ms>, "value": <json>, "contains": "<substring>", "nonEmpty": true}
3030
/// {"cmd": "get_text", "objectName": "<name>"}
3131
/// {"cmd": "get_clipboard_text"}
32+
/// {"cmd": "set_clipboard_text", "text": "<value>"}
3233
/// {"cmd": "save_screenshot", "path": "</tmp/screenshot.png>"}
3334
/// {"cmd": "list_objects"}
3435
class TestBridge : public QObject
@@ -71,6 +72,7 @@ private Q_SLOTS:
7172
QByteArray cmdWaitForProperty(const QString& object_name, const QString& prop, int timeout_ms, const QJsonValue& expected, bool has_expected, const QString& contains, bool non_empty);
7273
QByteArray cmdGetText(const QString& object_name);
7374
QByteArray cmdGetClipboardText();
75+
QByteArray cmdSetClipboardText(const QString& text);
7476
QByteArray cmdSaveScreenshot(const QString& path);
7577
QByteArray cmdListObjects();
7678

test/functional/qml_driver.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,12 @@ def get_clipboard_text(self):
166166
raise QmlDriverError(f"get_clipboard_text() failed: {resp['error']}")
167167
return resp["text"]
168168

169+
def set_clipboard_text(self, text):
170+
"""Set clipboard text inside the GUI process."""
171+
resp = self._send({"cmd": "set_clipboard_text", "text": text})
172+
if "error" in resp:
173+
raise QmlDriverError(f"set_clipboard_text() failed: {resp['error']}")
174+
169175
def save_screenshot(self, path):
170176
"""Capture the GUI window and save it as a PNG file."""
171177
resp = self._send({"cmd": "save_screenshot", "path": path})

0 commit comments

Comments
 (0)