Skip to content

Commit d34c14c

Browse files
feat(nimbus): add Fenix end-to-end enrollment integration test (#15345)
Because * Fenix has no live CI coverage of the recipe contract with Experimenter — the previous version rotted when its CircleCI jobs got gated behind a bot-only branch filter and stopped running, and the Python/Kotlin harness was never updated after the post-Oct-2025 monorepo consolidation renamed gradle outputs and moved Fenix's build context. * Without CI, schema, feature manifest, JEXL, or bucketing changes on either side can regress silently. This commit * Adds `.github/workflows/fenix-integration-test.yml`, matrixed over `[beta, release]`, that downloads a signed Fenix APK from the indexed TaskCluster route (`gecko.v2.mozilla-{beta,release}.latest.mobile.fenix-{beta,release}`), stands up the Experimenter stack, mints a preview-state experiment via the pytest harness, boots an Android emulator with KVM, and runs the full JEXL + bucketing + enrollment path via `nimbus-cli enroll --preserve-targeting --preserve-bucketing`. * Integrates the test into the existing pytest harness: `test_fenix_enrollment.py` uses the standard `@pytest.mark.fenix_enrollment` marker and the `create_fenix_experiment` factory fixture in `android/conftest.py`, which reuses `helpers.create_experiment` + a new `helpers.launch_to_preview` wrapper and polls `/api/v6/experiments/{slug}/` for the allocated bucketConfig. * Disables the emulator's network before enrolling so Fenix's startup `maybeFetchExperiments` can't overwrite our local enrollment with production Remote Settings recipes (which would otherwise evolve-unenroll our test experiment in favor of a real production one that claims the same feature slot). * Adds `fenix-beta` and `fenix-release` variants to `update-firefox.yml` so the existing daily bumper refreshes the pinned TC task ids in `experimenter/tests/firefox_fenix_{beta,release}_build.env`, mirroring the desktop variants. Fixes #15340
1 parent b13b886 commit d34c14c

10 files changed

Lines changed: 283 additions & 213 deletions

File tree

Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
name: Fenix Enrollment Integration Test
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "3 12 * * *"
7+
push:
8+
branches:
9+
- main
10+
- update_firefox_fenix_beta
11+
- update_firefox_fenix_release
12+
pull_request:
13+
merge_group:
14+
types: [checks_requested]
15+
16+
jobs:
17+
test:
18+
name: "Fenix Enrollment (${{ matrix.channel }})"
19+
runs-on: ubuntu-24.04
20+
permissions:
21+
contents: read
22+
strategy:
23+
fail-fast: false
24+
matrix:
25+
include:
26+
- channel: beta
27+
build_env: experimenter/tests/firefox_fenix_beta_build.env
28+
task_id_var: FIREFOX_FENIX_BETA_TASK_ID
29+
- channel: release
30+
build_env: experimenter/tests/firefox_fenix_release_build.env
31+
task_id_var: FIREFOX_FENIX_RELEASE_TASK_ID
32+
env:
33+
INTEGRATION_TEST_NGINX_URL: https://localhost
34+
FENIX_APK_PATH: ${{ github.workspace }}/fenix.apk
35+
FENIX_CHANNEL: ${{ matrix.channel }}
36+
steps:
37+
- uses: actions/checkout@v6
38+
with:
39+
fetch-depth: 0
40+
41+
- uses: ./.github/actions/check-changed-paths
42+
id: check-paths
43+
with:
44+
paths: "experimenter/experimenter/ experimenter/tests/integration/ experimenter/tests/firefox_fenix_beta_build.env experimenter/tests/firefox_fenix_release_build.env"
45+
46+
- uses: ./.github/actions/setup-cached-build
47+
if: steps.check-paths.outputs.should-run == 'true'
48+
49+
- name: Download Fenix ${{ matrix.channel }} APK
50+
if: steps.check-paths.outputs.should-run == 'true'
51+
run: |
52+
. ${{ matrix.build_env }}
53+
if [ -z "${!TASK_ID_VAR}" ]; then
54+
echo "::error::$TASK_ID_VAR is empty in ${{ matrix.build_env }}. Run update-firefox.yml with the ${{ matrix.channel == 'beta' && 'fenix-beta' || 'fenix-release' }} variant to populate it."
55+
exit 1
56+
fi
57+
curl -sSfL -o "$FENIX_APK_PATH" \
58+
"https://firefox-ci-tc.services.mozilla.com/api/queue/v1/task/${!TASK_ID_VAR}/artifacts/public/build/target.x86_64.apk"
59+
env:
60+
TASK_ID_VAR: ${{ matrix.task_id_var }}
61+
62+
- name: Install nimbus-cli
63+
if: steps.check-paths.outputs.should-run == 'true'
64+
run: |
65+
curl -sSfL https://raw.githubusercontent.com/mozilla/application-services/main/install-nimbus-cli.sh -o /tmp/install-nimbus-cli.sh
66+
sudo bash /tmp/install-nimbus-cli.sh --directory /usr/local/bin
67+
68+
- name: Set up Python
69+
if: steps.check-paths.outputs.should-run == 'true'
70+
uses: actions/setup-python@v5
71+
with:
72+
python-version: "3.12"
73+
74+
- name: Install poetry
75+
if: steps.check-paths.outputs.should-run == 'true'
76+
run: pipx install poetry
77+
78+
- name: Bring up Experimenter stack
79+
if: steps.check-paths.outputs.should-run == 'true'
80+
run: |
81+
cp .env.integration-tests .env
82+
make refresh SKIP_DUMMY=1 up_prod_detached
83+
84+
- name: Wait for Experimenter backend to be ready
85+
if: steps.check-paths.outputs.should-run == 'true'
86+
run: curl --retry 60 --retry-delay 5 --retry-all-errors -sfk -o /dev/null https://localhost/__lbheartbeat__
87+
88+
- name: Enable KVM
89+
if: steps.check-paths.outputs.should-run == 'true'
90+
run: |
91+
echo 'KERNEL=="kvm", GROUP="kvm", MODE="0666", OPTIONS+="static_node=kvm"' | sudo tee /etc/udev/rules.d/99-kvm4all.rules
92+
sudo udevadm control --reload-rules
93+
sudo udevadm trigger --name-match=kvm
94+
95+
- name: Prepare Android SDK install dir
96+
if: steps.check-paths.outputs.should-run == 'true'
97+
run: |
98+
sudo mkdir -p /usr/local/lib/android
99+
sudo chown -R "$USER" /usr/local/lib/android
100+
101+
- name: Run Fenix enrollment test on emulator
102+
if: steps.check-paths.outputs.should-run == 'true'
103+
uses: reactivecircus/android-emulator-runner@v2
104+
with:
105+
api-level: 34
106+
arch: x86_64
107+
target: google_apis
108+
profile: pixel_6
109+
disable-animations: true
110+
disk-size: 4096M
111+
emulator-options: -no-window -no-boot-anim -no-audio -accel on -gpu swiftshader_indirect
112+
script: make integration_test_nimbus_fenix
113+
114+
- name: Upload test report on failure
115+
if: failure() && steps.check-paths.outputs.should-run == 'true'
116+
uses: actions/upload-artifact@v4
117+
with:
118+
name: fenix-${{ matrix.channel }}-test-report
119+
path: experimenter/tests/integration/test-reports/
120+
if-no-files-found: ignore

.github/workflows/update-firefox.yml

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,8 @@ on:
1313
- all
1414
- desktop-release
1515
- desktop-beta
16+
- fenix-beta
17+
- fenix-release
1618

1719
jobs:
1820
update:
@@ -31,6 +33,12 @@ jobs:
3133
- application: desktop
3234
channel: beta
3335
display_name: Firefox Desktop Beta
36+
- application: fenix
37+
channel: beta
38+
display_name: Firefox Fenix Beta
39+
- application: fenix
40+
channel: release
41+
display_name: Firefox Fenix Release
3442
steps:
3543
- name: Check if this variant should run
3644
id: should-run

Makefile

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -258,8 +258,9 @@ integration_test_nimbus_sdk: build_integration_test build_prod
258258
MOZ_HEADLESS=1 $(COMPOSE_INTEGRATION_RUN) -it rust-sdk sh -c "./experimenter/tests/nimbus_rust_tests.sh"
259259

260260
integration_test_nimbus_fenix:
261-
poetry -C experimenter/tests/integration/ -vvv install --no-root
262-
poetry -C experimenter/tests/integration/ -vvv run pytest --html=workspace/test-results/report.htm --self-contained-html --reruns-delay 30 --driver Firefox experimenter/tests/integration/nimbus/android --junitxml=experimenter/tests/integration/test-reports/experimenter_fenix_integration_tests.xml -vvv
261+
poetry -C experimenter/tests install --no-root
262+
mkdir -p experimenter/tests/integration/test-reports
263+
cd experimenter/tests && poetry run pytest -m fenix_enrollment -o addopts= -p no:rerunfailures --junitxml=integration/test-reports/fenix_enrollment.xml integration/nimbus/android $(PYTEST_ARGS)
263264

264265
# cirrus
265266
CIRRUS_ENABLE = export CIRRUS=1 &&
Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1 +1 @@
1-
FIREFOX_FENIX_RELEASE_TASK_ID="YmbxEtM6QqGQLzrwS532aw"
1+
FIREFOX_FENIX_RELEASE_TASK_ID="aYsyO389TuSLoLVOKpvGsg"
Lines changed: 69 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,69 @@
1+
import os
2+
import time
3+
4+
import pytest
5+
import requests
6+
7+
from nimbus.models.base_dataclass import BaseExperimentApplications
8+
from nimbus.utils import helpers
9+
10+
FENIX_APP = BaseExperimentApplications.FIREFOX_FENIX.value
11+
RECIPE_POLL_TIMEOUT = 60
12+
13+
14+
@pytest.fixture(name="fenix_channel")
15+
def fixture_fenix_channel():
16+
return os.environ["FENIX_CHANNEL"]
17+
18+
19+
@pytest.fixture(name="fenix_apk_path")
20+
def fixture_fenix_apk_path():
21+
return os.environ["FENIX_APK_PATH"]
22+
23+
24+
@pytest.fixture
25+
def experiment_slug(fenix_channel):
26+
return f"fenix-{fenix_channel}-integration-test"
27+
28+
29+
def wait_for_recipe(slug):
30+
base_url = os.environ.get("INTEGRATION_TEST_NGINX_URL", "https://nginx")
31+
url = f"{base_url}/api/v6/experiments/{slug}/"
32+
deadline = time.time() + RECIPE_POLL_TIMEOUT
33+
last_error = None
34+
while time.time() < deadline:
35+
try:
36+
resp = requests.get(url, verify=False, timeout=5)
37+
if resp.status_code == 200:
38+
recipe = resp.json()
39+
if recipe.get("slug") == slug and recipe.get("bucketConfig"):
40+
return recipe
41+
last_error = (
42+
f"recipe missing bucketConfig: {recipe.get('bucketConfig')!r}"
43+
)
44+
else:
45+
last_error = f"HTTP {resp.status_code}"
46+
except (requests.RequestException, ValueError) as exc:
47+
last_error = str(exc)
48+
time.sleep(1)
49+
pytest.fail(f"Timed out waiting for recipe at {url} ({last_error})")
50+
51+
52+
@pytest.fixture
53+
def create_fenix_experiment(application_feature_ids):
54+
def _create_fenix_experiment(slug, channel):
55+
feature_id = application_feature_ids[FENIX_APP]
56+
helpers.create_experiment(
57+
slug,
58+
FENIX_APP,
59+
data={
60+
"feature_config_ids": [int(feature_id)],
61+
"channel": channel,
62+
"firefox_min_version": "",
63+
},
64+
targeting="no_targeting",
65+
)
66+
helpers.launch_to_preview(slug)
67+
return wait_for_recipe(slug)
68+
69+
return _create_fenix_experiment

experimenter/tests/integration/nimbus/android/gradlewbuild.py

Lines changed: 0 additions & 42 deletions
This file was deleted.
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import json
2+
import re
3+
import subprocess
4+
import time
5+
6+
import pytest
7+
8+
from nimbus.models.base_dataclass import BaseExperimentApplications
9+
10+
FENIX_APP = BaseExperimentApplications.FIREFOX_FENIX.value
11+
APP_APPLY_WAIT = 15
12+
LOG_STATE_WAIT = 5
13+
14+
15+
@pytest.mark.fenix_enrollment
16+
def test_fenix_enrollment(
17+
fenix_channel,
18+
fenix_apk_path,
19+
experiment_slug,
20+
create_fenix_experiment,
21+
tmp_path,
22+
):
23+
recipe = create_fenix_experiment(experiment_slug, fenix_channel)
24+
25+
recipe_path = tmp_path / "fenix_recipe.json"
26+
recipe_path.write_text(json.dumps(recipe))
27+
28+
subprocess.check_call(["adb", "install", fenix_apk_path])
29+
subprocess.check_call(["adb", "logcat", "-c"])
30+
31+
# Prevent the real Nimbus RS fetch from overwriting our local enrollment
32+
# (without this, Nimbus evolves against the fresh fetch — which does not
33+
# contain our test experiment — and unenrolls us).
34+
subprocess.check_call(["adb", "shell", "svc", "wifi", "disable"])
35+
subprocess.check_call(["adb", "shell", "svc", "data", "disable"])
36+
37+
subprocess.check_call(
38+
[
39+
"nimbus-cli",
40+
"--app",
41+
FENIX_APP,
42+
"--channel",
43+
fenix_channel,
44+
"enroll",
45+
experiment_slug,
46+
"--branch",
47+
"control",
48+
"--file",
49+
str(recipe_path),
50+
"--preserve-targeting",
51+
"--preserve-bucketing",
52+
"--reset-app",
53+
"--no-validate",
54+
]
55+
)
56+
time.sleep(APP_APPLY_WAIT)
57+
58+
subprocess.check_call(
59+
["nimbus-cli", "--app", FENIX_APP, "--channel", fenix_channel, "log-state"]
60+
)
61+
time.sleep(LOG_STATE_WAIT)
62+
63+
logcat = subprocess.check_output(["adb", "logcat", "-d"], text=True)
64+
65+
pattern = re.compile(
66+
rf"nimbus_client:\s*{re.escape(experiment_slug)}\s+\|\s*\S+\s+\|\s*(\S+)"
67+
)
68+
match = pattern.search(logcat)
69+
nimbus_lines = [line for line in logcat.splitlines() if "nimbus_client" in line]
70+
assert match is not None, (
71+
f"No log-state row found for {experiment_slug}.\n"
72+
f"--- last 30 nimbus_client lines ---\n" + "\n".join(nimbus_lines[-30:])
73+
)
74+
enrolled_branch = match.group(1)
75+
assert enrolled_branch in {"control", "treatment-a"}, (
76+
f"Unexpected branch {enrolled_branch!r} for {experiment_slug}"
77+
)

0 commit comments

Comments
 (0)