The same iOS screens work locally, but once they run in a remote build job, they sometimes miss the “ready” message from the web page. Don’t immediately blame the cloud Mac’s network. If the page communicates with native code through a WKWebView bridge, load timing, message fields, or the handler lifecycle could be responsible. A local HTML file committed with the repository can turn communication in both directions into a contract test that does not depend on an external page.
Define the bridge contract
Set a minimal protocol: the page sends {kind: "ready", version: 1} to native code, and native code sends {kind: "sync", value: "ok"} to the page. The first message uses window.webkit.messageHandlers.bridge.postMessage; the second uses evaluateJavaScript. The tests need to establish separately that each message arrives and that its fields have the right types—not merely that the page renders.
Save the following as BridgeFixture.html where the test target can read it, and confirm that the file is included in that target’s resources:
<!doctype html>
<html lang="en">
<meta charset="utf-8">
<body>
<script>
window.bridgeReceive = function (message) {
if (message.kind !== "sync" || typeof message.value !== "string") {
throw new Error("Invalid native message");
}
document.body.dataset.received = message.value;
};
window.webkit.messageHandlers.bridge.postMessage({
kind: "ready",
version: 1
});
</script>
</body>
</html>
dataset.received is an observation point for the test, not part of the production protocol. Production code and the fixture should share the same field definitions. When a field changes, update both sides and make incompatible changes fail the test explicitly. Don’t guess when the page has finished loading by waiting a fixed number of seconds.
Test both directions in the simulator
The test creates a WKUserContentController, registers a WKScriptMessageHandler named bridge, and places the controller in a WKWebViewConfiguration. After reading the HTML from the test bundle, it calls loadHTMLString(_:baseURL:). When ready arrives, first confirm that message.body is a dictionary. Then check that kind is a string, version is a number, and its value is 1. An unrecognized message should fail an assertion.
For the native-to-page direction, wait until WKNavigationDelegate receives webView(_:didFinish:). Then use JSONSerialization to encode the outgoing dictionary as JSON text, insert it into window.bridgeReceive(...), and call evaluateJavaScript. Don’t insert user input directly into a script string: quotes and newlines can change what the script means. Once the call completes, read document.body.dataset.received and assert that its value is ok.
A return from
loadHTMLStringonly means loading has started. Even if the page has already sentready, that return does not replace the navigation completion event. Use a separate XCTest expectation for each direction so a failure identifies which side did not respond.
Add failure paths for loading failures and script execution errors. Give each expectation an explicit timeout, and distinguish “page did not finish loading,” “native code did not receive the message,” and “page did not accept the native message” in failure output. Otherwise, CI will report only a generic timeout.
Run the test on a cloud Mac
First, check the currently available configurations in the ArmMesh cloud Mac console. After selecting a node, pin the Xcode version and simulator runtime. Use xcrun simctl list devices available to find an available device and put the selected simulator UUID in the job configuration; don’t rely on fuzzy matching of device names. Replace the project, scheme, and UUID in this command with the repository’s actual values:
xcodebuild test \
-project Client.xcodeproj \
-scheme Client \
-destination 'platform=iOS Simulator,id=<SIMULATOR_UUID>' \
-only-testing:ClientTests/BridgeContractTests \
-resultBundlePath "$PWD/BridgeContract.xcresult"
Before running it, confirm that the scheme is shared, the test target includes the HTML resource, and the result path does not already exist. xcodebuild will not overwrite an existing result bundle as though it were an empty directory. On failure, retain the .xcresult and build log, and record the Xcode version and simulator UUID so you can rerun the test in the same environment.
This test needs no remote web page, so a network outage should not change its result. Run a separate integration test against the real page to distinguish a broken bridge contract from a failed page deployment or request.
Handle timing and cleanup pitfalls
If the page calls postMessage before the handler is registered, its first message may be lost. Finish configuration and registration before creating and loading the WebView. Don’t reuse a controller with old handlers across tests. At the end of the test, call removeScriptMessageHandler(forName: "bridge") and release the WebView’s navigation delegate reference.
Test rejection paths as well: have the fixture send a message without version, then one with version as a string, and confirm that native code does not treat either as a valid ready message. If the production protocol includes request IDs, acknowledgments must match by ID; a late message from a previous request must not fulfill the current expectation. When user content is involved, log only field structure and redacted errors in test output.
Finally, verify the test on its own and then as part of the full suite. Consecutive runs should produce no duplicate callbacks, and another simulator with the same version should yield the same assertion results. The local fixture now covers the stable interface contract, while the real-page test covers integration after deployment; each kind of failure has a clear owner.
Frequently asked questions
Why not use the production website as the test fixture?
Deployments and network conditions would change the input. Keep the contract fixture local and test production integration separately.
When is it safe for native code to call a page function?
Wait for WKNavigationDelegate.didFinish before calling evaluateJavaScript; loadHTMLString returning does not mean the function is ready.
Add a cloud Mac with a fixed configuration to your workflow
Rent dedicated physical nodes by the day, week, month, or quarter. Run builds, automation, and MLX inference with fixed hardware resources.