Skip to content

Commit 1c257b0

Browse files
committed
test: add QML RBF controls e2e coverage (Issue #17)
1 parent d2edf67 commit 1c257b0

File tree

2 files changed

+345
-0
lines changed

2 files changed

+345
-0
lines changed

doc/test-automation-selectors.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ It supports the parity backlog Definition of Done (`DefinitionOfDone.md`) requir
2424
- Receive flow: `receiveAmountInput`, `receiveLabelInput`, `receiveContactSelectButton`, `receiveContactsPopup`, `receiveContactsSearchInput`, `receiveContactRow`, `receiveContactSelectFirstActionButton`, `receiveContactUseButton`, `receiveCreateAddressButton`, `receiveCopySelectedUriButton`, `receiveQrImage`
2525
- Send flow: `sendAddressInput`, `sendOpenContactsButton`, `sendContactsPopup`, `sendContactsSearchInput`, `sendContactLabelInput`, `sendContactAddressInput`, `sendContactSaveButton`, `sendContactDeleteButton`, `sendContactUseButton`, `sendContinueButton`, `sendResultPopup`
2626
- Wallet tabs/pages: `walletSendPage`, `walletRequestPaymentPage`, `activityListView`, `activityOpenFirstRowActionButton`, `activityOpenRowAddressInput`, `activityOpenRowByAddressActionButton`, `activityDetailsPage`, `activityDetailsBackButton`
27+
- Activity RBF actions: `activityDetailsBumpButton`, `activityDetailsBumpPopup`, `activityDetailsBumpPreviewButton`, `activityDetailsBumpConfirmPopup`, `activityDetailsBumpConfirmButton`, `activityDetailsBumpDisabledReasonText`, `activityDetailsCancelButton`, `activityDetailsCancelPopup`, `activityDetailsCancelConfirmButton`, `activityDetailsRbfStatusText`, `activityDetailsReplacementTxidText`
2728
- Onboarding storage flow: `onboardingStorageAmountDetailedSettingsButton`, `storageSettingsPruneTargetInput`, `storageLocationDefaultOption`
2829
- RPC console flow: `aboutDeveloperOptionsItem`, `developerRpcConsoleItem`, `rpcConsoleInput`, `rpcConsoleSubmitButton`, `rpcConsoleOutputText`
2930
- Mempool diagnostics flow: `developerMempoolDiagnosticsItem`, `mempoolDiagnosticsRefreshButton`, `mempoolDiagnosticsTxCountValue`, `mempoolDiagnosticsDynamicUsageValue`, `mempoolDiagnosticsMaxUsageValue`
Lines changed: 344 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,344 @@
1+
#!/usr/bin/env python3
2+
# Copyright (c) 2026 The Bitcoin Core developers
3+
# Distributed under the MIT software license, see the accompanying
4+
# file COPYING or http://www.opensource.org/licenses/mit-license.php.
5+
"""End-to-end QML test for activity RBF speed-up/cancel controls.
6+
7+
Runs three isolated scenarios to avoid page-stack/test-object ambiguity:
8+
1. Eligible speed-up flow with replacement txid/status verification.
9+
2. Cancel/abandon flow verification for an abandon-eligible transaction.
10+
3. Ineligible non-RBF transaction disables speed-up with explanatory reason.
11+
"""
12+
13+
import argparse
14+
import sys
15+
from decimal import Decimal
16+
from pathlib import Path
17+
from urllib.parse import quote
18+
19+
from qml_test_harness import QmlTestHarness, StepScreenshotRecorder
20+
21+
REPO_ROOT = Path(__file__).resolve().parents[2]
22+
BITCOIN_FUNCTIONAL_PATH = REPO_ROOT / "bitcoin" / "test" / "functional"
23+
if str(BITCOIN_FUNCTIONAL_PATH) not in sys.path:
24+
sys.path.insert(0, str(BITCOIN_FUNCTIONAL_PATH))
25+
26+
from test_framework.authproxy import AuthServiceProxy, JSONRPCException
27+
from test_framework.util import wait_until_helper_internal
28+
29+
30+
WALLET_NAME = "qml_rbf_wallet"
31+
RECEIVER_WALLET_NAME = "qml_rbf_receiver"
32+
INITIAL_BLOCKS = 130
33+
34+
35+
class ScenarioContext:
36+
def __init__(self, harness, gui, shots, rpc, wallet_rpc, receiver_wallet_rpc):
37+
self.harness = harness
38+
self.gui = gui
39+
self.shots = shots
40+
self.rpc = rpc
41+
self.wallet_rpc = wallet_rpc
42+
self.receiver_wallet_rpc = receiver_wallet_rpc
43+
44+
45+
def parse_args():
46+
parser = argparse.ArgumentParser(description="QML E2E RBF speed-up/cancel controls test")
47+
parser.add_argument(
48+
"--socket-path",
49+
help="Attach to an existing bridge socket. Not supported by this test.",
50+
)
51+
parser.add_argument(
52+
"--screenshot-dir",
53+
help="If set, save step screenshots (PNG) into this directory.",
54+
)
55+
return parser.parse_args()
56+
57+
58+
def build_rpc_url(user, password, port, wallet_name=None):
59+
auth = f"{quote(user, safe='')}:{quote(password, safe='')}"
60+
base = f"http://{auth}@127.0.0.1:{port}"
61+
if wallet_name is None:
62+
return base
63+
return f"{base}/wallet/{quote(wallet_name, safe='')}"
64+
65+
66+
def read_cookie(datadir):
67+
cookie_path = Path(datadir) / "regtest" / ".cookie"
68+
wait_until_helper_internal(lambda: cookie_path.is_file(), timeout=60)
69+
cookie = cookie_path.read_text(encoding="utf8").strip()
70+
user, password = cookie.split(":", 1)
71+
return user, password
72+
73+
74+
def connect_rpc(datadir, rpc_port):
75+
user, password = read_cookie(datadir)
76+
rpc = AuthServiceProxy(build_rpc_url(user, password, rpc_port), timeout=120)
77+
78+
def rpc_ready():
79+
try:
80+
return rpc.getblockchaininfo()["chain"] == "regtest"
81+
except Exception:
82+
return False
83+
84+
wait_until_helper_internal(rpc_ready, timeout=60)
85+
86+
wallet_rpc = AuthServiceProxy(
87+
build_rpc_url(user, password, rpc_port, WALLET_NAME), timeout=120
88+
)
89+
receiver_wallet_rpc = AuthServiceProxy(
90+
build_rpc_url(user, password, rpc_port, RECEIVER_WALLET_NAME), timeout=120
91+
)
92+
return rpc, wallet_rpc, receiver_wallet_rpc
93+
94+
95+
def ensure_wallet_loaded(rpc, wallet_name):
96+
try:
97+
rpc.createwallet(wallet_name)
98+
return
99+
except JSONRPCException as e:
100+
if "already exists" not in str(e).lower():
101+
raise
102+
try:
103+
rpc.loadwallet(wallet_name)
104+
except JSONRPCException as e:
105+
if "already loaded" not in str(e).lower():
106+
raise
107+
108+
109+
def wait_for_gui_wallet_ready(gui):
110+
gui.wait_for_property("walletBadge", "loading", timeout_ms=30000, value=False)
111+
gui.wait_for_property("walletBadge", "noWalletLoaded", timeout_ms=30000, value=False)
112+
gui.wait_for_property("walletBadge", "text", timeout_ms=30000, contains=WALLET_NAME)
113+
114+
115+
def select_wallet_tab(gui, tab_object_name, expected_page):
116+
gui.click(tab_object_name)
117+
gui.wait_for_page(expected_page, timeout_ms=15000)
118+
119+
120+
def setup_scenario(args, tag):
121+
harness = QmlTestHarness(
122+
skip_onboard=True,
123+
# -walletbroadcast=0 keeps unconfirmed wallet txs local, enabling
124+
# deterministic abandon coverage in a one-node harness.
125+
extra_args=["-fallbackfee=0.00001000", "-walletbroadcast=0"],
126+
)
127+
harness.start()
128+
129+
gui = harness.driver
130+
shots = StepScreenshotRecorder(gui, args.screenshot_dir)
131+
rpc, wallet_rpc, receiver_wallet_rpc = connect_rpc(harness.datadir, harness.rpc_port)
132+
133+
ensure_wallet_loaded(rpc, RECEIVER_WALLET_NAME)
134+
ensure_wallet_loaded(rpc, WALLET_NAME)
135+
wait_for_gui_wallet_ready(gui)
136+
137+
funding_addr = wallet_rpc.getnewaddress(f"qml-rbf-{tag}-fund", "bech32")
138+
rpc.generatetoaddress(INITIAL_BLOCKS, funding_addr)
139+
140+
return ScenarioContext(harness, gui, shots, rpc, wallet_rpc, receiver_wallet_rpc)
141+
142+
143+
def teardown_scenario(ctx):
144+
ctx.harness.stop()
145+
146+
147+
def list_send_txids(wallet_rpc):
148+
return {
149+
tx["txid"]
150+
for tx in wallet_rpc.listtransactions("*", 5000, 0, True)
151+
if tx.get("txid") and tx.get("category") == "send"
152+
}
153+
154+
155+
def wait_for_new_send_txid(wallet_rpc, known_send_txids):
156+
observed = {"txid": None}
157+
158+
def find_new_send_txid():
159+
for tx in wallet_rpc.listtransactions("*", 5000, 0, True):
160+
txid = tx.get("txid")
161+
if not txid or txid in known_send_txids:
162+
continue
163+
if tx.get("category") == "send":
164+
observed["txid"] = txid
165+
return True
166+
return False
167+
168+
wait_until_helper_internal(find_new_send_txid, timeout=30)
169+
if observed["txid"] is None:
170+
raise AssertionError("Did not observe new send transaction")
171+
172+
known_send_txids.add(observed["txid"])
173+
return observed["txid"]
174+
175+
176+
def send_via_qml(gui, wallet_rpc, known_send_txids, address, amount_btc, note):
177+
select_wallet_tab(gui, "walletSendTab", "walletSendPage")
178+
gui.set_text("sendAddressInput", address)
179+
gui.set_text("sendAmountInput", str(amount_btc))
180+
gui.set_text("sendNoteInput", note)
181+
gui.wait_for_property("sendContinueButton", "enabled", timeout_ms=15000, value=True)
182+
183+
gui.click("sendContinueButton")
184+
gui.wait_for_page("walletSendReviewPage", timeout_ms=15000)
185+
gui.click("sendConfirmButton")
186+
gui.wait_for_page("sendResultPopup", timeout_ms=15000)
187+
gui.click("sendResultCloseButton")
188+
189+
return wait_for_new_send_txid(wallet_rpc, known_send_txids)
190+
191+
192+
def open_activity_details_for_txid(gui, expected_txid):
193+
select_wallet_tab(gui, "walletActivityTab", "activityListView")
194+
195+
def open_and_match():
196+
gui.click("activityOpenFirstRowActionButton")
197+
gui.wait_for_page("activityDetailsPage", timeout_ms=10000)
198+
opened_txid = gui.get_property("activityDetailsPage", "txid")
199+
if opened_txid == expected_txid:
200+
return True
201+
gui.click("activityDetailsBackButton")
202+
gui.wait_for_page("activityListView", timeout_ms=10000)
203+
return False
204+
205+
wait_until_helper_internal(open_and_match, timeout=30)
206+
207+
208+
def transaction_is_abandoned(tx_info):
209+
if tx_info.get("abandoned") is True:
210+
return True
211+
for detail in tx_info.get("details", []):
212+
if detail.get("abandoned") is True:
213+
return True
214+
return False
215+
216+
217+
def run_bump_scenario(args):
218+
ctx = setup_scenario(args, "bump")
219+
try:
220+
gui = ctx.gui
221+
wallet_rpc = ctx.wallet_rpc
222+
receiver_wallet_rpc = ctx.receiver_wallet_rpc
223+
224+
known_send_txids = list_send_txids(wallet_rpc)
225+
destination = receiver_wallet_rpc.getnewaddress("qml-rbf-bump", "bech32")
226+
original_txid = send_via_qml(
227+
gui,
228+
wallet_rpc,
229+
known_send_txids,
230+
destination,
231+
Decimal("0.00300000"),
232+
"qml-rbf-bump",
233+
)
234+
235+
open_activity_details_for_txid(gui, original_txid)
236+
gui.wait_for_property("activityDetailsBumpButton", "enabled", timeout_ms=10000, value=True)
237+
238+
gui.click("activityDetailsBumpButton")
239+
gui.wait_for_property("activityDetailsBumpPopup", "opened", timeout_ms=10000, value=True)
240+
gui.click("activityDetailsBumpPreviewButton")
241+
gui.wait_for_property("activityDetailsBumpConfirmPopup", "opened", timeout_ms=10000, value=True)
242+
gui.click("activityDetailsBumpConfirmButton")
243+
244+
replacement_txid = gui.wait_for_property(
245+
"activityDetailsReplacementTxidText", "text", timeout_ms=15000, non_empty=True
246+
)
247+
status_text = gui.wait_for_property(
248+
"activityDetailsRbfStatusText", "text", timeout_ms=15000, non_empty=True
249+
)
250+
251+
assert replacement_txid != original_txid
252+
assert "replacement" in status_text.lower(), status_text
253+
254+
replacement_tx = wallet_rpc.gettransaction(replacement_txid)
255+
assert replacement_tx.get("txid") == replacement_txid
256+
257+
gui.wait_for_property("activityDetailsBumpButton", "enabled", timeout_ms=10000, value=False)
258+
second_bump_reason = gui.wait_for_property(
259+
"activityDetailsBumpDisabledReasonText", "text", timeout_ms=10000, non_empty=True
260+
)
261+
assert len(second_bump_reason.strip()) > 0
262+
263+
original_tx = wallet_rpc.gettransaction(original_txid)
264+
if "replaced_by_txid" in original_tx:
265+
assert original_tx["replaced_by_txid"] == replacement_txid
266+
else:
267+
conflicts = original_tx.get("walletconflicts", [])
268+
assert replacement_txid in conflicts, "Original tx missing replacement linkage"
269+
270+
ctx.shots.capture("rbf_bump_success")
271+
return original_txid, replacement_txid
272+
finally:
273+
teardown_scenario(ctx)
274+
275+
276+
def run_cancel_scenario(args):
277+
ctx = setup_scenario(args, "cancel")
278+
try:
279+
gui = ctx.gui
280+
wallet_rpc = ctx.wallet_rpc
281+
receiver_wallet_rpc = ctx.receiver_wallet_rpc
282+
283+
cancel_destination = receiver_wallet_rpc.getnewaddress("qml-rbf-cancel", "bech32")
284+
cancel_txid = wallet_rpc.sendtoaddress(
285+
cancel_destination,
286+
Decimal("0.00250000"),
287+
"qml-rbf-cancel",
288+
)
289+
290+
wait_until_helper_internal(
291+
lambda: any(
292+
tx.get("txid") == cancel_txid
293+
for tx in wallet_rpc.listtransactions("*", 5000, 0, True)
294+
),
295+
timeout=30,
296+
)
297+
298+
open_activity_details_for_txid(gui, cancel_txid)
299+
gui.wait_for_property("activityDetailsCancelButton", "enabled", timeout_ms=10000, value=True)
300+
301+
gui.click("activityDetailsCancelButton")
302+
gui.wait_for_property("activityDetailsCancelPopup", "opened", timeout_ms=10000, value=True)
303+
gui.click("activityDetailsCancelConfirmButton")
304+
305+
cancel_status = gui.wait_for_property(
306+
"activityDetailsRbfStatusText", "text", timeout_ms=15000, non_empty=True
307+
)
308+
assert "canceled" in cancel_status.lower(), cancel_status
309+
310+
canceled_tx = wallet_rpc.gettransaction(cancel_txid)
311+
assert transaction_is_abandoned(canceled_tx), "Canceled tx is not marked abandoned"
312+
313+
ctx.shots.capture("rbf_cancel_success")
314+
return cancel_txid
315+
finally:
316+
teardown_scenario(ctx)
317+
318+
def run_tests():
319+
args = parse_args()
320+
if args.socket_path:
321+
raise AssertionError(
322+
"--socket-path is not supported for this test; it requires harness-managed RPC/datadir."
323+
)
324+
325+
original_txid, replacement_txid = run_bump_scenario(args)
326+
cancel_txid = run_cancel_scenario(args)
327+
328+
print("\n" + "=" * 60)
329+
print("QML RBF speed-up/cancel E2E PASSED")
330+
print(f"Original txid: {original_txid}")
331+
print(f"Replacement txid: {replacement_txid}")
332+
print(f"Canceled txid: {cancel_txid}")
333+
print("=" * 60)
334+
335+
336+
if __name__ == "__main__":
337+
try:
338+
run_tests()
339+
except Exception as e:
340+
print(f"\nFAILED: {e}", file=sys.stderr)
341+
import traceback
342+
343+
traceback.print_exc()
344+
sys.exit(1)

0 commit comments

Comments
 (0)