Skip to content

Commit 56e23dc

Browse files
feat(nimbus): add iOS end-to-end enrollment integration test (#15403)
Because * The iOS Nimbus integration test has been dormant since the CircleCI scaffolding was removed; without live CI coverage, changes to the recipe contract regress silently. This is the iOS counterpart to #15340 / #15345. * Unlike Fenix (signed APK on a TaskCluster public route), firefox-ios publishes no prebuilt simulator artifact. We build from source on every run. * macos-15 GHA runners can't host Docker (Apple Virtualization rejects nested-virt boot), so the Experimenter stack and the iOS build can't share a job. This commit * Splits the workflow into two jobs coordinating via a recipe-JSON artifact: `Create iOS recipe` (ubuntu) brings up the Experimenter stack and writes a bucket-allocated recipe; `iOS Enrollment` (macos-15) downloads it and enrolls firefox-ios against it. * Builds the Fennec scheme on Xcode 26.2 / iPhone 17 / iOS 26.2, then re-codesigns the bundle leaves-to-root with a minimal `com.apple.security.application-groups` entitlement so the simulator resolves the app group container (without it, `Experiments.dbPath` is nil and Nimbus refuses to initialize). * Verifies enrollment by polling `<app-container>/Library/Caches/Logs/Firefox.log` for the `<slug> | <features> | <branch>` row that `nimbus-cli enroll`'s embedded `--log-state` produces, asserting the branch matches one of the recipe's two ratio-1 branches. * Pins the firefox-ios SHA in `firefox_ios_main_build.env`; adds an `ios/main` entry to `update-firefox.yml` so the daily bumper updates the pinned SHA and triggers the workflow against new firefox-ios HEAD. * Gates the workflow on iOS-specific paths only — backend changes are covered by the Fenix test, which exercises the same Experimenter emission path. Fixes #15402
1 parent e99ab37 commit 56e23dc

12 files changed

Lines changed: 344 additions & 176 deletions
Lines changed: 200 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,200 @@
1+
name: iOS Enrollment Integration Test
2+
3+
on:
4+
workflow_dispatch:
5+
schedule:
6+
- cron: "3 12 * * *"
7+
push:
8+
branches:
9+
- main
10+
- update_firefox_ios_main
11+
pull_request:
12+
merge_group:
13+
types: [checks_requested]
14+
15+
jobs:
16+
create-recipe:
17+
name: "Create iOS recipe"
18+
runs-on: ubuntu-24.04
19+
outputs:
20+
should-run: ${{ steps.check-paths.outputs.should-run }}
21+
env:
22+
INTEGRATION_TEST_NGINX_URL: https://localhost
23+
IOS_CHANNEL: developer
24+
IOS_RECIPE_PATH: ${{ github.workspace }}/ios_recipe.json
25+
steps:
26+
- uses: actions/checkout@v6
27+
with:
28+
fetch-depth: 0
29+
30+
- uses: ./.github/actions/check-changed-paths
31+
id: check-paths
32+
with:
33+
paths: "experimenter/tests/integration/nimbus/ios/ experimenter/tests/firefox_ios_main_build.env"
34+
35+
- uses: ./.github/actions/setup-cached-build
36+
if: steps.check-paths.outputs.should-run == 'true'
37+
38+
- name: Set up Python
39+
if: steps.check-paths.outputs.should-run == 'true'
40+
uses: actions/setup-python@v5
41+
with:
42+
python-version: "3.12"
43+
44+
- name: Install poetry
45+
if: steps.check-paths.outputs.should-run == 'true'
46+
run: pipx install poetry
47+
48+
- name: Bring up Experimenter stack
49+
if: steps.check-paths.outputs.should-run == 'true'
50+
run: |
51+
cp .env.integration-tests .env
52+
make refresh SKIP_DUMMY=1 up_prod_detached
53+
54+
- name: Wait for Experimenter backend to be ready
55+
if: steps.check-paths.outputs.should-run == 'true'
56+
run: curl --retry 60 --retry-delay 5 --retry-all-errors -sfk -o /dev/null https://localhost/__lbheartbeat__
57+
58+
- name: Install test deps
59+
if: steps.check-paths.outputs.should-run == 'true'
60+
run: poetry -C experimenter/tests install --no-root
61+
62+
- name: Create recipe
63+
if: steps.check-paths.outputs.should-run == 'true'
64+
working-directory: experimenter/tests
65+
run: poetry run pytest -m ios_create_recipe -o addopts= -p no:rerunfailures integration/nimbus/ios
66+
67+
- name: Upload recipe artifact
68+
if: steps.check-paths.outputs.should-run == 'true'
69+
uses: actions/upload-artifact@v4
70+
with:
71+
name: ios-recipe
72+
path: ${{ github.workspace }}/ios_recipe.json
73+
if-no-files-found: error
74+
retention-days: 1
75+
76+
ios-test:
77+
name: "iOS Enrollment"
78+
needs: create-recipe
79+
if: needs.create-recipe.outputs.should-run == 'true'
80+
runs-on: macos-15
81+
env:
82+
IOS_CHANNEL: developer
83+
XCODE_VERSION: "26.2"
84+
IOS_VERSION: "26.2"
85+
IOS_SIMULATOR: "iPhone 17"
86+
IOS_RECIPE_PATH: ${{ github.workspace }}/ios_recipe.json
87+
steps:
88+
- uses: actions/checkout@v6
89+
90+
- name: Read firefox-ios SHA
91+
run: |
92+
. experimenter/tests/firefox_ios_main_build.env
93+
if [ -z "$FIREFOX_IOS_SHA" ]; then
94+
echo "::error::FIREFOX_IOS_SHA is empty in experimenter/tests/firefox_ios_main_build.env."
95+
exit 1
96+
fi
97+
echo "FIREFOX_IOS_SHA=$FIREFOX_IOS_SHA" >> "$GITHUB_ENV"
98+
99+
- name: Checkout firefox-ios
100+
uses: actions/checkout@v6
101+
with:
102+
repository: mozilla-mobile/firefox-ios
103+
ref: ${{ env.FIREFOX_IOS_SHA }}
104+
path: firefox-ios-checkout
105+
106+
- name: Select Xcode ${{ env.XCODE_VERSION }}
107+
run: |
108+
sudo rm -rf /Applications/Xcode.app
109+
sudo xcode-select -s /Applications/Xcode_${{ env.XCODE_VERSION }}.app/Contents/Developer
110+
111+
- name: Bootstrap firefox-ios
112+
working-directory: firefox-ios-checkout
113+
run: ./bootstrap.sh firefox --force
114+
115+
- name: Build Fennec scheme
116+
working-directory: firefox-ios-checkout/firefox-ios
117+
run: |
118+
xcodebuild -resolvePackageDependencies -onlyUsePackageVersionsFromResolvedFile
119+
xcodebuild build \
120+
-project Client.xcodeproj \
121+
-scheme Fennec \
122+
-destination "platform=iOS Simulator,name=${IOS_SIMULATOR},OS=${IOS_VERSION}" \
123+
-derivedDataPath ./DerivedData \
124+
COMPILER_INDEX_STORE_ENABLE=NO \
125+
CODE_SIGN_IDENTITY= CODE_SIGNING_REQUIRED=NO CODE_SIGNING_ALLOWED=NO \
126+
ENABLE_USER_SCRIPT_SANDBOXING=NO \
127+
ARCHS="arm64"
128+
129+
- name: Locate and ad-hoc sign Client.app
130+
run: |
131+
APP_PATH=$(find firefox-ios-checkout/firefox-ios/DerivedData/Build/Products -path "*iphonesimulator*/Client.app" -type d | head -1)
132+
if [ -z "$APP_PATH" ]; then
133+
echo "::error::Could not locate Client.app in DerivedData"
134+
find firefox-ios-checkout/firefox-ios/DerivedData/Build/Products -maxdepth 3 -type d || true
135+
exit 1
136+
fi
137+
# Sign leaves-to-root. --deep is deprecated and doesn't always
138+
# reach every embedded binary (dylibs, extensions, nested
139+
# frameworks) reliably on macOS 15.
140+
find "$APP_PATH" -name '*.dylib' -print0 | xargs -0 -I{} codesign --force --sign - --preserve-metadata=entitlements {}
141+
find "$APP_PATH" -type d -name '*.framework' -print0 | xargs -0 -I{} codesign --force --sign - --preserve-metadata=entitlements {}
142+
find "$APP_PATH" -type d -name '*.appex' -print0 | xargs -0 -I{} codesign --force --sign - --preserve-metadata=entitlements {}
143+
# Apply a minimal entitlements plist with just the app group
144+
# (group.org.mozilla.ios.Fennec) declaration so containerURL
145+
# (forSecurityApplicationGroupIdentifier:) resolves to a real
146+
# path. Without it, Nimbus refuses to init ("Nimbus didn't get
147+
# to create, because of a nil dbPath"). The full Fennec
148+
# entitlements plist has com.apple.developer.* keys that
149+
# require a provisioning profile — SpringBoard rejects launch
150+
# of ad-hoc-signed apps that claim them.
151+
ENTITLEMENTS=$(mktemp -t simulator-entitlements-XXXXXX).plist
152+
cat > "$ENTITLEMENTS" <<EOF
153+
<?xml version="1.0" encoding="UTF-8"?>
154+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
155+
<plist version="1.0">
156+
<dict>
157+
<key>com.apple.security.application-groups</key>
158+
<array>
159+
<string>group.org.mozilla.ios.Fennec</string>
160+
</array>
161+
</dict>
162+
</plist>
163+
EOF
164+
codesign --force --sign - --entitlements "$ENTITLEMENTS" "$APP_PATH"
165+
echo "IOS_APP_PATH=${GITHUB_WORKSPACE}/${APP_PATH}" >> "$GITHUB_ENV"
166+
167+
- name: Install nimbus-cli
168+
run: |
169+
curl -sSfL https://raw.githubusercontent.com/mozilla/application-services/main/install-nimbus-cli.sh -o /tmp/install-nimbus-cli.sh
170+
sudo bash /tmp/install-nimbus-cli.sh --directory /usr/local/bin
171+
172+
- name: Set up Python
173+
uses: actions/setup-python@v5
174+
with:
175+
python-version: "3.12"
176+
177+
- name: Install poetry
178+
run: pipx install poetry
179+
180+
- name: Install test deps
181+
run: poetry -C experimenter/tests install --no-root
182+
183+
- name: Download recipe artifact
184+
uses: actions/download-artifact@v4
185+
with:
186+
name: ios-recipe
187+
path: ${{ github.workspace }}
188+
189+
- name: Boot iOS simulator
190+
run: |
191+
xcrun simctl boot "${IOS_SIMULATOR}"
192+
xcrun simctl bootstatus "${IOS_SIMULATOR}" -b
193+
# Launch Simulator.app so there's a real SpringBoard to foreground
194+
# the app — without it, simctl-launched apps can be stuck in a
195+
# background-ish state where AppDelegate never finishes launching.
196+
open -ga Simulator
197+
sleep 15
198+
199+
- name: Run iOS enrollment test
200+
run: make integration_test_nimbus_ios

.github/workflows/update-firefox.yml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ on:
1515
- desktop-beta
1616
- fenix-beta
1717
- fenix-release
18+
- ios-main
1819

1920
jobs:
2021
update:
@@ -39,6 +40,9 @@ jobs:
3940
- application: fenix
4041
channel: release
4142
display_name: Firefox Fenix Release
43+
- application: ios
44+
channel: main
45+
display_name: Firefox iOS
4246
steps:
4347
- name: Check if this variant should run
4448
id: should-run

Makefile

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -262,6 +262,9 @@ integration_test_nimbus_fenix:
262262
mkdir -p experimenter/tests/integration/test-reports
263263
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)
264264

265+
integration_test_nimbus_ios:
266+
cd experimenter/tests && poetry run pytest -m ios_enrollment -o addopts= -p no:rerunfailures integration/nimbus/ios $(PYTEST_ARGS)
267+
265268
# cirrus
266269
CIRRUS_ENABLE = export CIRRUS=1 &&
267270
CIRRUS_RUFF_FORMAT_CHECK = ruff format --check --diff .
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
FIREFOX_IOS_SHA="87dcc1641f953be97c43d83936112eba85d8e126"

experimenter/tests/integration/nimbus/ios/README.md

Lines changed: 0 additions & 3 deletions
This file was deleted.
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
import json
2+
import os
3+
from pathlib import Path
4+
5+
import pytest
6+
7+
8+
@pytest.fixture(name="ios_channel")
9+
def fixture_ios_channel():
10+
return os.environ.get("IOS_CHANNEL", "developer")
11+
12+
13+
@pytest.fixture(name="ios_app_path")
14+
def fixture_ios_app_path():
15+
return os.environ["IOS_APP_PATH"]
16+
17+
18+
@pytest.fixture(name="ios_recipe_path")
19+
def fixture_ios_recipe_path():
20+
return Path(os.environ["IOS_RECIPE_PATH"])
21+
22+
23+
@pytest.fixture(name="ios_recipe")
24+
def fixture_ios_recipe(ios_recipe_path):
25+
return json.loads(ios_recipe_path.read_text())
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import json
2+
import os
3+
import time
4+
5+
import pytest
6+
import requests
7+
from nimbus.models.base_dataclass import BaseExperimentApplications
8+
from nimbus.utils import helpers
9+
10+
IOS_APP = BaseExperimentApplications.FIREFOX_IOS.value
11+
RECIPE_POLL_TIMEOUT = 60
12+
13+
14+
def wait_for_recipe(slug):
15+
url = f"{os.environ.get('INTEGRATION_TEST_NGINX_URL', 'https://nginx')}/api/v6/experiments/{slug}/"
16+
deadline = time.time() + RECIPE_POLL_TIMEOUT
17+
while time.time() < deadline:
18+
try:
19+
resp = requests.get(url, verify=False, timeout=5)
20+
if resp.status_code == 200 and resp.json().get("bucketConfig"):
21+
return resp.json()
22+
except (requests.RequestException, ValueError):
23+
pass
24+
time.sleep(1)
25+
pytest.fail(f"Timed out waiting for recipe at {url}")
26+
27+
28+
@pytest.mark.ios_create_recipe
29+
def test_create_ios_recipe(ios_channel, ios_recipe_path):
30+
slug = f"ios-{ios_channel}-integration-test"
31+
feature_id = helpers.get_feature_id_as_string("no-feature-ios", IOS_APP)
32+
assert feature_id
33+
helpers.create_experiment(
34+
slug,
35+
IOS_APP,
36+
data={
37+
"feature_config_ids": [int(feature_id)],
38+
"channel": ios_channel,
39+
"firefox_min_version": "",
40+
},
41+
targeting="no_targeting",
42+
)
43+
helpers.launch_to_preview(slug)
44+
ios_recipe_path.write_text(json.dumps(wait_for_recipe(slug)))
Lines changed: 52 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,52 @@
1+
import re
2+
import subprocess
3+
import time
4+
from pathlib import Path
5+
6+
import pytest
7+
8+
NIMBUS_CLI_APP = "firefox_ios"
9+
BUNDLE_ID = "org.mozilla.ios.Fennec"
10+
LOG_POLL_DEADLINE_SECONDS = 180
11+
LOG_POLL_INTERVAL_SECONDS = 2
12+
13+
14+
@pytest.mark.ios_enrollment
15+
def test_ios_enrollment(ios_channel, ios_app_path, ios_recipe, ios_recipe_path):
16+
experiment_slug = ios_recipe["slug"]
17+
subprocess.check_call(["xcrun", "simctl", "install", "booted", ios_app_path])
18+
subprocess.check_call(
19+
[
20+
"nimbus-cli",
21+
"--app",
22+
NIMBUS_CLI_APP,
23+
"--channel",
24+
ios_channel,
25+
"enroll",
26+
experiment_slug,
27+
"--branch",
28+
"control",
29+
"--file",
30+
str(ios_recipe_path),
31+
"--preserve-targeting",
32+
"--preserve-bucketing",
33+
"--reset-app",
34+
"--no-validate",
35+
]
36+
)
37+
38+
container = subprocess.check_output(
39+
["xcrun", "simctl", "get_app_container", "booted", BUNDLE_ID, "data"],
40+
text=True,
41+
).strip()
42+
log_path = Path(container) / "Library" / "Caches" / "Logs" / "Firefox.log"
43+
pattern = re.compile(rf"{re.escape(experiment_slug)}\s*\|\s*\S+\s*\|\s*(\S+)")
44+
deadline = time.time() + LOG_POLL_DEADLINE_SECONDS
45+
while time.time() < deadline:
46+
if log_path.exists():
47+
match = pattern.search(log_path.read_text())
48+
if match:
49+
assert match.group(1).strip() in {"control", "treatment-a"}
50+
return
51+
time.sleep(LOG_POLL_INTERVAL_SECONDS)
52+
pytest.fail(f"No enrollment row for {experiment_slug}")

0 commit comments

Comments
 (0)