also looking at this
fix(ios): schedule a notification with the data the page gave it
#262
8 files
+242
-22
Review threads live on the whole diff, not on one commit, so none are shown here - a thread's line means something in the branch's final form, and painting it into an intermediate step would put it on code it is not about.
| @@ -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>[] |