shvedov.tech
← Back to writing

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:

  1. Download the .ipa from the release feed.
  2. xcrun simctl install <master-udid> App.app.
  3. xcrun simctl launch -w <master-udid> com.example.app — wait for the PID.
  4. sleep 60 — time for swcd to register applinks:example.com.
  5. xcrun simctl terminate <master-udid> com.example.app, leaving the app installed.
  6. 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:

  1. 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.
  2. 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 listapps hides xctest-runners. WebDriverAgentRunner-Runner.app has LSUIElement=1 and 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 is xcrun simctl get_app_container <udid> <bundle-id>.
  • simctl clone needs a shutdown source. If the master is accidentally left Booted after a manual check, cloning fails with Unable 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.
  • useXctestrunFile is broken in xcuitest-driver 9.2.4. With useXctestrunFile: true plus bootstrapPath, the file name is built as ..._iphonesimulator${iosSdkVersion}-arm64.xctestrun, and iosSdkVersion resolves to null. The driver looks for a nonexistent _iphonesimulatornull-arm64.xctestrun and the session hangs. Fix: don’t pass these caps — the driver picks up the prebuild via derivedDataPath on 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 only WebDriverAgent-* (needed for prebuild);
  • removes Xcode Archives older than 14 days, CoreSimulator Caches, Xcode user cache;
  • rotates ~/.appium/appium_logs.log if it’s over 200 MB;
  • cleans simulator logs for non-booted devices;
  • xcrun simctl delete unavailable plus removes zombie simulators that aren’t Booted;
  • safety net: if GET http://127.0.0.1:4444/status shows active sessions, the job fails without touching anything.

macos-reboot:

  • launchctl bootout for the selenoid units;
  • pkill -f appium|xcodebuild|WebDriverAgentRunner|iproxy;
  • xcrun simctl shutdown all;
  • shutdown -r +1, wait for the connection, smoke-check http://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:

  1. Finds the appium-webdriveragent root under ~/.appium/node_modules/... (Appium 2.x puts drivers in $APPIUM_HOME, not global npm).
  2. Reads the actual Xcode version from xcodebuild -version and the highest iOS runtime from xcrun simctl list runtimes -j.
  3. Computes an xcode|ios|xcuitest fingerprint — if it hasn’t changed, no rebuild.
  4. 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 (including GCC_TREAT_WARNINGS_AS_ERRORS=NO — without it Xcode 16 complains about warnings-as-errors in WDA 7.x).
  5. Stores the path to the built .xctestrun and 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.