also looking at this
feat(ios): tell the page about a notification that arrives while the app is open
#259
11 files
+410
-58
| @@ -376,6 +376,23 @@ describe('Craft iOS builder', () => { | ||
| 376 | 376 | expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationResponse"') |
| 377 | 377 | }) |
| 378 | 378 | |
| 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 | ||
| 379 | 396 | it('settles calendar callbacks only after a real EventKit operation', async () => { |
| 380 | 397 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-')) |
| 381 | 398 | await init({ |
| @@ -94,6 +94,8 @@ function loadPage(beforeInject?: (page: Record<string, any>) => void, seed = SEE | ||
| 94 | 94 | // What CraftEventManager.handleNotificationResponse evaluates. |
| 95 | 95 | tap: (detail: unknown) => |
| 96 | 96 | page.dispatchEvent(new CustomEvent('craftNotificationResponse', { detail })), |
| 97 | receive: (detail: unknown) => | |
| 98 | page.dispatchEvent(new CustomEvent('craftNotificationReceived', { detail })), | |
| 97 | 99 | } |
| 98 | 100 | } |
| 99 | 101 | |
| @@ -253,6 +255,48 @@ describe('the injected iOS page script', () => { | ||
| 253 | 255 | expect(seen).toEqual([]) |
| 254 | 256 | }) |
| 255 | 257 | |
| 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 | ||
| 256 | 300 | // #198 and #215. The Android page script runs the same block; its own test |
| 257 | 301 | // covers the rest of the contract. |
| 258 | 302 | const tick = () => new Promise(resolve => setTimeout(resolve, 5)) |
| @@ -115,6 +115,11 @@ final class CraftAppDelegate: NSObject, UIApplicationDelegate, UNUserNotificatio | ||
| 115 | 115 | } |
| 116 | 116 | |
| 117 | 117 | 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) | |
| 118 | 123 | completionHandler([.banner, .badge, .sound]) |
| 119 | 124 | } |
| 120 | 125 | } |
| @@ -156,12 +161,22 @@ class CraftEventManager { | ||
| 156 | 161 | /// event: an APNs payload's keys always are, and the page could not index |
| 157 | 162 | /// by anything else anyway. |
| 158 | 163 | 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] { | |
| 159 | 174 | var data: [String: Any] = [:] |
| 160 | 175 | for (key, value) in userInfo { |
| 161 | 176 | guard let key = key as? String else { continue } |
| 162 | 177 | data[key] = value |
| 163 | 178 | } |
| 164 | sendToWeb("craftNotificationResponse", data: data) | |
| 179 | return data | |
| 165 | 180 | } |
| 166 | 181 | |
| 167 | 182 | func handleSiriActivity(_ activity: NSUserActivity) -> Bool { |
| @@ -3149,6 +3164,17 @@ struct CraftWebView: UIViewRepresentable { | ||
| 3149 | 3164 | craft.notifications.onTap = function(callback) { |
| 3150 | 3165 | return craft._subscribeNotificationTaps(callback); |
| 3151 | 3166 | }; |
| 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 | }; | |
| 3152 | 3178 | } |
| 3153 | 3179 | })(window.craft); |
| 3154 | 3180 | |
| @@ -70,6 +70,41 @@ describe('Mobile notification taps', () => { | ||
| 70 | 70 | }) |
| 71 | 71 | }) |
| 72 | 72 | |
| 73 | describe('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 | ||
| 73 | 108 | describe('Mobile Android bridge promises', () => { |
| 74 | 109 | it('reads the typed watch reachability envelope', async () => { |
| 75 | 110 | const previousWindow = (globalThis as any).window |
| @@ -1375,6 +1375,23 @@ export const pushNotifications = { | ||
| 1375 | 1375 | }) ?? (() => {}) |
| 1376 | 1376 | } |
| 1377 | 1377 | 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)) | |
| 1378 | 1395 | } |
| 1379 | 1396 | } |
| 1380 | 1397 | |
| @@ -1495,6 +1495,11 @@ export interface CraftNotificationResponseEvent extends CustomEvent { | ||
| 1495 | 1495 | detail: Record<string, any>; |
| 1496 | 1496 | } |
| 1497 | 1497 | |
| 1498 | /** A notification that arrived while the app was open, in the same shape as a tap. iOS. */ | |
| 1499 | export interface CraftNotificationReceivedEvent extends CustomEvent { | |
| 1500 | detail: Record<string, any>; | |
| 1501 | } | |
| 1502 | ||
| 1498 | 1503 | export interface CraftWatchUserInfoEvent extends CustomEvent { |
| 1499 | 1504 | detail: Record<string, any>; |
| 1500 | 1505 | } |
| @@ -1546,6 +1551,7 @@ declare global { | ||
| 1546 | 1551 | craftNetworkChange: CraftNetworkChangeEvent; |
| 1547 | 1552 | craftPushToken: CraftPushTokenEvent; |
| 1548 | 1553 | craftNotificationResponse: CraftNotificationResponseEvent; |
| 1554 | craftNotificationReceived: CraftNotificationReceivedEvent; | |
| 1549 | 1555 | craftWatchMessage: CraftWatchMessageEvent; |
| 1550 | 1556 | craftWatchReachability: CraftWatchReachabilityEvent; |
| 1551 | 1557 | craftWatchContext: CraftWatchContextEvent; |
| @@ -17,6 +17,7 @@ | ||
| 17 | 17 | <p id="deeplink"></p> |
| 18 | 18 | <p id="notification-permission"></p> |
| 19 | 19 | <p id="notification"></p> |
| 20 | <p id="notification-received"></p> | |
| 20 | 21 | <script> |
| 21 | 22 | // The page drives itself and reports through the bridge's own `log` action, |
| 22 | 23 | // which is the one channel both runtimes already forward off-device: iOS |
| @@ -505,6 +506,10 @@ | ||
| 505 | 506 | // the suite, seconds after the bridge appeared, and reports what it was |
| 506 | 507 | // handed. On any other launch it was handed nothing, and says so. |
| 507 | 508 | // |
| 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 | // | |
| 508 | 513 | // Permission is asked for only when the link asks, because nothing |
| 509 | 514 | // answers the prompt on the launches simctl makes. iOS only: Android has |
| 510 | 515 | // no tap path to test yet. |
| @@ -520,6 +525,27 @@ | ||
| 520 | 525 | if (typeof onTap === 'function') { |
| 521 | 526 | seen.unsubscribe = typeof window.craft.notifications.onTap(function (detail) { seen.taps.push(detail || {}) }) |
| 522 | 527 | } |
| 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() | |
| 523 | 549 | // Long enough for a replay, or a second delivery, to have arrived. |
| 524 | 550 | setTimeout(function () { |
| 525 | 551 | seen.dispatched = dispatchedTaps |
| @@ -1,13 +1,15 @@ | ||
| 1 | 1 | import XCTest |
| 2 | 2 | |
| 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). | |
| 5 | 6 | /// |
| 6 | 7 | /// A person's sequence, end to end: the app asks for permission and is |
| 7 | 8 | /// 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. | |
| 11 | 13 | /// |
| 12 | 14 | /// As with the deep-link test, no assertions about the answer live here. The |
| 13 | 15 | /// page's report is printed and `scripts/mobile-e2e/protocol.ts` judges it. |
| @@ -17,7 +19,13 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 17 | 19 | private var permissionLink: String { environment["PROBE_NOTIFY_LINK"] ?? "" } |
| 18 | 20 | private var pushBody: String { environment["PROBE_PUSH_BODY"] ?? "" } |
| 19 | 21 | |
| 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 { | |
| 21 | 29 | XCTAssertFalse(bundleId.isEmpty, "PROBE_BUNDLE_ID is not set") |
| 22 | 30 | XCTAssertFalse(permissionLink.isEmpty, "PROBE_NOTIFY_LINK is not set") |
| 23 | 31 | XCTAssertFalse(pushBody.isEmpty, "PROBE_PUSH_BODY is not set") |
| @@ -33,13 +41,18 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 33 | 41 | if open.waitForExistence(timeout: 5) { open.tap() } |
| 34 | 42 | XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "the permission link did not start the app") |
| 35 | 43 | |
| 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. | |
| 36 | 47 | let allow = springboard.alerts.buttons["Allow"] |
| 37 | XCTAssertTrue(allow.waitForExistence(timeout: 60), "the page never asked for notification permission") | |
| 38 | allow.tap() | |
| 39 | 48 | let permission = app.staticTexts |
| 40 | 49 | .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFY-PERMISSION '")) |
| 41 | 50 | .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") | |
| 43 | 56 | XCTAssertEqual(permission.label, "CRAFT-E2E-NOTIFY-PERMISSION granted") |
| 44 | 57 | |
| 45 | 58 | // Killed, so the tap below is what starts it. A running app would be |
| @@ -47,21 +60,52 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 47 | 60 | // case that always worked. |
| 48 | 61 | app.terminate() |
| 49 | 62 | 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 | } | |
| 51 | 88 | |
| 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) { | |
| 54 | 103 | let banner = springboard.descendants(matching: .any) |
| 55 | 104 | .matching(NSPredicate(format: "label CONTAINS %@", pushBody)) |
| 56 | 105 | .firstMatch |
| 106 | print("CRAFT-E2E-PUSH-READY cold") | |
| 57 | 107 | XCTAssertTrue(banner.waitForExistence(timeout: 120), "no banner for the push ever appeared") |
| 58 | 108 | banner.tap() |
| 59 | 109 | 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)") | |
| 66 | 110 | } |
| 67 | 111 | } |
| @@ -1,8 +1,9 @@ | ||
| 1 | import type { PushStage } from './protocol' | |
| 1 | 2 | import type { LegOutcome, RunnerOptions } from './types' |
| 2 | 3 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 4 | import { join } from 'node:path' |
| 4 | 5 | import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index' |
| 5 | import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationPayload, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 6 | import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationBody, notificationPayload, notificationReceiptProblems, notificationReceiptReport, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 6 | 7 | import { command, driverPage, waitForFile } from './support' |
| 7 | 8 | |
| 8 | 9 | /** The scheme the probe registers, and the one its cold-start link uses. */ |
| @@ -66,14 +67,19 @@ const UI_TESTS = `${APP_NAME}UITests` | ||
| 66 | 67 | const UI_TEST_CLASSES = ['DeepLinkColdStartTests', 'NotificationTapColdStartTests'] as const |
| 67 | 68 | |
| 68 | 69 | /** |
| 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. | |
| 72 | 73 | */ |
| 73 | 74 | const NOTIFICATION_TEST_TIMEOUT_MS = 300_000 |
| 74 | 75 | |
| 75 | /** The line the notification UI test prints once the app is gone. */ | |
| 76 | const 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 | */ | |
| 80 | function pushesAskedFor(output: string): PushStage[] { | |
| 81 | return [...output.matchAll(/^CRAFT-E2E-PUSH-READY (cold|foreground)\s*$/gm)].map(match => match[1] as PushStage) | |
| 82 | } | |
| 77 | 83 | |
| 78 | 84 | /** |
| 79 | 85 | * One leg is one answer to "who served the call". |
| @@ -316,44 +322,57 @@ schemes: | ||
| 316 | 322 | await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true }) |
| 317 | 323 | failures.push(...deepLinkProblems(deepLinkResults(`${coldStart.stdout}\n${coldStart.stderr}`), link)) |
| 318 | 324 | |
| 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. | |
| 323 | 331 | // |
| 324 | 332 | // 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. | |
| 328 | 334 | const notificationLog = join(evidence, 'xcodebuild-notification.log') |
| 335 | const notificationEvidence = `${label}/xcodebuild-notification.log` | |
| 329 | 336 | const tapping = Bun.spawn(uiTest('NotificationTapColdStartTests'), { |
| 330 | 337 | cwd: project, |
| 331 | 338 | env: { |
| 332 | 339 | ...process.env, |
| 333 | 340 | TEST_RUNNER_PROBE_BUNDLE_ID: BUNDLE_ID, |
| 334 | 341 | 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'), | |
| 336 | 343 | }, |
| 337 | 344 | stdin: 'ignore', |
| 338 | 345 | stdout: Bun.file(notificationLog), |
| 339 | 346 | stderr: Bun.file(join(evidence, 'xcodebuild-notification-stderr.log')), |
| 340 | 347 | }) |
| 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() | |
| 349 | 362 | await tapping.exited |
| 350 | clearTimeout(timer) | |
| 351 | 363 | await command(['xcrun', 'simctl', 'io', device.udid, 'screenshot', join(evidence, 'notification-screen.png')], { allowFailure: true }) |
| 352 | 364 | 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 | } | |
| 357 | 376 | |
| 358 | 377 | const dispatched = zigDispatchedActions(consoleText) |
| 359 | 378 | const refused = zigRefusals(consoleText) |
| @@ -1,7 +1,7 @@ | ||
| 1 | 1 | import { describe, expect, it } from 'bun:test' |
| 2 | 2 | import { readFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | import { 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' | |
| 4 | import { 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' | |
| 5 | 5 | |
| 6 | 6 | const ESC = String.fromCharCode(27) |
| 7 | 7 | |
| @@ -422,12 +422,57 @@ describe('notification tap cold start', () => { | ||
| 422 | 422 | .toEqual(['the notification tap cold start never reported; see ios-shim/xcodebuild-notification.log']) |
| 423 | 423 | }) |
| 424 | 424 | |
| 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 | ||
| 438 | describe('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']) | |
| 431 | 476 | }) |
| 432 | 477 | }) |
| 433 | 478 | |
| @@ -447,13 +447,26 @@ export function deepLinkProblems( | ||
| 447 | 447 | export const NOTIFICATION_ROUTE = '/e2e/notification-tap' |
| 448 | 448 | |
| 449 | 449 | /** |
| 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 | */ | |
| 454 | export type PushStage = 'cold' | 'foreground' | |
| 455 | ||
| 456 | /** The banner text of a stage's push, which the UI test finds the banner by. */ | |
| 457 | export 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. | |
| 451 | 463 | * |
| 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. | |
| 454 | 467 | */ |
| 455 | export function notificationPayload(run: string): string { | |
| 456 | return `${JSON.stringify({ aps: { alert: { title: 'Craft E2E', body: run } }, craftE2E: run, route: NOTIFICATION_ROUTE })}\n` | |
| 468 | export 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` | |
| 457 | 470 | } |
| 458 | 471 | |
| 459 | 472 | /** What a page that subscribed late was handed after a tap launched the app. */ |
| @@ -521,6 +534,66 @@ export function notificationTapProblems( | ||
| 521 | 534 | return problems |
| 522 | 535 | } |
| 523 | 536 | |
| 537 | /** What a page that was open when a push arrived was handed by onReceive. */ | |
| 538 | export 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 | */ | |
| 553 | export 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 | */ | |
| 574 | export 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 | ||
| 524 | 597 | /** The need the Android share case announces before it opens the menu. */ |
| 525 | 598 | export const DISMISS_SHARE_MENU = 'dismiss-share-menu' |
| 526 | 599 | |