also looking at this
fix(ios): schedule a notification with the data the page gave it
#262
8 files
+242
-22
| @@ -393,6 +393,20 @@ describe('Craft iOS builder', () => { | ||
| 393 | 393 | expect(handle.slice(0, handle.indexOf('\n }\n'))).toContain('sendToWeb("craftNotificationReceived"') |
| 394 | 394 | }) |
| 395 | 395 | |
| 396 | it('schedules a local notification with the data the page gave it', async () => { | |
| 397 | // #258: `data` never reached userInfo, so a tap on a scheduled | |
| 398 | // notification handed the page {} and it had nothing to route on. | |
| 399 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-notification-data-')) | |
| 400 | await init({ name: 'WildLoop', bundleId: 'org.wildloop.app', output, config: { enableLocalNotifications: true } }) | |
| 401 | const swift = readFileSync(join(output, 'Sources', 'WildLoopApp.swift'), 'utf8') | |
| 402 | ||
| 403 | const schedule = swift.slice(swift.indexOf('private func scheduleLocalNotification(')) | |
| 404 | const body = schedule.slice(0, schedule.indexOf('\n }\n')) | |
| 405 | expect(body).toContain('if let info = data["data"] as? [String: Any] { content.userInfo = info }') | |
| 406 | // Set on the content the request is made from, before it is filed. | |
| 407 | expect(body.indexOf('content.userInfo = info')).toBeLessThan(body.indexOf('UNNotificationRequest(identifier: id, content: content')) | |
| 408 | }) | |
| 409 | ||
| 396 | 410 | it('settles calendar callbacks only after a real EventKit operation', async () => { |
| 397 | 411 | const output = mkdtempSync(join(tmpdir(), 'craft-ios-calendar-')) |
| 398 | 412 | await init({ |
| @@ -4433,6 +4433,10 @@ struct CraftWebView: UIViewRepresentable { | ||
| 4433 | 4433 | content.body = data["body"] as? String ?? "" |
| 4434 | 4434 | if let subtitle = data["subtitle"] as? String { content.subtitle = subtitle } |
| 4435 | 4435 | if let badge = data["badge"] as? Int { content.badge = NSNumber(value: badge) } |
| 4436 | // The page's `data`, which is what a tap (#255) and an arrival | |
| 4437 | // (#256) hand back. It was dropped, so a tap on a scheduled | |
| 4438 | // notification reached the page as {} (#258). | |
| 4439 | if let info = data["data"] as? [String: Any] { content.userInfo = info } | |
| 4436 | 4440 | content.sound = .default |
| 4437 | 4441 | |
| 4438 | 4442 | let id = data["id"] as? String ?? UUID().uuidString |
| @@ -707,12 +707,16 @@ const Schedule = struct { | ||
| 707 | 707 | subtitle: ?[]u8, |
| 708 | 708 | badge: ?i64, |
| 709 | 709 | trigger: Trigger, |
| 710 | /// The page's `data` object as JSON text, for `userInfo`. Text here so | |
| 711 | /// parsing stays pure; `buildRequest` turns it into an NSDictionary. | |
| 712 | user_info: ?[]u8 = null, | |
| 710 | 713 | |
| 711 | 714 | fn deinit(self: *Schedule, allocator: std.mem.Allocator) void { |
| 712 | 715 | if (self.id) |v| allocator.free(v); |
| 713 | 716 | allocator.free(self.title); |
| 714 | 717 | allocator.free(self.body); |
| 715 | 718 | if (self.subtitle) |v| allocator.free(v); |
| 719 | if (self.user_info) |v| allocator.free(v); | |
| 716 | 720 | self.* = undefined; |
| 717 | 721 | } |
| 718 | 722 | }; |
| @@ -759,6 +763,16 @@ fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule { | ||
| 759 | 763 | } else null; |
| 760 | 764 | errdefer if (id) |v| allocator.free(v); |
| 761 | 765 | |
| 766 | // `data` rides along as the notification's userInfo, which is what a tap | |
| 767 | // (#255) and an arrival (#256) hand the page. It was dropped, so a | |
| 768 | // scheduled notification reached the page as `{}` (#258). An object only, | |
| 769 | // as `as? [String: Any]` reads it. | |
| 770 | const user_info: ?[]u8 = if (notification.get("data")) |v| switch (v) { | |
| 771 | .object => try jsonText(allocator, v), | |
| 772 | else => null, | |
| 773 | } else null; | |
| 774 | errdefer if (user_info) |v| allocator.free(v); | |
| 775 | ||
| 762 | 776 | return .{ |
| 763 | 777 | .id = id, |
| 764 | 778 | .title = title, |
| @@ -766,9 +780,17 @@ fn parseSchedule(allocator: std.mem.Allocator, data: []const u8) !Schedule { | ||
| 766 | 780 | .subtitle = subtitle, |
| 767 | 781 | .badge = numberOr(notification.get("badge")), |
| 768 | 782 | .trigger = triggerFrom(notification), |
| 783 | .user_info = user_info, | |
| 769 | 784 | }; |
| 770 | 785 | } |
| 771 | 786 | |
| 787 | fn jsonText(allocator: std.mem.Allocator, value: std.json.Value) ![]u8 { | |
| 788 | var out: std.Io.Writer.Allocating = .init(allocator); | |
| 789 | defer out.deinit(); | |
| 790 | try std.json.Stringify.value(value, .{}, &out.writer); | |
| 791 | return out.toOwnedSlice(); | |
| 792 | } | |
| 793 | ||
| 772 | 794 | fn stringOr(value: ?std.json.Value, fallback: []const u8) []const u8 { |
| 773 | 795 | const v = value orelse return fallback; |
| 774 | 796 | return switch (v) { |
| @@ -827,6 +849,9 @@ const ScheduleSels = struct { | ||
| 827 | 849 | request_with: Id, |
| 828 | 850 | add_request: Id, |
| 829 | 851 | uuid_string: Id, |
| 852 | set_user_info: Id, | |
| 853 | data_with_bytes: Id, | |
| 854 | json_object_with_data: Id, | |
| 830 | 855 | |
| 831 | 856 | fn resolve() !ScheduleSels { |
| 832 | 857 | if (!is_darwin) return error.UnsupportedPlatform; |
| @@ -848,6 +873,9 @@ const ScheduleSels = struct { | ||
| 848 | 873 | .request_with = try selector("requestWithIdentifier:content:trigger:"), |
| 849 | 874 | .add_request = try selector("addNotificationRequest:withCompletionHandler:"), |
| 850 | 875 | .uuid_string = try selector("UUIDString"), |
| 876 | .set_user_info = try selector("setUserInfo:"), | |
| 877 | .data_with_bytes = try selector("dataWithBytes:length:"), | |
| 878 | .json_object_with_data = try selector("JSONObjectWithData:options:error:"), | |
| 851 | 879 | }; |
| 852 | 880 | } |
| 853 | 881 | }; |
| @@ -1113,6 +1141,7 @@ fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id { | ||
| 1113 | 1141 | try setStringField(content, sels.set_title, schedule.title, sels); |
| 1114 | 1142 | try setStringField(content, sels.set_body, schedule.body, sels); |
| 1115 | 1143 | if (schedule.subtitle) |sub| try setStringField(content, sels.set_subtitle, sub, sels); |
| 1144 | if (schedule.user_info) |json| objc.msgSendVoid1(content, sels.set_user_info, try jsonObject(json, sels)); | |
| 1116 | 1145 | |
| 1117 | 1146 | if (schedule.badge) |badge| { |
| 1118 | 1147 | const NSNumber = objc.objc_getClass("NSNumber") orelse return error.ClassNotFound; |
| @@ -1141,6 +1170,25 @@ fn buildRequest(schedule: *Schedule, sels: ScheduleSels) !Id { | ||
| 1141 | 1170 | return request; |
| 1142 | 1171 | } |
| 1143 | 1172 | |
| 1173 | /// The page's `data` as the NSDictionary `userInfo` takes, read back through | |
| 1174 | /// NSJSONSerialization into the same Foundation types WebKit hands the Swift | |
| 1175 | /// side. JSON the page sent always reads; refusing when it does not beats | |
| 1176 | /// scheduling a notification that would reach the page as `{}` again. | |
| 1177 | fn jsonObject(json: []const u8, sels: ScheduleSels) !Id { | |
| 1178 | const NSData = objc.objc_getClass("NSData") orelse return error.ClassNotFound; | |
| 1179 | const DataFn = *const fn (Id, Id, [*]const u8, c_ulong) callconv(.c) Id; | |
| 1180 | const make_data: DataFn = @ptrCast(&objc.objc_msgSend); | |
| 1181 | const bytes = make_data(NSData, sels.data_with_bytes, json.ptr, json.len); | |
| 1182 | if (bytes == null) return error.NoData; | |
| 1183 | ||
| 1184 | const NSJSONSerialization = objc.objc_getClass("NSJSONSerialization") orelse return error.ClassNotFound; | |
| 1185 | const ReadFn = *const fn (Id, Id, Id, c_ulong, ?*Id) callconv(.c) Id; | |
| 1186 | const read: ReadFn = @ptrCast(&objc.objc_msgSend); | |
| 1187 | const object = read(NSJSONSerialization, sels.json_object_with_data, bytes, 0, null); | |
| 1188 | if (object == null) return error.UserInfoUnreadable; | |
| 1189 | return object; | |
| 1190 | } | |
| 1191 | ||
| 1144 | 1192 | /// A nil trigger is legal and means "deliver now" — see `Trigger`. |
| 1145 | 1193 | fn buildTrigger(trigger: Trigger, sels: ScheduleSels) !Id { |
| 1146 | 1194 | switch (trigger) { |
| @@ -1371,6 +1419,33 @@ test "the payload is read the way the spec reads it" { | ||
| 1371 | 1419 | ); |
| 1372 | 1420 | } |
| 1373 | 1421 | |
| 1422 | test "the page's data travels as userInfo, and only an object does" { | |
| 1423 | // #258: `data` was never read, so a tap on a scheduled notification handed | |
| 1424 | // the page `{}` and "open the thing this is about" had nothing to open. | |
| 1425 | const allocator = testing.allocator; | |
| 1426 | ||
| 1427 | var with = try parseSchedule(allocator, "{\"notification\":{\"title\":\"t\",\"data\":{\"screen\":\"plant-id\",\"n\":2,\"deep\":{\"ok\":true}}}}"); | |
| 1428 | defer with.deinit(allocator); | |
| 1429 | const text = with.user_info orelse return error.DataDropped; | |
| 1430 | const round = try std.json.parseFromSlice(std.json.Value, allocator, text, .{}); | |
| 1431 | defer round.deinit(); | |
| 1432 | try testing.expectEqualStrings("plant-id", round.value.object.get("screen").?.string); | |
| 1433 | try testing.expectEqual(@as(i64, 2), round.value.object.get("n").?.integer); | |
| 1434 | try testing.expect(round.value.object.get("deep").?.object.get("ok").?.bool); | |
| 1435 | ||
| 1436 | // `as? [String: Any]`: anything but an object is no userInfo at all. | |
| 1437 | for ([_][]const u8{ | |
| 1438 | "{\"notification\":{\"title\":\"t\"}}", | |
| 1439 | "{\"notification\":{\"data\":\"plant-id\"}}", | |
| 1440 | "{\"notification\":{\"data\":[1,2]}}", | |
| 1441 | "{\"notification\":{\"data\":null}}", | |
| 1442 | }) |payload| { | |
| 1443 | var without = try parseSchedule(allocator, payload); | |
| 1444 | defer without.deinit(allocator); | |
| 1445 | try testing.expect(without.user_info == null); | |
| 1446 | } | |
| 1447 | } | |
| 1448 | ||
| 1374 | 1449 | test "a non-positive delay delivers now instead of raising" { |
| 1375 | 1450 | // The spec passes `delay / 1000` to `UNTimeIntervalNotificationTrigger` |
| 1376 | 1451 | // unguarded, and an interval of zero or less raises an uncatchable ObjC |
| @@ -18,6 +18,7 @@ | ||
| 18 | 18 | <p id="notification-permission"></p> |
| 19 | 19 | <p id="notification"></p> |
| 20 | 20 | <p id="notification-received"></p> |
| 21 | <p id="notification-local"></p> | |
| 21 | 22 | <script> |
| 22 | 23 | // The page drives itself and reports through the bridge's own `log` action, |
| 23 | 24 | // which is the one channel both runtimes already forward off-device: iOS |
| @@ -510,6 +511,10 @@ | ||
| 510 | 511 | // what onReceive handed it, and how many taps it had by then, since an |
| 511 | 512 | // arrival is not a tap. |
| 512 | 513 | // |
| 514 | // #258: opened by the local link, the page schedules a notification of | |
| 515 | // its own, with data, a few seconds out. The test kills the app before it | |
| 516 | // fires and taps it, and this same report says what the tap carried. | |
| 517 | // | |
| 513 | 518 | // Permission is asked for only when the link asks, because nothing |
| 514 | 519 | // answers the prompt on the launches simctl makes. iOS only: Android has |
| 515 | 520 | // no tap path to test yet. |
| @@ -520,6 +525,15 @@ | ||
| 520 | 525 | var said = function (text) { document.getElementById('notification-permission').textContent = 'CRAFT-E2E-NOTIFY-PERMISSION ' + text } |
| 521 | 526 | window.craft.permissions.request('notifications').then(said, function (error) { said('rejected ' + describeError(error).message) }) |
| 522 | 527 | } |
| 528 | if (launch && launch.url.indexOf('://e2e/local') !== -1) { | |
| 529 | var scheduled = function (text) { document.getElementById('notification-local').textContent = 'CRAFT-E2E-LOCAL-SCHEDULED ' + text } | |
| 530 | window.craft.notifications.schedule({ | |
| 531 | title: 'Craft E2E', | |
| 532 | body: NONCE + ' local', | |
| 533 | delay: 8000, | |
| 534 | data: { craftE2E: NONCE, stage: 'local', nested: { kept: true } } | |
| 535 | }).then(function () { scheduled('ok') }, function (error) { scheduled('rejected ' + describeError(error).message) }) | |
| 536 | } | |
| 523 | 537 | var onTap = window.craft.notifications && window.craft.notifications.onTap |
| 524 | 538 | var seen = { dispatched: 0, taps: [], onTap: typeof onTap, unsubscribe: null } |
| 525 | 539 | if (typeof onTap === 'function') { |
| @@ -1,8 +1,9 @@ | ||
| 1 | 1 | import XCTest |
| 2 | 2 | |
| 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). | |
| 3 | /// Taps a pushed notification that launches the probe app, has a second push | |
| 4 | /// arrive while it is open, then taps a local notification the page scheduled | |
| 5 | /// with data, and reports what its page was handed each time (#255, #256, | |
| 6 | /// #258). | |
| 6 | 7 | /// |
| 7 | 8 | /// A person's sequence, end to end: the app asks for permission and is |
| 8 | 9 | /// allowed, the app is killed, a real push arrives through `simctl push`, and |
| @@ -10,6 +11,8 @@ import XCTest | ||
| 10 | 11 | /// `simctl` runs on the host, so the test prints `CRAFT-E2E-PUSH-READY cold` |
| 11 | 12 | /// once the app is gone and `CRAFT-E2E-PUSH-READY foreground` once the page |
| 12 | 13 | /// is listening, and the harness sends each push when it reads the line. |
| 14 | /// Last, a link has the page schedule a notification of its own, the app is | |
| 15 | /// killed before it fires, and its banner is tapped. | |
| 13 | 16 | /// |
| 14 | 17 | /// As with the deep-link test, no assertions about the answer live here. The |
| 15 | 18 | /// page's report is printed and `scripts/mobile-e2e/protocol.ts` judges it. |
| @@ -18,6 +21,8 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 18 | 21 | private var bundleId: String { environment["PROBE_BUNDLE_ID"] ?? "" } |
| 19 | 22 | private var permissionLink: String { environment["PROBE_NOTIFY_LINK"] ?? "" } |
| 20 | 23 | private var pushBody: String { environment["PROBE_PUSH_BODY"] ?? "" } |
| 24 | private var localLink: String { environment["PROBE_LOCAL_LINK"] ?? "" } | |
| 25 | private var localBody: String { environment["PROBE_LOCAL_BODY"] ?? "" } | |
| 21 | 26 | |
| 22 | 27 | override func setUp() { |
| 23 | 28 | // Each step stands on the one before it; carrying on past a failure |
| @@ -25,10 +30,12 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 25 | 30 | continueAfterFailure = false |
| 26 | 31 | } |
| 27 | 32 | |
| 28 | func testPushesReachThePage() throws { | |
| 33 | func testNotificationsReachThePage() throws { | |
| 29 | 34 | XCTAssertFalse(bundleId.isEmpty, "PROBE_BUNDLE_ID is not set") |
| 30 | 35 | XCTAssertFalse(permissionLink.isEmpty, "PROBE_NOTIFY_LINK is not set") |
| 31 | 36 | XCTAssertFalse(pushBody.isEmpty, "PROBE_PUSH_BODY is not set") |
| 37 | XCTAssertFalse(localLink.isEmpty, "PROBE_LOCAL_LINK is not set") | |
| 38 | XCTAssertFalse(localBody.isEmpty, "PROBE_LOCAL_BODY is not set") | |
| 32 | 39 | |
| 33 | 40 | let app = XCUIApplication(bundleIdentifier: bundleId) |
| 34 | 41 | let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") |
| @@ -60,7 +67,8 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 60 | 67 | // case that always worked. |
| 61 | 68 | app.terminate() |
| 62 | 69 | XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "the app did not stop, so this would not be a cold start") |
| 63 | tapColdPush(springboard, app: app) | |
| 70 | print("CRAFT-E2E-PUSH-READY cold") | |
| 71 | tapBanner(springboard, app: app, containing: pushBody) | |
| 64 | 72 | |
| 65 | 73 | let report = app.staticTexts |
| 66 | 74 | .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION '")) |
| @@ -84,9 +92,32 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 84 | 92 | .firstMatch |
| 85 | 93 | _ = arrived.waitForExistence(timeout: 30) |
| 86 | 94 | print("CRAFT-E2E-NOTIFICATION-RECEIVED-RESULT \(receipts.label)") |
| 95 | ||
| 96 | // #258: a notification the page scheduled itself, with data. The | |
| 97 | // local link has the page schedule it a few seconds out, and the app | |
| 98 | // is killed before it fires, so its tap is a cold start like the push. | |
| 99 | app.terminate() | |
| 100 | XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "the app did not stop before the local link") | |
| 101 | XCUIDevice.shared.system.open(URL(string: localLink)!) | |
| 102 | if open.waitForExistence(timeout: 5) { open.tap() } | |
| 103 | XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "the local link did not start the app") | |
| 104 | let scheduled = app.staticTexts | |
| 105 | .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-LOCAL-SCHEDULED '")) | |
| 106 | .firstMatch | |
| 107 | XCTAssertTrue(scheduled.waitForExistence(timeout: 60), "the page never said whether it scheduled the notification") | |
| 108 | XCTAssertEqual(scheduled.label, "CRAFT-E2E-LOCAL-SCHEDULED ok") | |
| 109 | app.terminate() | |
| 110 | XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "the app did not stop, so the local tap would not be a cold start") | |
| 111 | tapBanner(springboard, app: app, containing: localBody) | |
| 112 | ||
| 113 | let local = app.staticTexts | |
| 114 | .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION '")) | |
| 115 | .firstMatch | |
| 116 | XCTAssertTrue(local.waitForExistence(timeout: 120), "the page never reported what the local notification's tap handed it") | |
| 117 | print("CRAFT-E2E-LOCAL-RESULT \(local.label)") | |
| 87 | 118 | } |
| 88 | 119 | |
| 89 | /// Asks the host for the cold push and taps its banner. | |
| 120 | /// Taps the banner of a notification that has arrived or is about to. | |
| 90 | 121 | /// |
| 91 | 122 | /// The banner, not Notification Center: on iOS 26 Notification Center is |
| 92 | 123 | /// pulled down as the Lock Screen, and a tap there only offers Open. The |
| @@ -99,12 +130,11 @@ final class NotificationTapColdStartTests: XCTestCase { | ||
| 99 | 130 | /// enough for the banner to go, and the test fails saying the tap could |
| 100 | 131 | /// not find it. There is no retrying around that: a tap that misses ends |
| 101 | 132 | /// the test method, even inside XCTExpectFailure. |
| 102 | private func tapColdPush(_ springboard: XCUIApplication, app: XCUIApplication) { | |
| 133 | private func tapBanner(_ springboard: XCUIApplication, app: XCUIApplication, containing text: String) { | |
| 103 | 134 | let banner = springboard.descendants(matching: .any) |
| 104 | .matching(NSPredicate(format: "label CONTAINS %@", pushBody)) | |
| 135 | .matching(NSPredicate(format: "label CONTAINS %@", text)) | |
| 105 | 136 | .firstMatch |
| 106 | print("CRAFT-E2E-PUSH-READY cold") | |
| 107 | XCTAssertTrue(banner.waitForExistence(timeout: 120), "no banner for the push ever appeared") | |
| 137 | XCTAssertTrue(banner.waitForExistence(timeout: 120), "no banner for \(text) ever appeared") | |
| 108 | 138 | banner.tap() |
| 109 | 139 | XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "tapping the banner did not start the app") |
| 110 | 140 | } |
| @@ -3,7 +3,7 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 3 | 3 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 4 | 4 | import { join } from 'node:path' |
| 5 | 5 | import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index' |
| 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 | import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, localNotificationProblems, notificationBody, notificationPayload, notificationReceiptProblems, notificationReceiptReport, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 7 | 7 | import { command, driverPage, waitForFile } from './support' |
| 8 | 8 | |
| 9 | 9 | /** The scheme the probe registers, and the one its cold-start link uses. */ |
| @@ -38,6 +38,9 @@ const CONFIG = { | ||
| 38 | 38 | enableSpeechRecognition: false, |
| 39 | 39 | // For the cold-start link (#198), which the harness opens from an XCUITest. |
| 40 | 40 | enableDeepLinks: true, |
| 41 | // For the notification the page schedules with data (#258), served by | |
| 42 | // Swift on the shim leg and by Zig on the runtime leg. | |
| 43 | enableLocalNotifications: true, | |
| 41 | 44 | urlSchemes: [DEEP_LINK_SCHEME], |
| 42 | 45 | } |
| 43 | 46 | |
| @@ -68,8 +71,8 @@ const UI_TEST_CLASSES = ['DeepLinkColdStartTests', 'NotificationTapColdStartTest | ||
| 68 | 71 | |
| 69 | 72 | /** |
| 70 | 73 | * 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. | |
| 74 | * launches the app four times, answers a permission prompt and waits out a | |
| 75 | * scheduled notification; it usually takes about a minute. | |
| 73 | 76 | */ |
| 74 | 77 | const NOTIFICATION_TEST_TIMEOUT_MS = 300_000 |
| 75 | 78 | |
| @@ -340,6 +343,8 @@ schemes: | ||
| 340 | 343 | TEST_RUNNER_PROBE_BUNDLE_ID: BUNDLE_ID, |
| 341 | 344 | TEST_RUNNER_PROBE_NOTIFY_LINK: `${DEEP_LINK_SCHEME}://e2e/notify?run=${encodeURIComponent(nonce)}`, |
| 342 | 345 | TEST_RUNNER_PROBE_PUSH_BODY: notificationBody(nonce, 'cold'), |
| 346 | TEST_RUNNER_PROBE_LOCAL_LINK: `${DEEP_LINK_SCHEME}://e2e/local?run=${encodeURIComponent(nonce)}`, | |
| 347 | TEST_RUNNER_PROBE_LOCAL_BODY: notificationBody(nonce, 'local'), | |
| 343 | 348 | }, |
| 344 | 349 | stdin: 'ignore', |
| 345 | 350 | stdout: Bun.file(notificationLog), |
| @@ -368,10 +373,13 @@ schemes: | ||
| 368 | 373 | } |
| 369 | 374 | else { |
| 370 | 375 | failures.push(...notificationTapProblems(notificationTapReport(notificationOutput), nonce, notificationEvidence)) |
| 371 | if (!pushed.includes('foreground')) | |
| 376 | if (!pushed.includes('foreground')) { | |
| 372 | 377 | failures.push(`the notification UI test never got to the push that arrives while the app is open; see ${notificationEvidence}`) |
| 373 | else | |
| 378 | } | |
| 379 | else { | |
| 374 | 380 | failures.push(...notificationReceiptProblems(notificationReceiptReport(notificationOutput), nonce, notificationEvidence)) |
| 381 | failures.push(...localNotificationProblems(notificationTapReport(notificationOutput, 'CRAFT-E2E-LOCAL-RESULT'), nonce, notificationEvidence)) | |
| 382 | } | |
| 375 | 383 | } |
| 376 | 384 | |
| 377 | 385 | const dispatched = zigDispatchedActions(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, 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' | |
| 4 | import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, localNotificationProblems, 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 | |
| @@ -435,6 +435,48 @@ describe('notification tap cold start', () => { | ||
| 435 | 435 | }) |
| 436 | 436 | }) |
| 437 | 437 | |
| 438 | describe('tap on a notification the page scheduled with data', () => { | |
| 439 | const RUN = 'craft-e2e-ios-runtime-1' | |
| 440 | const tap = { craftE2E: RUN, stage: 'local', nested: { kept: true } } | |
| 441 | const pushed = { craftE2E: RUN, stage: 'cold', route: NOTIFICATION_ROUTE } | |
| 442 | const output = (report: object) => [ | |
| 443 | `CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION ${JSON.stringify({ dispatched: 1, taps: [pushed], onTap: 'function', unsubscribe: 'function' })}`, | |
| 444 | `CRAFT-E2E-LOCAL-RESULT CRAFT-E2E-NOTIFICATION ${JSON.stringify(report)}`, | |
| 445 | ].join('\n') | |
| 446 | const judge = (report: object) => localNotificationProblems(notificationTapReport(output(report), 'CRAFT-E2E-LOCAL-RESULT'), RUN) | |
| 447 | ||
| 448 | it('passes a tap that hands back the data it was scheduled with, nested values included', () => { | |
| 449 | expect(judge({ dispatched: 1, taps: [tap], onTap: 'function', unsubscribe: 'function' })).toEqual([]) | |
| 450 | }) | |
| 451 | ||
| 452 | // The bug: neither scheduler set userInfo. | |
| 453 | it('fails a tap that hands the page {}', () => { | |
| 454 | expect(judge({ dispatched: 1, taps: [{}], onTap: 'function', unsubscribe: 'function' })) | |
| 455 | .toEqual(['a tap on a scheduled notification handed the page {}: the data it was scheduled with never reached userInfo']) | |
| 456 | }) | |
| 457 | ||
| 458 | it('fails lost nesting, another run\'s data, a double delivery, and a tap never dispatched', () => { | |
| 459 | expect(judge({ dispatched: 1, taps: [{ ...tap, nested: '[object Object]' }], onTap: 'function', unsubscribe: 'function' })[0]).toContain('nested data did not survive') | |
| 460 | expect(judge({ dispatched: 1, taps: [{ ...tap, craftE2E: 'an-earlier-run' }], onTap: 'function', unsubscribe: 'function' })[0]).toContain('expected the data it was scheduled with once') | |
| 461 | expect(judge({ dispatched: 1, taps: [tap, tap], onTap: 'function', unsubscribe: 'function' })[0]).toContain('expected the data it was scheduled with once') | |
| 462 | expect(judge({ dispatched: 0, taps: [], onTap: 'function', unsubscribe: 'function' })).toEqual(['the app never dispatched the tap on the scheduled notification']) | |
| 463 | }) | |
| 464 | ||
| 465 | it('reads each tap report by its own label', () => { | |
| 466 | // The push's report is not the scheduled notification's, and a run that | |
| 467 | // never got to the local stage has no local report at all. | |
| 468 | expect(notificationTapReport(output({ dispatched: 1, taps: [tap] }))?.taps).toEqual([pushed]) | |
| 469 | expect(notificationTapReport(output({ dispatched: 1, taps: [tap] }), 'CRAFT-E2E-LOCAL-RESULT')?.taps).toEqual([tap]) | |
| 470 | expect(localNotificationProblems(notificationTapReport(output({}).split('\n')[0]!, 'CRAFT-E2E-LOCAL-RESULT'), RUN)) | |
| 471 | .toEqual(['the tap on the scheduled notification never reported; see xcodebuild-notification.log']) | |
| 472 | }) | |
| 473 | ||
| 474 | it('finds the local banner by text the push banners do not share', () => { | |
| 475 | expect(notificationBody(RUN, 'local')).toBe(`${RUN} local`) | |
| 476 | expect(notificationBody(RUN, 'cold')).not.toContain(notificationBody(RUN, 'local')) | |
| 477 | }) | |
| 478 | }) | |
| 479 | ||
| 438 | 480 | describe('notification that arrives while the app is open', () => { |
| 439 | 481 | const RUN = 'craft-e2e-ios-shim-1' |
| 440 | 482 | const arrival = { aps: { alert: { title: 'Craft E2E', body: `${RUN} foreground` } }, craftE2E: RUN, stage: 'foreground', route: NOTIFICATION_ROUTE } |
| @@ -453,8 +453,11 @@ export const NOTIFICATION_ROUTE = '/e2e/notification-tap' | ||
| 453 | 453 | */ |
| 454 | 454 | export type PushStage = 'cold' | 'foreground' |
| 455 | 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 { | |
| 456 | /** | |
| 457 | * The banner text of a stage's notification, which the UI test finds the | |
| 458 | * banner by: a pushed stage, or `local`, the one the page schedules (#258). | |
| 459 | */ | |
| 460 | export function notificationBody(run: string, stage: PushStage | 'local'): string { | |
| 458 | 461 | return `${run} ${stage}` |
| 459 | 462 | } |
| 460 | 463 | |
| @@ -485,12 +488,15 @@ export interface NotificationTapReport { | ||
| 485 | 488 | * The page's report the UI test printed, read out of xcodebuild's output. |
| 486 | 489 | * |
| 487 | 490 | * `ios-uitests/NotificationTapColdStartTests.swift` writes one |
| 488 | * `CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION {json}` line. None, | |
| 489 | * or one whose JSON does not parse, is null: the test never got that far. | |
| 491 | * `<label> CRAFT-E2E-NOTIFICATION {json}` line per tap: labelled | |
| 492 | * `CRAFT-E2E-NOTIFICATION-RESULT` for the push, `CRAFT-E2E-LOCAL-RESULT` for | |
| 493 | * the scheduled notification. None, or one whose JSON does not parse, is | |
| 494 | * null: the test never got that far. | |
| 490 | 495 | */ |
| 491 | export function notificationTapReport(output: string): NotificationTapReport | null { | |
| 496 | export function notificationTapReport(output: string, label = 'CRAFT-E2E-NOTIFICATION-RESULT'): NotificationTapReport | null { | |
| 492 | 497 | for (const line of output.split('\n')) { |
| 493 | const match = line.match(/CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION (\{.*\})\s*$/) | |
| 498 | const at = line.indexOf(`${label} CRAFT-E2E-NOTIFICATION `) | |
| 499 | const match = at === -1 ? null : line.slice(at + label.length + 1).match(/^CRAFT-E2E-NOTIFICATION (\{.*\})\s*$/) | |
| 494 | 500 | if (!match) continue |
| 495 | 501 | try { |
| 496 | 502 | const report = JSON.parse(match[1]!) as NotificationTapReport |
| @@ -534,6 +540,33 @@ export function notificationTapProblems( | ||
| 534 | 540 | return problems |
| 535 | 541 | } |
| 536 | 542 | |
| 543 | /** | |
| 544 | * Why the tap on the notification the page scheduled is not a pass (#258). | |
| 545 | * | |
| 546 | * The page scheduled it with `data`, and the tap must hand that back once, | |
| 547 | * nested values included. The bug handed the page `{}`: neither scheduler put | |
| 548 | * `data` into userInfo, so there was nothing for the tap to carry. | |
| 549 | */ | |
| 550 | export function localNotificationProblems( | |
| 551 | report: NotificationTapReport | null, | |
| 552 | run: string, | |
| 553 | evidence = 'xcodebuild-notification.log', | |
| 554 | ): string[] { | |
| 555 | if (!report) | |
| 556 | return [`the tap on the scheduled notification never reported; see ${evidence}`] | |
| 557 | if (report.dispatched === 0) | |
| 558 | return ['the app never dispatched the tap on the scheduled notification'] | |
| 559 | if (report.taps.length === 1 && Object.keys(report.taps[0]!).length === 0) | |
| 560 | return ['a tap on a scheduled notification handed the page {}: the data it was scheduled with never reached userInfo'] | |
| 561 | ||
| 562 | const ours = report.taps.filter(tap => tap.craftE2E === run && tap.stage === 'local') | |
| 563 | if (ours.length !== 1 || report.taps.length !== 1) | |
| 564 | return [`a tap on the scheduled notification handed the page ${JSON.stringify(report.taps)}, expected the data it was scheduled with once`] | |
| 565 | if ((ours[0]!.nested as { kept?: unknown } | undefined)?.kept !== true) | |
| 566 | return [`the scheduled notification's nested data did not survive: it carried ${JSON.stringify(ours[0])}`] | |
| 567 | return [] | |
| 568 | } | |
| 569 | ||
| 537 | 570 | /** What a page that was open when a push arrived was handed by onReceive. */ |
| 538 | 571 | export interface NotificationReceiptReport { |
| 539 | 572 | received: Record<string, unknown>[] |