Universal links on iOS simulators
Why this matters
iOS UI tests run on CI. A chunk of them validates flows of the shape “user taps a universal link, the app picks it up”. Not opens Safari, not shows a fallback — actually opens.
The tests run on simulators only. There are no real iOS devices in the farm — that’s not a choice, it’s a constraint. Buying and maintaining a real-device farm is a project of its own. And nobody is clicking through these by hand: every MR brings dozens of link scenarios. So it has to work reliably on simulators.
And it didn’t. The same test with the same link would pass one time and fail the next — the link landed in the app sometimes, in Safari other times.
What’s actually going on
iOS decides who owns a universal link via a system daemon called swcd (Shared Web Credentials). It fetches apple-app-site-association for the domain from Apple’s servers and caches the domain → bundle-id mapping. Until that cache exists, the system treats the link as unowned and opens it in the browser.
The vulnerable window is 30–60 seconds after install and first launch. A freshly booted simulator almost always falls inside that window: the test starts a couple of seconds after install + launch, and swcd simply hasn’t caught up.
What we tried
1. Entitlements tweak
Hypothesis: add something like applinks:example.com?mode=developer to the entitlements so swcd fetches AASA directly from our domain, bypassing the Apple CDN. Should be faster.
Built a dev build, shipped it. No visible effect.
2. Retry via kill Safari
Hypothesis: if the first openurl lands in Safari, swcd hasn’t pulled AASA yet. Wait, kill Safari, retry — the second attempt should hit the app.
Wrote a retry wrapper: detect that Safari is active, call terminateApp com.apple.mobilesafari, wait, repeat openurl. Most of the flake disappeared, but a tail remained: if swcd was still behind by the second attempt, the test failed. And this is symptom masking — every test pays extra seconds in waits and retries.
Side quest: “maybe it’s certain nodes”
Along the way we noticed a pattern: tests that failed in the nightly run often passed first try on a daytime rerun. New hypothesis — maybe there are nodes in the macOS farm that get stuck overnight (zombie simulators, bloated caches), and tests on those nodes fail more often.
Added the node name to test logs. Hypothesis didn’t hold up — failures were spread evenly.
That leaves one plausible explanation for why daytime reruns are steadier: at night the load is higher — the whole suite starts at once, not just a handful of reruns. The GPU and the network are busier, system caches take longer to warm, and the window in which openurl falls back to Safari widens. Couldn’t prove it; parked.
3. Warming up the master simulator
The idea: CI uses a “master simulator + simctl clone” pattern. Every test session gets a fresh APFS copy-on-write clone of the master. Install and warm up the app in the master once and clone it — the clone inherits the binary, the LaunchServices record, and the AASA cache. Pay for the warm-up once, not in every test.
We wrote an ansible role called app-preseed:
- Download the
.ipafrom the release feed. xcrun simctl install <master-udid> App.app.xcrun simctl launch -w <master-udid> com.example.app— wait for the PID.sleep 60— time forswcdto registerapplinks:example.com.xcrun simctl terminate <master-udid> com.example.app, leaving the app installed.- Shut the master down (mandatory for
simctl clone).
Rolled it out on a single node, ran the suite — everything passed first try, links stably opened in the app. We cheered.
Then we read the xcuitest-driver logs and found out the app in the test wasn’t the right one. On session start, the driver saw the bundle-id was already installed and skipped the install:
[XCUITestDriver] Not scrubbing third party app in anticipation of uninstall
...
[XCUITestDriver] App 'com.example.app' is already installed
The test was running on the preseed build, not on the one passed via appium:app — meaning the code under validation wasn’t the one from the MR. The link worked because the preseed version, AASA registration and all, survived session startup. But that’s not what we were trying to test.
The mechanism itself works. Install and warm the app in the master, shut down, clone, launch — universal links are stable. We just couldn’t use it as-is because the wrong build was being validated.
Two paths out of this:
- Prepare the master per session. During node acquire, download exactly the build to be tested, install it into the master, warm it, then clone. Pro: no overhead inside the test. Con: +60 seconds per session, for every test — not only the ones that actually need universal links.
- Warm up from the test. Leave the master without preseed; put the warm-up into a test fixture. Pro: cost lives only where it’s needed. Con: those specific tests get longer.
We picked option two. Universal links are needed by maybe a tenth of the suite, and +60 seconds per session would be paid by everything. The app-preseed role was reverted.
Traps along the way
simctl listappshides xctest-runners.WebDriverAgentRunner-Runner.apphasLSUIElement=1and doesn’t appear in the output. We spent half an hour chasing “why isn’t WDA in the master” when it actually was. The right check isxcrun simctl get_app_container <udid> <bundle-id>.simctl cloneneeds a shutdown source. If the master is accidentally leftBootedafter a manual check, cloning fails withUnable to clone device in current state: Booted. In the automated flow the role guarantees shutdown; in manual debugging it’s a trap.- SpringBoard in the clone is lazy. The icon of a preinstalled app doesn’t show up immediately after
boot, only after the first launch of any app or xctest in the clone. The app is physically there from the start — it’s a UI artifact of the simulator. useXctestrunFileis broken in xcuitest-driver 9.2.4. WithuseXctestrunFile: trueplusbootstrapPath, the file name is built as..._iphonesimulator${iosSdkVersion}-arm64.xctestrun, andiosSdkVersionresolves tonull. The driver looks for a nonexistent_iphonesimulatornull-arm64.xctestrunand the session hangs. Fix: don’t pass these caps — the driver picks up the prebuild viaderivedDataPathon its own.
4. Warming up from the test
The second path.
We added a warmed_up_device fixture to our test plugin — a wrapper around the standard device. Before handing the device to the test, it waits a configured number of seconds after the app is installed. The delay is tunable, and you can put any other link-related setup logic in there later.
A test that needs a stable universal link depends on warmed_up_device instead of device. That localizes the cost: ordinary tests with no link work pay zero; tests with links are stable and free of retry crutches.
Rule for test authors: there’s openurl https://example.com/... in your scenario — use warmed_up_device. There isn’t — use plain device.
Bonus — a couple of fixes along the way
While we were poking at simulators, we closed two long-standing macOS-farm pains: manual reset of stuck nodes and slow Appium session startup. Not directly related to universal links, but worth writing down.
Automatic hygiene
Ansible roles macos-cleanup (daily) and macos-reboot (weekly).
macos-cleanup:
- wipes
~/Library/Developer/Xcode/DerivedData, keeping onlyWebDriverAgent-*(needed for prebuild); - removes Xcode Archives older than 14 days, CoreSimulator Caches, Xcode user cache;
- rotates
~/.appium/appium_logs.logif it’s over 200 MB; - cleans simulator logs for non-booted devices;
xcrun simctl delete unavailableplus removes zombie simulators that aren’tBooted;- safety net: if
GET http://127.0.0.1:4444/statusshows active sessions, the job fails without touching anything.
macos-reboot:
launchctl bootoutfor the selenoid units;pkill -f appium|xcodebuild|WebDriverAgentRunner|iproxy;xcrun simctl shutdown all;shutdown -r +1, wait for the connection, smoke-checkhttp://127.0.0.1:4444/status.
WDA prebuild
WebDriverAgent is Appium’s bridge on iOS. Every new session used to rebuild it: 60–90 seconds just for that, plus sometimes the build failed and the session didn’t start at all.
The wda-prebuild role builds it once and leaves it in DerivedData:
- Finds the
appium-webdriveragentroot under~/.appium/node_modules/...(Appium 2.x puts drivers in$APPIUM_HOME, not globalnpm). - Reads the actual Xcode version from
xcodebuild -versionand the highest iOS runtime fromxcrun simctl list runtimes -j. - Computes an
xcode|ios|xcuitestfingerprint — if it hasn’t changed, no rebuild. - On first run or after an Xcode upgrade, runs
appium driver run xcuitest build-wda— the same code xcuitest-driver uses at runtime, with the same build settings (includingGCC_TREAT_WARNINGS_AS_ERRORS=NO— without it Xcode 16 complains about warnings-as-errors in WDA 7.x). - Stores the path to the built
.xctestrunand the fingerprint in~/wda-prebuild/*.txt.
start_appium.sh reads the path from the file and passes it to every session:
appium:usePrebuiltWDA = true
appium:usePreinstalledWDA = true
appium:derivedDataPath = ~/Library/Developer/Xcode/DerivedData/WebDriverAgent-<hash>
appium:wdaLaunchTimeout = 60000
The driver sees the prebuilt artifact, skips rebuild, and goes straight to xcodebuild test-without-building. From createSession to “WDA started” — seconds instead of a minute and a half. Bonus: the build no longer fails halfway through.
Docker cache quietly served stale code
The CI build with --cache-from $IMAGE:latest silently reused the COPY . /provision layer. In the log:
#5 [5/5] COPY . /provision
#5 DONE 0.0s
The image got tagged with a new SHA, the registry digest changed, but the contents of /provision inside were still from the previous commit. For several runs, ansible didn’t see the new roles even though they were already on master. Fixed by a single line in the Dockerfile:
ARG CACHEBUST=$CI_COMMIT_SHA
Place it before COPY and the layer cache invalidates on every commit.
Still open
We’d like a wrapper around xcuitest-driver with an “install on top, no uninstall” option. Then we could go back to infra-side warm-up: clone a pre-warmed master, install the actual build on top of it, and stop paying test time for warm-up. Haven’t scoped what maintaining our own driver fork would cost.