Follow-up to #255, and the first end-to-end push coverage on iOS.
#255 fixed two ways a tap on a notification that launched the app got lost. Each fix had a unit test, but nothing ran the sequence a person actually goes through: allow notifications, the app gets killed, a push arrives, and they tap the banner. This PR adds that sequence to the simulator harness, with a real push.
How it runs
NotificationTapColdStartTests is a new XCUITest in the probe app's UI test target:
- It opens
crafte2eprobe://e2e/notify?run=…. The driver page sees that launch link and callscraft.permissions.request('notifications'), and the test taps Allow on SpringBoard's prompt. Without permission, iOS accepts the push and shows nothing. - It kills the app and prints
CRAFT-E2E-PUSH-READY. ios.tsspawns that xcodebuild itself instead of going throughcommand, so it can read the log while the test runs. When the line appears, it runsxcrun simctl pushwith a payload that carries the run's nonce as the body, plus a customroutekey.- The test finds the banner by the nonce, so a notification left over from an earlier run can't be the one tapped. It taps the banner and reads the page's report back.
The page subscribes through notifications.onTap after its suite, seconds after the bridge appeared, the way a hydrating app subscribes. It reports what it was handed, plus how many craftNotificationResponse events native code dispatched, counted from its first line.
protocol.ts judges the report. Because of the dispatch count, a failure says which half of #255 broke:
- Nothing dispatched: native code dropped the tap before any page existed.
- Dispatched, but
onTapgot nothing: the page's replay flushed it before anyone subscribed.
It also fails if onTap is missing, if it returns no way to unsubscribe, if the tap is delivered twice or belongs to another run, or if the tap lost its route.
The existing deep-link run now uses -only-testing so the two UI test classes run separately.
Evidence (local, iOS 26.5 simulator, Xcode 26.6)
The full harness passed on both legs, including the existing 12 cases and both deep-link cold starts. The tap test took about 16 seconds per leg. This is the shim leg's report:
{"dispatched":1,"taps":[{"craftE2E":"craft-e2e-ios-shim-70910","aps":{"alert":{"title":"Craft E2E","body":"craft-e2e-ios-shim-70910"}},"route":"/e2e/notification-tap"}],"onTap":"function","unsubscribe":"function"}I reintroduced each half of the bug in the template and reran the whole harness each time. Both runs failed on both legs, with only the matching message:
| Mutation | Result |
|---|---|
handleNotificationResponse drops the tap when the page isn't ready, as the old NotificationCenter route did | the app never dispatched the tap that launched it; native code dropped it before the page existed |
| the replay listener stops buffering taps | a page that subscribed to notifications.onTap after launch was handed [], expected the tap that launched the app once |
protocol.test.ts has 7 new cases for the judge (62 in total), and they run in the harness job on every PR.
Choices
- A stdout line to signal readiness, not a file. xcodebuild forwarded the runner's
printlive, so the host sawPUSH-READYwhile the test was still waiting. It needs no path shared between the simulator and the host. - Permission is asked only when a link asks for it. Nothing answers the prompt on the launches
simctlmakes for the main suite, and an unanswered prompt would sit over the app for the rest of the leg. - The probe config doesn't enable push. The capability gates registration, which needs a signed
aps-environmententitlement that this unsigned simulator build can't have. Delivery and taps don't depend on it, andsimctl pushdelivers regardless. - iOS only. The page skips the probe on Android, where there's no tap path to test yet.
Not covered
- A push arriving while the app is open never reaches the page. Scoping this turned that up. It's filed as #256, and the harness can cover it the same way once it exists.
- Real APNs delivery.
simctl pushgoes around APNs, so it tests the app's side, not token registration or the server.