Build an iOS deep-link regression gate on a cloud Mac

Build an iOS deep-link regression gate on a cloud Mac

Once an iOS app relies on deep links for sign-in callbacks, campaign pages, order details, and notification destinations, a single routing refactor can leave links stuck on the home screen or push the same page twice when the app is already open. Manually tapping a few links only proves that they worked at that moment; it does not cover cold launches, foreground state, encoded parameters, or invalid input. A more reliable approach is to pin the simulator environment on a cloud Mac and expose the final route for every deep link as a result that continuous integration can inspect.

Do not assemble URLs piecemeal inside test scripts. Maintain a route manifest that clearly specifies the input, expected route, and app state.

Scenario Input Expected result
Product details linklab://product/42 product/42
Search parameters linklab://search?q=swift%20ui search?query=swift ui
Missing identifier linklab://product/ error/invalid-product
Unknown path linklab://unknown error/not-found

Expected values in the manifest should use the canonical form produced by the router, not the title of a particular button. This prevents UI copy changes from creating false positives and lets a failed test identify whether the problem lies in the path, parameters, or state restoration.

A deep-link gate verifies that input reaches the correct business route—not merely that the system accepts a URL. A successful command does not mean the correct page appeared.

Prepare at least two cases for each route: a cold launch and an already-foregrounded app. If authentication is involved, also distinguish between signed-in and signed-out states. The script must establish the test account state explicitly rather than inheriting it from the previous case.

Add an observable probe to debug builds

The command line can open a link, but it cannot reliably determine what the app ultimately displays. In DEBUG builds, the shared routing entry point can write its canonical result to the caches directory. Release builds should exclude this logic, and the probe must not record tokens or complete query parameters.

#if DEBUG
func recordResolvedRoute(_ route: String) {
    let payload: NSDictionary = [
        "route": route,
        "recordedAt": Date().timeIntervalSince1970
    ]
    let directory = FileManager.default.urls(
        for: .cachesDirectory,
        in: .userDomainMask
    )[0]
    let file = directory.appendingPathComponent("link-probe.plist")
    payload.write(to: file, atomically: true)
}
#endif

Place the probe after the router shared by URL schemes, universal links, and notification navigation. Recording separately in lifecycle callbacks may only prove that a callback fired; it does not prove that the parameters were validated and successfully resolved to the intended business screen.

Keep results atomic

Delete the previous file before every run, and use atomic replacement when writing the new result. Otherwise, an app crash or an early read may cause the current test to consume the previous result. Record only the route key, error type, and timestamp, and redact user input first.

Pin the simulator and run the routing matrix

CI should not use the ambiguous booted target as its starting point. Resolve the UDID from the device name and OS version, ensure that only the target simulator is running, and wait for the system to finish booting. The following example assumes that the pipeline has already obtained SIMULATOR_UDID.

set -euo pipefail

xcrun simctl shutdown all
xcrun simctl boot "$SIMULATOR_UDID"
xcrun simctl bootstatus "$SIMULATOR_UDID" -b

xcodebuild \
  -scheme LinkLab \
  -configuration Debug \
  -sdk iphonesimulator \
  -derivedDataPath .ci/DerivedData \
  build

APP_PATH=".ci/DerivedData/Build/Products/Debug-iphonesimulator/LinkLab.app"
xcrun simctl install "$SIMULATOR_UDID" "$APP_PATH"

After installation, obtain the data container, then clear the probe, open the link, and poll for the result for each case. Do not use one long, fixed sleep; as app startup time changes, it will either waste time or still finish before the file is available.

BUNDLE_ID="com.example.linklab"
DATA_DIR="$(xcrun simctl get_app_container \
  "$SIMULATOR_UDID" "$BUNDLE_ID" data)"
PROBE="$DATA_DIR/Library/Caches/link-probe.plist"

rm -f "$PROBE"
xcrun simctl openurl "$SIMULATOR_UDID" "linklab://product/42"

for attempt in 1 2 3 4 5 6 7 8 9 10; do
  test -f "$PROBE" && break
  sleep 1
done

test -f "$PROBE"
ACTUAL="$(/usr/libexec/PlistBuddy -c "Print :route" "$PROBE")"
test "$ACTUAL" = "product/42"

For foreground cases, send another URL after the initial launch. For cold-launch cases, run simctl terminate first. Save the input, expected value, actual value, and test log for every failure, but do not archive the entire app container if it contains sensitive information.

URL schemes provide a stable way to test the router, but they cannot replace end-to-end universal-link checks. Universal links also depend on associated-domain entitlements, the HTTPS-hosted site file, its content type, the app identifier, and system caches.

At the first layer, invoke the shared router directly on every commit to cover path canonicalization, percent encoding, duplicate parameters, empty values, and unknown routes quickly. At the second layer, open real HTTPS links on a controlled test domain, reinstall the app, and verify both cold launches and foreground transitions. Any change to the site association file must trigger this second layer; unit tests alone are not sufficient.

A common mistake is to edit the association file, immediately click the link again, and interpret a stale cached result as the new configuration. During diagnosis, record the app build number, OS version, installation order, and response headers from the test domain. First prove that the system delivered the link to the app, then inspect the business routing behavior.

Build failure evidence and cleanup into the gate

A reliable gate must do more than return a nonzero status. It should also help maintainers identify the failing layer quickly. At minimum, the failure summary should include the case name, app state, input type, expected route, actual route, and whether the probe file was created. If the file is missing, check installation, lifecycle entry points, and link declarations first. If the file exists but contains the wrong route, inspect the parser and navigation state.

The pre-merge checklist can remain concise:

  • The pipeline pins the simulator model and OS version;
  • Cold-launch and foreground states run separately;
  • Every case clears the app process or probe state first;
  • Special characters, empty parameters, and unknown paths all have negative cases;
  • Universal links have a separate HTTPS acceptance check;
  • Failure artifacts contain no credentials, tokens, or user data;
  • The job shuts down the simulator and removes temporary build directories when finished.

Finally, run xcrun simctl shutdown "$SIMULATOR_UDID" and let the workspace cleanup policy remove .ci/DerivedData. Only when the route contract, observable probe, and state isolation all work together does deep-link testing advance from “the link opens” to an engineering gate that can block navigation regressions.

Frequently asked questions

Does simctl openurl fully validate a universal link?

No. It can exercise app routing, but universal links also depend on the HTTPS association file, signed entitlements, TLS delivery, and operating-system caches.

Why test deep links during both cold launch and foreground execution?

Those states may enter different lifecycle callbacks. Testing both catches lost initial routes, duplicated navigation, and state left behind by earlier cases.

ZoomMini Cloud Mac

Choose dedicated physical nodes for builds, testing, and experiments

Two M4 configurations are available across nodes in Singapore, Tokyo, Seoul, and Hong Kong. Availability is confirmed by the result returned at checkout.

Choose a model and order