ReviewOS

also looking at this

craft-native/craft

test(ios): tap a real pushed notification that launches the app, and judge what the page gets

#257
Merged glennmichael123 wants to merge test/ios-e2e-notification-tap into main
5 files +308 -10
scripts/mobile-e2e/protocol.tsmodified+83-0
Changes to scripts/mobile-e2e/protocol.ts
@@ -438,6 +438,89 @@ export function deepLinkProblems(
438438 return problems
439439}
440440
441/**
442 * Where the pushed notification says to go, as a custom key beside `aps`.
443 *
444 * The part of a push an app routes on, and the part the tap has to carry
445 * intact: WildLoop opens the plant camera off a key like this one.
446 */
447export const NOTIFICATION_ROUTE = '/e2e/notification-tap'
448
449/**
450 * The push the harness sends with `simctl push` once the app is killed.
451 *
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.
454 */
455export function notificationPayload(run: string): string {
456 return `${JSON.stringify({ aps: { alert: { title: 'Craft E2E', body: run } }, craftE2E: run, route: NOTIFICATION_ROUTE })}\n`
457}
458
459/** What a page that subscribed late was handed after a tap launched the app. */
460export interface NotificationTapReport {
461 /** craftNotificationResponse events native code dispatched, counted from the page's first line. */
462 dispatched: number
463 /** What notifications.onTap handed a subscriber that arrived after the suite. */
464 taps: Record<string, unknown>[]
465 /** `typeof` notifications.onTap. */
466 onTap: string
467 /** `typeof` what onTap returned, or null when there was no onTap to call. */
468 unsubscribe: string | null
469}
470
471/**
472 * The page's report the UI test printed, read out of xcodebuild's output.
473 *
474 * `ios-uitests/NotificationTapColdStartTests.swift` writes one
475 * `CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION {json}` line. None,
476 * or one whose JSON does not parse, is null: the test never got that far.
477 */
478export function notificationTapReport(output: string): NotificationTapReport | null {
479 for (const line of output.split('\n')) {
480 const match = line.match(/CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION (\{.*\})\s*$/)
481 if (!match) continue
482 try {
483 const report = JSON.parse(match[1]!) as NotificationTapReport
484 if (Array.isArray(report.taps)) return report
485 }
486 catch {}
487 }
488 return null
489}
490
491/**
492 * Why the notification tap cold start is not a pass, one line per reason.
493 *
494 * #255 lost a tap in two places, and the report tells them apart: nothing
495 * dispatched is the app dropping it before any page existed, dispatched but
496 * not handed to onTap is the page flushing it before anyone subscribed.
497 */
498export function notificationTapProblems(
499 report: NotificationTapReport | null,
500 run: string,
501 evidence = 'xcodebuild-notification.log',
502): string[] {
503 if (!report)
504 return [`the notification tap cold start never reported; see ${evidence}`]
505 if (report.onTap !== 'function')
506 return [`notifications.onTap is ${report.onTap}, so a page has no way to be handed a tap`]
507
508 const problems: string[] = []
509 if (report.unsubscribe !== 'function')
510 problems.push(`notifications.onTap returned ${report.unsubscribe}, expected a function that unsubscribes`)
511 if (report.dispatched === 0) {
512 problems.push('the app never dispatched the tap that launched it; native code dropped it before the page existed')
513 return problems
514 }
515
516 const ours = report.taps.filter(tap => tap.craftE2E === run)
517 if (ours.length !== 1 || report.taps.length !== 1)
518 problems.push(`a page that subscribed to notifications.onTap after launch was handed ${JSON.stringify(report.taps)}, expected the tap that launched the app once`)
519 else if (ours[0]!.route !== NOTIFICATION_ROUTE)
520 problems.push(`the tap reached the page without the push's route: it carried ${JSON.stringify(ours[0])}`)
521 return problems
522}
523
441524/** The need the Android share case announces before it opens the menu. */
442525export const DISMISS_SHARE_MENU = 'dismiss-share-menu'
443526