Skip to content

Commit 76ee4ab

Browse files
committed
.github: tests: add bootloader FOTA test and pytest_args filter input
Add a `test_bootloader_fota` test that verifies MCUboot B1 FOTA on thingy91x, including job creation, download, version verification, and cloud status checks. Extract `trigger_fota_poll` as a shared helper. Expose `MCUBOOT_BUNDLEID` via env and wire it through CI workflows. Add a `test_filter`/`pytest_args` input to both `build-and-target-test` and `target-test` workflows so individual tests can be run by name. Signed-off-by: Simen S. Røstad <simen.rostad@nordicsemi.no>
1 parent 87837bd commit 76ee4ab

4 files changed

Lines changed: 131 additions & 14 deletions

File tree

.github/workflows/build-and-target-test.yml

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,12 @@ on:
3333
type: boolean
3434
required: false
3535
default: false
36+
test_filter:
37+
description: 'Pytest -k expression to isolate tests (e.g. "test_app_fota").
38+
Leave empty to run all tests for the selected devices.'
39+
type: string
40+
required: false
41+
default: ""
3642

3743
workflow_call:
3844
inputs:
@@ -157,3 +163,4 @@ jobs:
157163
artifact_run_id: ${{ needs.build.outputs.run_id }}
158164
devices: ${{ needs.setup.outputs.devices }}
159165
run_nightly_tests: ${{ needs.setup.outputs.run_nightly_tests == 'true' }}
166+
pytest_args: ${{ inputs.test_filter != '' && format('-k "{0}"', inputs.test_filter) || '' }}

.github/workflows/target-test.yml

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,12 @@ on:
2222
type: string
2323
required: true
2424
default: thingy91x
25+
pytest_args:
26+
description: Extra arguments appended verbatim to the pytest invocation.
27+
(e.g. "-k test_app_fota"). Leave empty for the default behaviour.
28+
type: string
29+
required: false
30+
default: ""
2531

2632
workflow_dispatch:
2733
inputs:
@@ -48,6 +54,12 @@ on:
4854
type: string
4955
required: true
5056
default: thingy91x
57+
pytest_args:
58+
description: Extra arguments appended verbatim to the pytest invocation.
59+
(e.g. "-k test_app_fota"). Leave empty for the default behaviour.
60+
type: string
61+
required: false
62+
default: ""
5163

5264
permissions:
5365
contents: read
@@ -161,7 +173,8 @@ jobs:
161173
pytest -v "${pytest_marker[@]}" \
162174
--junit-xml=results/test-results.xml \
163175
--html=results/test-results.html --self-contained-html \
164-
${PYTEST_PATH}
176+
${PYTEST_PATH} \
177+
${{ inputs.pytest_args }}
165178
166179
shell: bash
167180
env:
@@ -175,6 +188,7 @@ jobs:
175188
MEMFAULT_ORGANIZATION_SLUG: ${{ vars.MEMFAULT_ORGANIZATION_SLUG }}
176189
MEMFAULT_PROJECT_SLUG: ${{ vars.MEMFAULT_PROJECT_SLUG }}
177190
APP_BUNDLEID: ${{ vars.APP_BUNDLEID }}
191+
MCUBOOT_BUNDLEID: ${{ vars.MCUBOOT_BUNDLEID }}
178192

179193
- name: Generate and Push Power Badge
180194
if: ${{ always() && matrix.device == 'ppk_thingy91x' }}

docs/common/test_and_ci_setup.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ The CI pipeline is triggered as follows:
1919

2020
- Pull Request: `build.yml`, `sonarcloud.yml`, `compliance.yml`. No target tests are run on PR to avoid instabilities.
2121
- Push to main: `build-and-target-test.yml`, `sonarcloud.yml`, `doc-sync.yml`. Only "fast" target tests are run. Avoiding excessively time-consuming tests.
22-
- Nightly: `build-and-target-test.yml`. Full set of target tests. Includes slow tests such as the full modem FOTA test and the power consumption test.
22+
- Nightly: `build-and-target-test.yml`. Full set of target tests. Includes slow tests such as the full modem FOTA test, application and bootloader (MCUboot) FOTA.
2323

2424
### Hardware Tests
2525

tests/on_target/tests/test_functional/test_fota.py

Lines changed: 108 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -27,14 +27,20 @@
2727
MFW_VERSION = "mfw_nrf91x1_2.0.4"
2828

2929
APP_BUNDLEID = os.getenv("APP_BUNDLEID")
30+
MCUBOOT_BUNDLEID = os.getenv("MCUBOOT_BUNDLEID")
31+
32+
BOOTLOADER_VERSION_BASELINE = "2"
33+
BOOTLOADER_VERSION_UPDATED = "3"
34+
BOOTLOADER_FIRMWARE_VERSION_LOG = "Firmware version 3"
3035

3136
TEST_APP_BIN = {
3237
"thingy91x": "artifacts/stable_version_jan_2025-update-signed.bin",
3338
"nrf9151dk": "artifacts/nrf9151dk_mar_2025_update_signed.bin"
3439
}
3540

3641
DEVICE_MSG_TIMEOUT = 60 * 5
37-
APP_FOTA_TIMEOUT = 60 * 10
42+
APP_FOTA_TIMEOUT = 60 * 15
43+
BOOTLOADER_FOTA_TIMEOUT = 60 * 20
3844
FULL_MFW_FOTA_TIMEOUT = 60 * 30
3945

4046
def await_nrfcloud(func, expected, field, timeout):
@@ -61,6 +67,41 @@ def get_modemversion(dut_fota):
6167
shadow = dut_fota.fota.get_device(dut_fota.device_id)
6268
return shadow["state"]["reported"]["device"]["deviceInfo"]["modemFirmware"]
6369

70+
def get_bootloaderversion(dut_fota):
71+
shadow = dut_fota.fota.get_device(dut_fota.device_id)
72+
return shadow["state"]["reported"]["device"]["deviceInfo"]["bootloaderVersion"]
73+
74+
def await_bootloader_version(dut_fota, expected, timeout=DEVICE_MSG_TIMEOUT):
75+
start = time.time()
76+
logger.info(f"Awaiting bootloaderVersion == {expected} in nrfcloud shadow...")
77+
while True:
78+
time.sleep(5)
79+
if time.time() - start > timeout:
80+
raise RuntimeError(
81+
f"Timeout awaiting bootloaderVersion == {expected}")
82+
try:
83+
version = get_bootloaderversion(dut_fota)
84+
except (KeyError, TypeError) as e:
85+
logger.warning(f"bootloaderVersion not in shadow yet: {e}")
86+
continue
87+
except Exception as e:
88+
logger.warning(f"Exception getting bootloaderVersion: {e}")
89+
continue
90+
logger.debug(f"Reported bootloaderVersion: {version}")
91+
if version == expected:
92+
return
93+
94+
def trigger_fota_poll(dut_fota, max_attempts=3):
95+
for i in range(max_attempts):
96+
try:
97+
time.sleep(10)
98+
dut_fota.uart.write("att_button press 1\r\n")
99+
dut_fota.uart.wait_for_str("nrf_cloud_fota_poll: Starting FOTA download", timeout=30)
100+
return
101+
except AssertionError:
102+
continue
103+
raise AssertionError(f"Fota update not available after {max_attempts} attempts")
104+
64105
def perform_disconnect_reconnect(dut_fota, expected_percentage):
65106
"""Helper function to perform a disconnect/reconnect sequence and verify resumption at expected percentage"""
66107
patterns_lte_offline = ["network: lte_lc_evt_handler: PDN connection network detached"]
@@ -170,17 +211,7 @@ def _run_fota(bundle_id="", fota_type="app", fotatimeout=APP_FOTA_TIMEOUT, new_v
170211
pytest.skip(f"FOTA create_job REST API error: {e}")
171212
logger.info(f"Created FOTA Job (ID: {dut_fota.data['job_id']})")
172213

173-
# Sleep a bit and trigger fota poll
174-
for i in range(3):
175-
try:
176-
time.sleep(10)
177-
dut_fota.uart.write("att_button press 1\r\n")
178-
dut_fota.uart.wait_for_str("nrf_cloud_fota_poll: Starting FOTA download", timeout=30)
179-
break
180-
except AssertionError:
181-
continue
182-
else:
183-
raise AssertionError(f"Fota update not available after {i} attempts")
214+
trigger_fota_poll(dut_fota)
184215

185216
if reschedule:
186217
run_fota_reschedule(dut_fota, fota_type)
@@ -284,6 +315,71 @@ def test_app_fota(run_fota_fixture):
284315
bundle_id=APP_BUNDLEID,
285316
)
286317

318+
@pytest.mark.slow
319+
def test_bootloader_fota(dut_fota, hex_file):
320+
'''
321+
Test MCUboot bootloader (B1) FOTA
322+
'''
323+
if os.getenv("DUT_DEVICE_TYPE") != "thingy91x":
324+
pytest.skip("Bootloader FOTA test runs on thingy91x only")
325+
if not MCUBOOT_BUNDLEID:
326+
pytest.skip("MCUBOOT_BUNDLEID environment variable not set")
327+
328+
try:
329+
flash_device(os.path.abspath(hex_file))
330+
dut_fota.uart.xfactoryreset()
331+
dut_fota.uart.flush()
332+
reset_device()
333+
334+
dut_fota.uart.wait_for_str_with_retries(
335+
"Connected to Cloud", max_retries=3, timeout=240, reset_func=reset_device)
336+
337+
await_bootloader_version(dut_fota, BOOTLOADER_VERSION_BASELINE)
338+
339+
try:
340+
dut_fota.data["job_id"] = dut_fota.fota.create_fota_job(
341+
dut_fota.device_id, MCUBOOT_BUNDLEID)
342+
dut_fota.data["bundle_id"] = MCUBOOT_BUNDLEID
343+
except NRFCloudFOTAError as e:
344+
pytest.skip(f"FOTA create_job REST API error: {e}")
345+
logger.info(f"Created bootloader FOTA job (ID: {dut_fota.data['job_id']})")
346+
347+
trigger_fota_poll(dut_fota)
348+
349+
dut_fota.uart.wait_for_str("fota_download: B1 update, selected", timeout=120)
350+
dut_fota.uart.wait_for_str("Download complete", timeout=BOOTLOADER_FOTA_TIMEOUT)
351+
post_download_pos = dut_fota.uart.get_size()
352+
353+
dut_fota.uart.wait_for_str(
354+
BOOTLOADER_FIRMWARE_VERSION_LOG,
355+
timeout=BOOTLOADER_FOTA_TIMEOUT,
356+
start_pos=post_download_pos,
357+
error_msg="Expected B0 fw_info v3 after download",
358+
)
359+
360+
await_nrfcloud(
361+
functools.partial(dut_fota.fota.get_fota_status, dut_fota.data["job_id"]),
362+
"IN_PROGRESS",
363+
"FOTA status",
364+
BOOTLOADER_FOTA_TIMEOUT,
365+
)
366+
await_nrfcloud(
367+
functools.partial(dut_fota.fota.get_fota_status, dut_fota.data["job_id"]),
368+
"COMPLETED",
369+
"FOTA status",
370+
BOOTLOADER_FOTA_TIMEOUT,
371+
)
372+
if not dut_fota.fota.get_fota_completed_executions(dut_fota.data["job_id"]) > 0:
373+
raise AssertionError("Bootloader FOTA job completed but no devices succeeded")
374+
375+
dut_fota.uart.wait_for_str_with_retries(
376+
"Connected to Cloud", max_retries=5, timeout=300, reset_func=reset_device)
377+
378+
await_bootloader_version(dut_fota, BOOTLOADER_VERSION_UPDATED,
379+
timeout=BOOTLOADER_FOTA_TIMEOUT)
380+
finally:
381+
flash_device(os.path.abspath(hex_file))
382+
287383
def test_delta_mfw_fota(run_fota_fixture):
288384
'''
289385
Test delta modem FOTA on nrf9151

0 commit comments

Comments
 (0)