Skip to content

Commit abc4530

Browse files
committed
Add optional step screenshots to QML E2E tests
Add a --screenshot-dir option to the onboarding and send/receive QML E2E tests and capture screenshots at major checkpoints using a shared StepScreenshotRecorder helper in the test harness. Wait for known StackView instances to report busy == false before capturing screenshots so step images are less likely to catch page transitions mid-animation. Add objectName values for the main and onboarding page stacks so the bridge can observe their busy state. Document the screenshot-dir test option in doc/test-bridge.md.
1 parent c60e90b commit abc4530

6 files changed

Lines changed: 88 additions & 3 deletions

File tree

doc/test-bridge.md

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -235,6 +235,14 @@ python3 test/functional/qml_test_onboarding.py
235235
python3 test/functional/qml_test_send_receive.py
236236
```
237237

238+
To save step screenshots during the onboarding/send/receive E2E tests, pass a
239+
directory path:
240+
241+
```bash
242+
python3 test/functional/qml_test_onboarding.py --screenshot-dir /tmp/qml-shots
243+
python3 test/functional/qml_test_send_receive.py --screenshot-dir /tmp/qml-shots
244+
```
245+
238246
The harness starts `bitcoin-core-app` with `QT_QPA_PLATFORM=offscreen`,
239247
`-resetguisettings`, and a temporary datadir. Some tests (for example
240248
`qml_test_send_receive.py`) opt in to `-skiponboard` for faster deterministic

qml/pages/main.qml

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ ApplicationWindow {
3434

3535
PageStack {
3636
id: main
37+
objectName: "mainStack"
3738
initialItem: {
3839
if (needOnboarding) {
3940
onboardingWizard
@@ -147,6 +148,7 @@ ApplicationWindow {
147148
id: node
148149
PageStack {
149150
id: nodeStack
151+
objectName: "nodeStack"
150152
vertical: true
151153
initialItem: node
152154
Component {

qml/pages/onboarding/OnboardingWizard.qml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import "../../controls"
99

1010
PageStack {
1111
id: root
12+
objectName: "onboardingWizardStack"
1213

1314
signal finished()
1415
initialItem: cover
@@ -54,4 +55,4 @@ PageStack {
5455
onNext: root.finished()
5556
}
5657
}
57-
}
58+
}

test/functional/qml_test_harness.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@
1010

1111
import argparse
1212
import os
13+
import re
1314
import shutil
1415
import signal
1516
import socket
@@ -90,9 +91,59 @@ def parse_args():
9091
"this Unix socket path instead of launching a new one. "
9192
"Start the app with: bitcoin-core-app -test-automation=<path>",
9293
)
94+
parser.add_argument(
95+
"--screenshot-dir",
96+
help="If set, save step screenshots (PNG) into this directory.",
97+
)
9398
return parser.parse_args()
9499

95100

101+
class StepScreenshotRecorder:
102+
"""Optional helper for saving labeled screenshots during tests."""
103+
104+
BUSY_STACK_OBJECTS = (
105+
"mainStack",
106+
"onboardingWizardStack",
107+
"walletSendPage",
108+
)
109+
110+
def __init__(self, driver, screenshot_dir=None):
111+
self.driver = driver
112+
self._counter = 0
113+
self.screenshot_dir = (
114+
os.path.abspath(screenshot_dir) if screenshot_dir else None
115+
)
116+
if self.screenshot_dir:
117+
os.makedirs(self.screenshot_dir, exist_ok=True)
118+
119+
def capture(self, label):
120+
if not self.screenshot_dir:
121+
return None
122+
# Screenshots taken immediately after a click/page change can catch
123+
# StackView transitions mid-animation. Wait for known page stacks to
124+
# finish transitioning (`busy == false`) before capturing.
125+
for object_name in self.BUSY_STACK_OBJECTS:
126+
try:
127+
self.driver.wait_for_property(
128+
object_name, "busy", timeout_ms=5000, value=False
129+
)
130+
except QmlDriverError:
131+
# Ignore missing objects/properties so the recorder remains
132+
# compatible with tests that don't use a given stack.
133+
pass
134+
self._counter += 1
135+
slug = re.sub(r"[^A-Za-z0-9._-]+", "_", label).strip("._-")
136+
if not slug:
137+
slug = "step"
138+
path = os.path.join(self.screenshot_dir, f"{self._counter:02d}_{slug}.png")
139+
info = self.driver.save_screenshot(path)
140+
print(
141+
f" screenshot: {info.get('path', path)} "
142+
f"({info.get('width', '?')}x{info.get('height', '?')})"
143+
)
144+
return path
145+
146+
96147
class QmlTestHarness:
97148
"""Test harness that launches the GUI and connects the test bridge.
98149

test/functional/qml_test_onboarding.py

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,13 @@
1313
import sys
1414
import time
1515

16-
from qml_test_harness import QmlTestHarness, QmlDriverError, dump_qml_tree, parse_args
16+
from qml_test_harness import (
17+
QmlTestHarness,
18+
QmlDriverError,
19+
StepScreenshotRecorder,
20+
dump_qml_tree,
21+
parse_args,
22+
)
1723

1824

1925
def run_tests():
@@ -22,13 +28,15 @@ def run_tests():
2228
try:
2329
harness.start()
2430
gui = harness.driver
31+
shots = StepScreenshotRecorder(gui, args.screenshot_dir)
2532

2633
# The app starts fresh (-resetguisettings), so we should land on
2734
# the onboarding cover page.
2835
page = gui.get_current_page()
2936
print(f"Initial page: {page}")
3037
assert "onboardingCover" in page or "Cover" in page, \
3138
f"Expected to start on onboardingCover, got: {page}"
39+
shots.capture("onboarding_cover")
3240

3341
# Define the expected progression through onboarding.
3442
# Each entry is (button_to_click, expected_next_page).
@@ -48,6 +56,7 @@ def run_tests():
4856
assert expected_page in current, \
4957
f"Expected {expected_page}, got: {current}"
5058
print(f" -> page: {current}")
59+
shots.capture(current)
5160

5261
# Click Next on the final connection page to finish onboarding.
5362
print("Click onboardingConnectionButton (finish onboarding) ...")
@@ -61,6 +70,7 @@ def run_tests():
6170
print(f" -> post-onboarding page: {final_page}")
6271
assert "onboarding" not in final_page.lower(), \
6372
f"Still on an onboarding page after finishing: {final_page}"
73+
shots.capture(f"post_onboarding_{final_page}")
6474

6575
print("\n" + "=" * 50)
6676
print("All tests PASSED")

test/functional/qml_test_send_receive.py

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from pathlib import Path
1919
from urllib.parse import quote
2020

21-
from qml_test_harness import QmlTestHarness, dump_qml_tree
21+
from qml_test_harness import QmlTestHarness, StepScreenshotRecorder, dump_qml_tree
2222

2323
REPO_ROOT = Path(__file__).resolve().parents[2]
2424
BITCOIN_FUNCTIONAL_PATH = REPO_ROOT / "bitcoin" / "test" / "functional"
@@ -42,6 +42,10 @@ def parse_args():
4242
"--socket-path",
4343
help="Attach to an existing bridge socket. Not supported by this test.",
4444
)
45+
parser.add_argument(
46+
"--screenshot-dir",
47+
help="If set, save step screenshots (PNG) into this directory.",
48+
)
4549
return parser.parse_args()
4650

4751

@@ -120,29 +124,34 @@ def run_tests():
120124
try:
121125
harness.start()
122126
gui = harness.driver
127+
shots = StepScreenshotRecorder(gui, args.screenshot_dir)
123128

124129
rpc, wallet_rpc = connect_rpc(harness.datadir, harness.rpc_port)
125130
ensure_wallet_loaded(rpc)
126131

127132
wait_for_gui_wallet_ready(gui)
133+
shots.capture("wallet_ready")
128134

129135
# Fund the wallet with mature coinbase funds.
130136
funding_addr = wallet_rpc.getnewaddress("qml-e2e-funding", "bech32")
131137
rpc.generatetoaddress(101, funding_addr)
132138

133139
# Capture baseline activity count in UI.
134140
select_wallet_tab(gui, "walletActivityTab", "activityListView")
141+
shots.capture("activity_before")
135142
activity_count_before = int(gui.get_property("activityListView", "count"))
136143

137144
# Receive flow: create request/address via QML.
138145
select_wallet_tab(gui, "walletReceiveTab", "walletRequestPaymentPage")
146+
shots.capture("receive_page")
139147
gui.set_text("receiveAmountInput", str(SEND_AMOUNT_BTC))
140148
gui.set_text("receiveLabelInput", RECEIVE_LABEL)
141149
gui.set_text("receiveMessageInput", RECEIVE_MESSAGE)
142150
gui.click("receiveCreateAddressButton")
143151
receive_address_text = gui.wait_for_property(
144152
"receiveAddressText", "text", timeout_ms=15000, non_empty=True
145153
)
154+
shots.capture("receive_request_created")
146155
receive_address = normalize_address(receive_address_text)
147156
assert receive_address, "Receive address is empty"
148157
assert rpc.validateaddress(receive_address)["isvalid"], "Receive address is invalid"
@@ -156,10 +165,13 @@ def run_tests():
156165
gui.set_text("sendAddressInput", receive_address)
157166
gui.set_text("sendAmountInput", str(SEND_AMOUNT_BTC))
158167
gui.set_text("sendNoteInput", SEND_NOTE)
168+
shots.capture("send_form_filled")
159169
gui.click("sendContinueButton")
160170
gui.wait_for_page("walletSendReviewPage", timeout_ms=15000)
171+
shots.capture("send_review")
161172
gui.click("sendConfirmButton")
162173
gui.wait_for_page("sendResultPopup", timeout_ms=15000)
174+
shots.capture("send_result_popup")
163175
gui.click("sendResultCloseButton")
164176

165177
sent_txid = {"value": None}
@@ -197,6 +209,7 @@ def find_sent_tx():
197209
lambda: int(gui.get_property("activityListView", "count")) > activity_count_before,
198210
timeout=30,
199211
)
212+
shots.capture("activity_after")
200213

201214
print("\n" + "=" * 50)
202215
print("QML send/receive E2E PASSED")

0 commit comments

Comments
 (0)