ReviewOS

also looking at this

craft-native/craft

feat(ios): tell the page about a notification that arrives while the app is open

#259
Merged glennmichael123 wants to merge feat/ios-notification-received into main
11 files +410 -58
packages/ios/src/index.test.tsmodified+17-0
Changes to packages/ios/src/index.test.ts
@@ -376,6 +376,23 @@ describe('Craft iOS builder', () => {
376376 expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationResponse"')
377377 })
378378
379 it('tells the page about a notification that arrives while the app is open', async () => {
380 // willPresent used to show the banner and nothing else, so a page on
381 // screen when a push landed heard about it only if someone tapped it.
382 const output = mkdtempSync(join(tmpdir(), 'craft-ios-notification-received-'))
383 await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output })
384 const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8')
385
386 const willPresent = swift.slice(swift.indexOf('willPresent notification: UNNotification'))
387 const body = willPresent.slice(0, willPresent.indexOf('\n }\n'))
388 expect(body).toContain('CraftEventManager.shared.handleNotificationReceived(notification.request.content.userInfo)')
389 // Told, not asked: the banner still shows.
390 expect(body).toContain('completionHandler([.banner, .badge, .sound])')
391
392 const handle = swift.slice(swift.indexOf('func handleNotificationReceived('))
393 expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationReceived"')
394 })
395
379396 it('settles calendar callbacks only after a real EventKit operation', async () => {
380397 const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-'))
381398 await init({
packages/ios/src/page-script.test.tsmodified+44-0
Changes to packages/ios/src/page-script.test.ts
@@ -94,6 +94,8 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, seed = SEE
9494 // What CraftEventManager.handleNotificationResponse evaluates.
9595 tap: (detail: unknown) =>
9696 page.dispatchEvent(new CustomEvent('craftNotificationResponse', { detail })),
97 receive: (detail: unknown) =>
98 page.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail })),
9799 }
98100}
99101
@@ -253,6 +255,48 @@ describe('the injected iOS page script', () => {
253255 expect(seen).toEqual([])
254256 })
255257
258 // #256: a notification that arrives while the page is open reaches it,
259 // live, on the notifications object the page ends up with.
260 it('hands a notification that arrived while the page was open to onReceive', () => {
261 const page = loadPage()
262 expect(typeof page.craft.notifications.onReceive).toBe('function')
263
264 const seen: unknown[] = []
265 const unsubscribe = page.craft.notifications.onReceive((detail: unknown) => seen.push(detail))
266 page.receive({ screen: 'recap' })
267 expect(seen).toEqual([{ screen: 'recap' }])
268
269 unsubscribe()
270 page.receive({ screen: 'plant-id' })
271 expect(seen).toEqual([{ screen: 'recap' }])
272 })
273
274 it('keeps arrivals and taps apart', async () => {
275 const page = loadPage()
276 const taps: unknown[] = []
277 const arrivals: unknown[] = []
278 page.craft.notifications.onTap((detail: unknown) => taps.push(detail))
279 page.craft.notifications.onReceive((detail: unknown) => arrivals.push(detail))
280 await tick()
281
282 page.receive({ screen: 'recap' })
283 page.tap({ screen: 'plant-id' })
284 expect(arrivals).toEqual([{ screen: 'recap' }])
285 expect(taps).toEqual([{ screen: 'plant-id' }])
286 })
287
288 // Held taps are for a launch the page was not there for. An arrival is
289 // news only to a page that is running, so nothing is held for later.
290 it('does not hold an arrival for a page that subscribes afterwards', async () => {
291 const page = loadPage()
292 page.receive({ screen: 'recap' })
293
294 const seen: unknown[] = []
295 page.craft.notifications.onReceive((detail: unknown) => seen.push(detail))
296 await tick()
297 expect(seen).toEqual([])
298 })
299
256300 // #198 and #215. The Android page script runs the same block; its own test
257301 // covers the rest of the contract.
258302 const tick = () => new Promise(resolve => setTimeout(resolve, 5))
packages/ios/templates/CraftApp.swiftmodified+27-1
Changes to packages/ios/templates/CraftApp.swift
@@ -115,6 +115,11 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio
115115 }
116116
117117 func userNotificationCenter(_ center: UNUserNotificationCenter, willPresent notification: UNNotification, withCompletionHandler completionHandler: @escaping (UNNotificationPresentationOptions) -> Void) {
118 // Told to the page as well as shown. A page that is open when a push
119 // lands used to hear about it only if someone tapped the banner, so it
120 // could not refresh what the push was about while it was on screen.
121 // The banner still shows: the page is told, not asked.
122 CraftEventManager.shared.handleNotificationReceived(notification.request.content.userInfo)
118123 completionHandler([.banner, .badge, .sound])
119124 }
120125}
@@ -156,12 +161,22 @@ class CraftEventManager {
156161 /// event: an APNs payload's keys always are, and the page could not index
157162 /// by anything else anyway.
158163 func handleNotificationResponse(_ userInfo: [AnyHashable: Any]) {
164 sendToWeb("craftNotificationResponse", data: Self.pageData(userInfo))
165 }
166
167 /// A notification that arrived while the app was in front, in the same
168 /// shape as a tap so one handler can read both.
169 func handleNotificationReceived(_ userInfo: [AnyHashable: Any]) {
170 sendToWeb("craftNotificationReceived", data: Self.pageData(userInfo))
171 }
172
173 private static func pageData(_ userInfo: [AnyHashable: Any]) -> [String: Any] {
159174 var data: [String: Any] = [:]
160175 for (key, value) in userInfo {
161176 guard let key = key as? String else { continue }
162177 data[key] = value
163178 }
164 sendToWeb("craftNotificationResponse", data: data)
179 return data
165180 }
166181
167182 func handleSiriActivity(_ activity: NSUserActivity) -> Bool {
@@ -3149,6 +3164,17 @@ struct CraftWebView: UIViewRepresentable {
31493164 craft.notifications.onTap = function(callback) {
31503165 return craft._subscribeNotificationTaps(callback);
31513166 };
3167 // A notification that arrived while the page was open.
3168 // Live only, with nothing held: it matters to a page that
3169 // is running, and one that subscribes later has no use for
3170 // an arrival it was not there to see.
3171 craft.notifications.onReceive = function(callback) {
3172 var listener = function(e) { callback(e.detail); };
3173 window.addEventListener('craftNotificationReceived', listener);
3174 return function() {
3175 window.removeEventListener('craftNotificationReceived', listener);
3176 };
3177 };
31523178 }
31533179 })(window.craft);
31543180
packages/typescript/src/__tests__/mobile.test.tsmodified+35-0
Changes to packages/typescript/src/__tests__/mobile.test.ts
@@ -70,6 +70,41 @@ describe('Mobile notification taps', () => {
7070 })
7171})
7272
73describe('Mobile notification arrivals', () => {
74 // #256: the bridge's onReceive, passed through, and the bare event when a
75 // bridge predates it.
76 it('subscribes through the bridge when it offers onReceive', () => {
77 const previousWindow = (globalThis as any).window
78 const unsubscribe = () => {}
79 let handed: ((detail: unknown) => void) | undefined
80 ;(globalThis as any).window = {
81 craft: { notifications: { onReceive: (callback: (detail: unknown) => void) => { handed = callback; return unsubscribe } } },
82 }
83
84 try {
85 const seen: unknown[] = []
86 expect(pushNotifications.onReceive(data => seen.push(data))).toBe(unsubscribe)
87 handed?.({ screen: 'recap' })
88 handed?.(undefined)
89 expect(seen).toEqual([{ screen: 'recap' }, {}])
90 }
91 finally {
92 if (previousWindow === undefined) delete (globalThis as any).window
93 else (globalThis as any).window = previousWindow
94 }
95 })
96
97 it('listens for craftNotificationReceived when the bridge has no onReceive', () => {
98 const seen: unknown[] = []
99 const unsubscribe = pushNotifications.onReceive(data => seen.push(data))
100 globalThis.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail: { screen: 'recap' } }))
101 globalThis.dispatchEvent(new CustomEvent('craftNotificationResponse', { detail: { screen: 'tapped' } }))
102 unsubscribe()
103 globalThis.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail: { screen: 'late' } }))
104 expect(seen).toEqual([{ screen: 'recap' }])
105 })
106})
107
73108describe('Mobile Android bridge promises', () => {
74109 it('reads the typed watch reachability envelope', async () => {
75110 const previousWindow = (globalThis as any).window
packages/typescript/src/api/mobile.tsmodified+17-0
Changes to packages/typescript/src/api/mobile.ts
@@ -1375,6 +1375,23 @@ export const pushNotifications = {
13751375 }) ?? (() => {})
13761376 }
13771377 return onCraftEvent('craftNotificationResponse', detail => callback(detail))
1378 },
1379 /**
1380 * A notification that arrived while the app was open, with the payload it
1381 * was sent with — the same shape `onNotification` hands a tap.
1382 *
1383 * The banner still shows. This is for a page that wants to react while it
1384 * is on screen, such as refreshing what the push was about. Live only: an
1385 * arrival before the page subscribed is not held for it. iOS only for now.
1386 */
1387 onReceive(callback: (data: Record<string, unknown>) => void): () => void {
1388 const craft = getCraftRoot()
1389 if (craft?.notifications?.onReceive) {
1390 return craft.notifications.onReceive((detail: unknown) => {
1391 callback((detail ?? {}) as Record<string, unknown>)
1392 }) ?? (() => {})
1393 }
1394 return onCraftEvent('craftNotificationReceived', detail => callback(detail))
13781395 }
13791396}
13801397
packages/typescript/types/craft.d.tsmodified+6-0
Changes to packages/typescript/types/craft.d.ts
@@ -1495,6 +1495,11 @@ export interface CraftNotificationResponseEvent extends CustomEvent {
14951495 detail: Record<string, any>;
14961496}
14971497
1498/** A notification that arrived while the app was open, in the same shape as a tap. iOS. */
1499export interface CraftNotificationReceivedEvent extends CustomEvent {
1500 detail: Record<string, any>;
1501}
1502
14981503export interface CraftWatchUserInfoEvent extends CustomEvent {
14991504 detail: Record<string, any>;
15001505}
@@ -1546,6 +1551,7 @@ declare global {
15461551 craftNetworkChange: CraftNetworkChangeEvent;
15471552 craftPushToken: CraftPushTokenEvent;
15481553 craftNotificationResponse: CraftNotificationResponseEvent;
1554 craftNotificationReceived: CraftNotificationReceivedEvent;
15491555 craftWatchMessage: CraftWatchMessageEvent;
15501556 craftWatchReachability: CraftWatchReachabilityEvent;
15511557 craftWatchContext: CraftWatchContextEvent;
scripts/mobile-e2e/driver.htmlmodified+26-0
Changes to scripts/mobile-e2e/driver.html
@@ -17,6 +17,7 @@
1717 <p id="deeplink"></p>
1818 <p id="notification-permission"></p>
1919 <p id="notification"></p>
20 <p id="notification-received"></p>
2021 <script>
2122 // The page drives itself and reports through the bridge's own `log` action,
2223 // which is the one channel both runtimes already forward off-device: iOS
@@ -505,6 +506,10 @@
505506 // the suite, seconds after the bridge appeared, and reports what it was
506507 // handed. On any other launch it was handed nothing, and says so.
507508 //
509 // #256: with the app open, a second push arrives, and the page reports
510 // what onReceive handed it, and how many taps it had by then, since an
511 // arrival is not a tap.
512 //
508513 // Permission is asked for only when the link asks, because nothing
509514 // answers the prompt on the launches simctl makes. iOS only: Android has
510515 // no tap path to test yet.
@@ -520,6 +525,27 @@
520525 if (typeof onTap === 'function') {
521526 seen.unsubscribe = typeof window.craft.notifications.onTap(function (detail) { seen.taps.push(detail || {}) })
522527 }
528 var onReceive = window.craft.notifications && window.craft.notifications.onReceive
529 var arrivals = { received: [], taps: 0, onReceive: typeof onReceive, unsubscribe: null }
530 function reportArrivals() {
531 arrivals.taps = seen.taps.length
532 var line = 'CRAFT-E2E-NOTIFICATION-RECEIVED ' + JSON.stringify(arrivals)
533 document.getElementById('notification-received').textContent = line
534 try { console.log(line) } catch (ignored) {}
535 try { if (window.craft && window.craft.log) window.craft.log(line) } catch (ignored) {}
536 }
537 // Reported at once, and again once arrivals have settled, so a page
538 // that was never told still has a report that says so.
539 if (typeof onReceive === 'function') {
540 var reporting = null
541 arrivals.unsubscribe = typeof window.craft.notifications.onReceive(function (detail) {
542 arrivals.received.push(detail || {})
543 // After a pause, so a second delivery of the same push is counted.
544 clearTimeout(reporting)
545 reporting = setTimeout(reportArrivals, 1500)
546 })
547 }
548 reportArrivals()
523549 // Long enough for a replay, or a second delivery, to have arrived.
524550 setTimeout(function () {
525551 seen.dispatched = dispatchedTaps
scripts/mobile-e2e/ios-uitests/NotificationTapColdStartTests.swiftmodified+62-18
Changes to scripts/mobile-e2e/ios-uitests/NotificationTapColdStartTests.swift
@@ -1,13 +1,15 @@
11import XCTest
22
3/// Taps a pushed notification that launches the probe app, and reports what
4/// its page was handed (#255).
3/// Taps a pushed notification that launches the probe app, then has a second
4/// push arrive while it is open, and reports what its page was handed each
5/// time (#255, #256).
56///
67/// A person's sequence, end to end: the app asks for permission and is
78/// allowed, the app is killed, a real push arrives through `simctl push`, and
8/// the banner is tapped. `simctl` runs on the host, so the test prints
9/// `CRAFT-E2E-PUSH-READY` once the app is gone and the harness sends the push
10/// when it reads that line.
9/// the notification is tapped. With the app now in front, another push arrives.
10/// `simctl` runs on the host, so the test prints `CRAFT-E2E-PUSH-READY cold`
11/// once the app is gone and `CRAFT-E2E-PUSH-READY foreground` once the page
12/// is listening, and the harness sends each push when it reads the line.
1113///
1214/// As with the deep-link test, no assertions about the answer live here. The
1315/// page's report is printed and `scripts/mobile-e2e/protocol.ts` judges it.
@@ -17,7 +19,13 @@ final class NotificationTapColdStartTests: XCTestCase {
1719 private var permissionLink: String { environment["PROBE_NOTIFY_LINK"] ?? "" }
1820 private var pushBody: String { environment["PROBE_PUSH_BODY"] ?? "" }
1921
20 func testTapOnAPushLaunchesTheApp() throws {
22 override func setUp() {
23 // Each step stands on the one before it; carrying on past a failure
24 // would only bury it under the ones it causes.
25 continueAfterFailure = false
26 }
27
28 func testPushesReachThePage() throws {
2129 XCTAssertFalse(bundleId.isEmpty, "PROBE_BUNDLE_ID is not set")
2230 XCTAssertFalse(permissionLink.isEmpty, "PROBE_NOTIFY_LINK is not set")
2331 XCTAssertFalse(pushBody.isEmpty, "PROBE_PUSH_BODY is not set")
@@ -33,13 +41,18 @@ final class NotificationTapColdStartTests: XCTestCase {
3341 if open.waitForExistence(timeout: 5) { open.tap() }
3442 XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "the permission link did not start the app")
3543
44 // The prompt, or an answer without one. iOS can remember a grant for
45 // a bundle across its reinstall, so the previous leg's Allow may still
46 // stand and no prompt appears; the page's answer is what counts.
3647 let allow = springboard.alerts.buttons["Allow"]
37 XCTAssertTrue(allow.waitForExistence(timeout: 60), "the page never asked for notification permission")
38 allow.tap()
3948 let permission = app.staticTexts
4049 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFY-PERMISSION '"))
4150 .firstMatch
42 XCTAssertTrue(permission.waitForExistence(timeout: 30), "the page never said how the permission request ended")
51 let deadline = Date().addingTimeInterval(90)
52 while !permission.exists && Date() < deadline {
53 if allow.exists { allow.tap() } else { _ = permission.waitForExistence(timeout: 1) }
54 }
55 XCTAssertTrue(permission.exists, "the page never said how the notification permission request ended")
4356 XCTAssertEqual(permission.label, "CRAFT-E2E-NOTIFY-PERMISSION granted")
4457
4558 // Killed, so the tap below is what starts it. A running app would be
@@ -47,21 +60,52 @@ final class NotificationTapColdStartTests: XCTestCase {
4760 // case that always worked.
4861 app.terminate()
4962 XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "the app did not stop, so this would not be a cold start")
50 print("CRAFT-E2E-PUSH-READY")
63 tapColdPush(springboard, app: app)
64
65 let report = app.staticTexts
66 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION '"))
67 .firstMatch
68 XCTAssertTrue(report.waitForExistence(timeout: 120), "the page never reported what it was handed")
69 print("CRAFT-E2E-NOTIFICATION-RESULT \(report.label)")
70
71 // The app is in front and the page has subscribed, since it reported
72 // above. A push now is one it can only hear about through onReceive.
73 // The page's receipt report is there from the moment it subscribed,
74 // and rewritten once an arrival has settled. Whatever it says after a
75 // generous wait is printed, including nothing arriving at all, so the
76 // harness can say which it was rather than only that time ran out.
77 print("CRAFT-E2E-PUSH-READY foreground")
78 let receipts = app.staticTexts
79 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION-RECEIVED '"))
80 .firstMatch
81 XCTAssertTrue(receipts.waitForExistence(timeout: 30), "the page never reported what onReceive handed it")
82 let arrived = app.staticTexts
83 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION-RECEIVED ' AND label CONTAINS %@", "\"received\":[{"))
84 .firstMatch
85 _ = arrived.waitForExistence(timeout: 30)
86 print("CRAFT-E2E-NOTIFICATION-RECEIVED-RESULT \(receipts.label)")
87 }
5188
52 // The banner, found by this run's body so a notification left over
53 // from an earlier run cannot be the one tapped.
89 /// Asks the host for the cold push and taps its banner.
90 ///
91 /// The banner, not Notification Center: on iOS 26 Notification Center is
92 /// pulled down as the Lock Screen, and a tap there only offers Open. The
93 /// banner is found by this run's text, so a notification left over from
94 /// an earlier run cannot be the one tapped.
95 ///
96 /// iOS takes a banner down after a few seconds, and a tap first waits for
97 /// SpringBoard to go idle and then looks the banner up again, about a
98 /// second on CI. On a machine at a load average of 16 that was once long
99 /// enough for the banner to go, and the test fails saying the tap could
100 /// not find it. There is no retrying around that: a tap that misses ends
101 /// the test method, even inside XCTExpectFailure.
102 private func tapColdPush(_ springboard: XCUIApplication, app: XCUIApplication) {
54103 let banner = springboard.descendants(matching: .any)
55104 .matching(NSPredicate(format: "label CONTAINS %@", pushBody))
56105 .firstMatch
106 print("CRAFT-E2E-PUSH-READY cold")
57107 XCTAssertTrue(banner.waitForExistence(timeout: 120), "no banner for the push ever appeared")
58108 banner.tap()
59109 XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "tapping the banner did not start the app")
60
61 let report = app.staticTexts
62 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION '"))
63 .firstMatch
64 XCTAssertTrue(report.waitForExistence(timeout: 120), "the page never reported what it was handed")
65 print("CRAFT-E2E-NOTIFICATION-RESULT \(report.label)")
66110 }
67111}
scripts/mobile-e2e/ios.tsmodified+46-27
Changes to scripts/mobile-e2e/ios.ts
@@ -1,8 +1,9 @@
1import type { PushStage } from './protocol'
12import type { LegOutcome, RunnerOptions } from './types'
23import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
34import { join } from 'node:path'
45import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index'
5import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationPayload, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
6import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationBody, notificationPayload, notificationReceiptProblems, notificationReceiptReport, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
67import { command, driverPage, waitForFile } from './support'
78
89/** The scheme the probe registers, and the one its cold-start link uses. */
@@ -66,14 +67,19 @@ const UI_TESTS = `${APP_NAME}UITests`
6667const UI_TEST_CLASSES = ['DeepLinkColdStartTests', 'NotificationTapColdStartTests'] as const
6768
6869/**
69 * How long the notification UI test gets to have the app killed and ready for
70 * a push, and then to tap it. It starts a test runner, launches the app twice
71 * and answers a permission prompt, so it is given more than one launch.
70 * How long the notification UI test gets in all. It starts a test runner,
71 * launches the app twice and answers a permission prompt; it usually takes
72 * under half a minute.
7273 */
7374const NOTIFICATION_TEST_TIMEOUT_MS = 300_000
7475
75/** The line the notification UI test prints once the app is gone. */
76const PUSH_READY = /^CRAFT-E2E-PUSH-READY\s*$/m
76/**
77 * The pushes the notification UI test has asked for, in order, one per
78 * `CRAFT-E2E-PUSH-READY <stage>` line.
79 */
80function pushesAskedFor(output: string): PushStage[] {
81 return [...output.matchAll(/^CRAFT-E2E-PUSH-READY (cold|foreground)\s*$/gm)].map(match => match[1] as PushStage)
82}
7783
7884/**
7985 * One leg is one answer to "who served the call".
@@ -316,44 +322,57 @@ schemes:
316322 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
317323 failures.push(...deepLinkProblems(deepLinkResults(`${coldStart.stdout}\n${coldStart.stderr}`), link))
318324
319 // #255: a tap on a push that launches the app, judged by what the page was
320 // handed. The UI test allows notifications, kills the app and prints
321 // CRAFT-E2E-PUSH-READY; `simctl push` runs on the host, so the push is sent
322 // from here when that line appears, and the test taps the banner.
325 // #255 and #256: a tap on a push that launches the app, then a push that
326 // arrives while it is open, each judged by what the page was handed. The UI
327 // test prints CRAFT-E2E-PUSH-READY <stage> when it is ready for each one:
328 // once it has allowed notifications and killed the app, and once the page
329 // it tapped open is listening. `simctl push` runs on the host, so each push
330 // is sent from here when its line appears.
323331 //
324332 // Spawned rather than run through `command`, which returns only once the
325 // process has exited, and this one waits on the push.
326 const payloadPath = join(evidence, 'notification.apns')
327 writeFileSync(payloadPath, notificationPayload(nonce))
333 // process has exited, and this one waits on the pushes.
328334 const notificationLog = join(evidence, 'xcodebuild-notification.log')
335 const notificationEvidence = `${label}/xcodebuild-notification.log`
329336 const tapping = Bun.spawn(uiTest('NotificationTapColdStartTests'), {
330337 cwd: project,
331338 env: {
332339 ...process.env,
333340 TEST_RUNNER_PROBE_BUNDLE_ID: BUNDLE_ID,
334341 TEST_RUNNER_PROBE_NOTIFY_LINK: `${DEEP_LINK_SCHEME}://e2e/notify?run=${encodeURIComponent(nonce)}`,
335 TEST_RUNNER_PROBE_PUSH_BODY: nonce,
342 TEST_RUNNER_PROBE_PUSH_BODY: notificationBody(nonce, 'cold'),
336343 },
337344 stdin: 'ignore',
338345 stdout: Bun.file(notificationLog),
339346 stderr: Bun.file(join(evidence, 'xcodebuild-notification-stderr.log')),
340347 })
341 const readyForPush = await waitForFile(
342 notificationLog,
343 NOTIFICATION_TEST_TIMEOUT_MS,
344 text => PUSH_READY.test(text) || tapping.exitCode !== null,
345 ) && PUSH_READY.test(readFileSync(notificationLog, 'utf8'))
346 if (readyForPush)
347 await command(['xcrun', 'simctl', 'push', device.udid, BUNDLE_ID, payloadPath], { logPath: join(evidence, 'simctl.log') })
348 const timer = setTimeout(() => tapping.kill(), NOTIFICATION_TEST_TIMEOUT_MS)
348 // One push per line asked for, sent as each line appears, until the test
349 // exits or runs out of time.
350 const pushed: PushStage[] = []
351 const deadline = Date.now() + NOTIFICATION_TEST_TIMEOUT_MS
352 while (tapping.exitCode === null && Date.now() < deadline) {
353 for (const stage of pushesAskedFor(readFileSync(notificationLog, 'utf8')).slice(pushed.length)) {
354 const payloadPath = join(evidence, `notification-${stage}.apns`)
355 writeFileSync(payloadPath, notificationPayload(nonce, stage))
356 await command(['xcrun', 'simctl', 'push', device.udid, BUNDLE_ID, payloadPath], { logPath: join(evidence, 'simctl.log') })
357 pushed.push(stage)
358 }
359 await Bun.sleep(250)
360 }
361 if (tapping.exitCode === null) tapping.kill()
349362 await tapping.exited
350 clearTimeout(timer)
351363 await command(['xcrun', 'simctl', 'io', device.udid, 'screenshot', join(evidence, 'notification-screen.png')], { allowFailure: true })
352364 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
353 if (!readyForPush)
354 failures.push(`the notification UI test never had the app killed and ready for a push; see ${label}/xcodebuild-notification.log`)
355 else
356 failures.push(...notificationTapProblems(notificationTapReport(readFileSync(notificationLog, 'utf8')), nonce, `${label}/xcodebuild-notification.log`))
365 const notificationOutput = readFileSync(notificationLog, 'utf8')
366 if (!pushed.includes('cold')) {
367 failures.push(`the notification UI test never had the app killed and ready for a push; see ${notificationEvidence}`)
368 }
369 else {
370 failures.push(...notificationTapProblems(notificationTapReport(notificationOutput), nonce, notificationEvidence))
371 if (!pushed.includes('foreground'))
372 failures.push(`the notification UI test never got to the push that arrives while the app is open; see ${notificationEvidence}`)
373 else
374 failures.push(...notificationReceiptProblems(notificationReceiptReport(notificationOutput), nonce, notificationEvidence))
375 }
357376
358377 const dispatched = zigDispatchedActions(consoleText)
359378 const refused = zigRefusals(consoleText)
scripts/mobile-e2e/protocol.test.tsmodified+52-7
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,7 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, NOTIFICATION_ROUTE, notificationPayload, notificationTapProblems, notificationTapReport, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, NOTIFICATION_ROUTE, notificationBody, notificationPayload, notificationReceiptProblems, notificationReceiptReport, notificationTapProblems, notificationTapReport, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
55
66const ESC = String.fromCharCode(27)
77
@@ -422,12 +422,57 @@ describe('notification tap cold start', () => {
422422 .toEqual(['the notification tap cold start never reported; see ios-shim/xcodebuild-notification.log'])
423423 })
424424
425 it('sends a push simctl accepts, whose body is the run the banner is found by', () => {
426 const payload = notificationPayload(RUN)
427 expect(new TextEncoder().encode(payload).length).toBeLessThanOrEqual(4096)
428 const parsed = JSON.parse(payload)
429 expect(parsed.aps.alert.body).toBe(RUN)
430 expect(parsed).toMatchObject({ craftE2E: RUN, route: NOTIFICATION_ROUTE })
425 it('sends pushes simctl accepts, each with the banner text its stage is found by', () => {
426 for (const stage of ['cold', 'foreground'] as const) {
427 const payload = notificationPayload(RUN, stage)
428 expect(new TextEncoder().encode(payload).length).toBeLessThanOrEqual(4096)
429 const parsed = JSON.parse(payload)
430 expect(parsed.aps.alert.body).toBe(notificationBody(RUN, stage))
431 expect(parsed).toMatchObject({ craftE2E: RUN, stage, route: NOTIFICATION_ROUTE })
432 }
433 // The cold banner is searched for by its text; the other stage's must not match it.
434 expect(notificationBody(RUN, 'foreground')).not.toContain(notificationBody(RUN, 'cold'))
435 })
436})
437
438describe('notification that arrives while the app is open', () => {
439 const RUN = 'craft-e2e-ios-shim-1'
440 const arrival = { aps: { alert: { title: 'Craft E2E', body: `${RUN} foreground` } }, craftE2E: RUN, stage: 'foreground', route: NOTIFICATION_ROUTE }
441 const judge = (report: object) => notificationReceiptProblems(
442 notificationReceiptReport(`CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION {"taps":[]}\nCRAFT-E2E-NOTIFICATION-RECEIVED-RESULT CRAFT-E2E-NOTIFICATION-RECEIVED ${JSON.stringify(report)}`),
443 RUN,
444 )
445
446 it('passes a page handed the push once through onReceive, and not as a tap', () => {
447 expect(judge({ received: [arrival], taps: 1, onReceive: 'function', unsubscribe: 'function' })).toEqual([])
448 })
449
450 // The bug: willPresent showed the banner and told the page nothing.
451 it('fails a page that was never told', () => {
452 expect(judge({ received: [], taps: 1, onReceive: 'function', unsubscribe: 'function' }))
453 .toEqual(['a page that was open when a push arrived was handed [] by onReceive, expected that push once'])
454 })
455
456 it('fails the cold push reported as an arrival, a duplicate, or one that lost its route', () => {
457 const cold = { ...arrival, stage: 'cold' }
458 expect(judge({ received: [cold, arrival], taps: 1, onReceive: 'function', unsubscribe: 'function' })[0]).toContain('expected that push once')
459 expect(judge({ received: [arrival, arrival], taps: 1, onReceive: 'function', unsubscribe: 'function' })[0]).toContain('expected that push once')
460 expect(judge({ received: [{ ...arrival, route: undefined }], taps: 1, onReceive: 'function', unsubscribe: 'function' })[0]).toContain('without its route')
461 })
462
463 it('fails an arrival handed to onTap as well', () => {
464 expect(judge({ received: [arrival], taps: 2, onReceive: 'function', unsubscribe: 'function' }))
465 .toEqual(['the push that arrived while the app was open was handed to onTap as well; onTap had 2 taps'])
466 })
467
468 it('fails a bridge with no onReceive, or one that returns no way to unsubscribe, and names a run that never reported', () => {
469 expect(judge({ received: [], taps: 1, onReceive: 'undefined', unsubscribe: null }))
470 .toEqual(['notifications.onReceive is undefined, so a page cannot hear about a push that arrives while it is open'])
471 expect(judge({ received: [arrival], taps: 1, onReceive: 'function', unsubscribe: 'undefined' }))
472 .toEqual(['notifications.onReceive returned undefined, expected a function that unsubscribes'])
473 // The tap's report line is not a receipt report.
474 expect(notificationReceiptProblems(notificationReceiptReport('CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION {"taps":[]}'), RUN))
475 .toEqual(['the push that arrived while the app was open never reported; see xcodebuild-notification.log'])
431476 })
432477})
433478
scripts/mobile-e2e/protocol.tsmodified+78-5
Changes to scripts/mobile-e2e/protocol.ts
@@ -447,13 +447,26 @@ export function deepLinkProblems(
447447export const NOTIFICATION_ROUTE = '/e2e/notification-tap'
448448
449449/**
450 * The push the harness sends with `simctl push` once the app is killed.
450 * The two pushes the notification UI test asks for: `cold` once the app is
451 * killed, for its banner to be tapped (#255), and `foreground` once the app
452 * that tap launched is open (#256).
453 */
454export type PushStage = 'cold' | 'foreground'
455
456/** The banner text of a stage's push, which the UI test finds the banner by. */
457export function notificationBody(run: string, stage: PushStage): string {
458 return `${run} ${stage}`
459}
460
461/**
462 * The push the harness sends with `simctl push` for one stage.
451463 *
452 * The body is the run's nonce, which the UI test finds the banner by, and
453 * `craftE2E` carries it again as data, for the page to hand back.
464 * `craftE2E` and `stage` carry the run and the stage again as data, for the
465 * page to hand back, so a push from another run or the other stage cannot
466 * pass for this one.
454467 */
455export function notificationPayload(run: string): string {
456 return `${JSON.stringify({ aps: { alert: { title: 'Craft E2E', body: run } }, craftE2E: run, route: NOTIFICATION_ROUTE })}\n`
468export function notificationPayload(run: string, stage: PushStage): string {
469 return `${JSON.stringify({ aps: { alert: { title: 'Craft E2E', body: notificationBody(run, stage) } }, craftE2E: run, stage, route: NOTIFICATION_ROUTE })}\n`
457470}
458471
459472/** What a page that subscribed late was handed after a tap launched the app. */
@@ -521,6 +534,66 @@ export function notificationTapProblems(
521534 return problems
522535}
523536
537/** What a page that was open when a push arrived was handed by onReceive. */
538export interface NotificationReceiptReport {
539 received: Record<string, unknown>[]
540 /** How many taps onTap had handed the page when it reported. */
541 taps: number
542 /** `typeof` notifications.onReceive. */
543 onReceive: string
544 /** `typeof` what onReceive returned, or null when there was no onReceive to call. */
545 unsubscribe: string | null
546}
547
548/**
549 * The receipt report the UI test printed, as a
550 * `CRAFT-E2E-NOTIFICATION-RECEIVED-RESULT CRAFT-E2E-NOTIFICATION-RECEIVED {json}`
551 * line. Null when there is none, or it does not parse.
552 */
553export function notificationReceiptReport(output: string): NotificationReceiptReport | null {
554 for (const line of output.split('\n')) {
555 const match = line.match(/CRAFT-E2E-NOTIFICATION-RECEIVED-RESULT CRAFT-E2E-NOTIFICATION-RECEIVED (\{.*\})\s*$/)
556 if (!match) continue
557 try {
558 const report = JSON.parse(match[1]!) as NotificationReceiptReport
559 if (Array.isArray(report.received)) return report
560 }
561 catch {}
562 }
563 return null
564}
565
566/**
567 * Why the push that arrived while the app was open is not a pass (#256).
568 *
569 * The page must be handed that push once, through onReceive, with its data
570 * intact. Not the cold push, which arrived while the app was not running and
571 * was the tap's to deliver, and not as a tap: an arrival is not a tap, and a
572 * page routing on onTap would jump to a screen nobody asked for.
573 */
574export function notificationReceiptProblems(
575 report: NotificationReceiptReport | null,
576 run: string,
577 evidence = 'xcodebuild-notification.log',
578): string[] {
579 if (!report)
580 return [`the push that arrived while the app was open never reported; see ${evidence}`]
581 if (report.onReceive !== 'function')
582 return [`notifications.onReceive is ${report.onReceive}, so a page cannot hear about a push that arrives while it is open`]
583
584 const problems: string[] = []
585 if (report.unsubscribe !== 'function')
586 problems.push(`notifications.onReceive returned ${report.unsubscribe}, expected a function that unsubscribes`)
587 const ours = report.received.filter(entry => entry.craftE2E === run && entry.stage === 'foreground')
588 if (ours.length !== 1 || report.received.length !== 1)
589 problems.push(`a page that was open when a push arrived was handed ${JSON.stringify(report.received)} by onReceive, expected that push once`)
590 else if (ours[0]!.route !== NOTIFICATION_ROUTE)
591 problems.push(`the push reached onReceive without its route: it carried ${JSON.stringify(ours[0])}`)
592 if (report.taps > 1)
593 problems.push(`the push that arrived while the app was open was handed to onTap as well; onTap had ${report.taps} taps`)
594 return problems
595}
596
524597/** The need the Android share case announces before it opens the menu. */
525598export const DISMISS_SHARE_MENU = 'dismiss-share-menu'
526599