ReviewOS

also looking at this

craft-native/craft

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

#259
Merged glennmichael123 wants to merge feat/ios-notification-received into main
11 files +410 -58

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.

scripts/mobile-e2e/driver.htmlmodified+26-0
Changes to scripts/mobile-e2e/driver.html
@@ -17,6 +17,7 @@
1717 <p id="deeplink"></p>
1818 <p id="notification-permission"></p>
1919 <p id="notification"></p>
20 <p id="notification-received"></p>
2021 <script>
2122 // The page drives itself and reports through the bridge's own `log` action,
2223 // which is the one channel both runtimes already forward off-device: iOS
@@ -505,6 +506,10 @@
505506 // the suite, seconds after the bridge appeared, and reports what it was
506507 // handed. On any other launch it was handed nothing, and says so.
507508 //
509 // #256: with the app open, a second push arrives, and the page reports
510 // what onReceive handed it, and how many taps it had by then, since an
511 // arrival is not a tap.
512 //
508513 // Permission is asked for only when the link asks, because nothing
509514 // answers the prompt on the launches simctl makes. iOS only: Android has
510515 // no tap path to test yet.
@@ -520,6 +525,27 @@
520525 if (typeof onTap === 'function') {
521526 seen.unsubscribe = typeof window.craft.notifications.onTap(function (detail) { seen.taps.push(detail || {}) })
522527 }
528 var onReceive = window.craft.notifications && window.craft.notifications.onReceive
529 var arrivals = { received: [], taps: 0, onReceive: typeof onReceive, unsubscribe: null }
530 function reportArrivals() {
531 arrivals.taps = seen.taps.length
532 var line = 'CRAFT-E2E-NOTIFICATION-RECEIVED ' + JSON.stringify(arrivals)
533 document.getElementById('notification-received').textContent = line
534 try { console.log(line) } catch (ignored) {}
535 try { if (window.craft && window.craft.log) window.craft.log(line) } catch (ignored) {}
536 }
537 // Reported at once, and again once arrivals have settled, so a page
538 // that was never told still has a report that says so.
539 if (typeof onReceive === 'function') {
540 var reporting = null
541 arrivals.unsubscribe = typeof window.craft.notifications.onReceive(function (detail) {
542 arrivals.received.push(detail || {})
543 // After a pause, so a second delivery of the same push is counted.
544 clearTimeout(reporting)
545 reporting = setTimeout(reportArrivals, 1500)
546 })
547 }
548 reportArrivals()
523549 // Long enough for a replay, or a second delivery, to have arrived.
524550 setTimeout(function () {
525551 seen.dispatched = dispatchedTaps
scripts/mobile-e2e/ios-uitests/NotificationTapColdStartTests.swiftmodified+62-18
Changes to scripts/mobile-e2e/ios-uitests/NotificationTapColdStartTests.swift
@@ -1,13 +1,15 @@
11import XCTest
22
3/// Taps a pushed notification that launches the probe app, and reports what
4/// its page was handed (#255).
3/// Taps a pushed notification that launches the probe app, then has a second
4/// push arrive while it is open, and reports what its page was handed each
5/// time (#255, #256).
56///
67/// A person's sequence, end to end: the app asks for permission and is
78/// allowed, the app is killed, a real push arrives through `simctl push`, and
8/// the banner is tapped. `simctl` runs on the host, so the test prints
9/// `CRAFT-E2E-PUSH-READY` once the app is gone and the harness sends the push
10/// when it reads that line.
9/// the notification is tapped. With the app now in front, another push arrives.
10/// `simctl` runs on the host, so the test prints `CRAFT-E2E-PUSH-READY cold`
11/// once the app is gone and `CRAFT-E2E-PUSH-READY foreground` once the page
12/// is listening, and the harness sends each push when it reads the line.
1113///
1214/// As with the deep-link test, no assertions about the answer live here. The
1315/// page's report is printed and `scripts/mobile-e2e/protocol.ts` judges it.
@@ -17,7 +19,13 @@ final class NotificationTapColdStartTests: XCTestCase {
1719 private var permissionLink: String { environment["PROBE_NOTIFY_LINK"] ?? "" }
1820 private var pushBody: String { environment["PROBE_PUSH_BODY"] ?? "" }
1921
20 func testTapOnAPushLaunchesTheApp() throws {
22 override func setUp() {
23 // Each step stands on the one before it; carrying on past a failure
24 // would only bury it under the ones it causes.
25 continueAfterFailure = false
26 }
27
28 func testPushesReachThePage() throws {
2129 XCTAssertFalse(bundleId.isEmpty, "PROBE_BUNDLE_ID is not set")
2230 XCTAssertFalse(permissionLink.isEmpty, "PROBE_NOTIFY_LINK is not set")
2331 XCTAssertFalse(pushBody.isEmpty, "PROBE_PUSH_BODY is not set")
@@ -33,13 +41,18 @@ final class NotificationTapColdStartTests: XCTestCase {
3341 if open.waitForExistence(timeout: 5) { open.tap() }
3442 XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "the permission link did not start the app")
3543
44 // The prompt, or an answer without one. iOS can remember a grant for
45 // a bundle across its reinstall, so the previous leg's Allow may still
46 // stand and no prompt appears; the page's answer is what counts.
3647 let allow = springboard.alerts.buttons["Allow"]
37 XCTAssertTrue(allow.waitForExistence(timeout: 60), "the page never asked for notification permission")
38 allow.tap()
3948 let permission = app.staticTexts
4049 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFY-PERMISSION '"))
4150 .firstMatch
42 XCTAssertTrue(permission.waitForExistence(timeout: 30), "the page never said how the permission request ended")
51 let deadline = Date().addingTimeInterval(90)
52 while !permission.exists && Date() < deadline {
53 if allow.exists { allow.tap() } else { _ = permission.waitForExistence(timeout: 1) }
54 }
55 XCTAssertTrue(permission.exists, "the page never said how the notification permission request ended")
4356 XCTAssertEqual(permission.label, "CRAFT-E2E-NOTIFY-PERMISSION granted")
4457
4558 // Killed, so the tap below is what starts it. A running app would be
@@ -47,21 +60,52 @@ final class NotificationTapColdStartTests: XCTestCase {
4760 // case that always worked.
4861 app.terminate()
4962 XCTAssertTrue(app.wait(for: .notRunning, timeout: 15), "the app did not stop, so this would not be a cold start")
50 print("CRAFT-E2E-PUSH-READY")
63 tapColdPush(springboard, app: app)
64
65 let report = app.staticTexts
66 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION '"))
67 .firstMatch
68 XCTAssertTrue(report.waitForExistence(timeout: 120), "the page never reported what it was handed")
69 print("CRAFT-E2E-NOTIFICATION-RESULT \(report.label)")
70
71 // The app is in front and the page has subscribed, since it reported
72 // above. A push now is one it can only hear about through onReceive.
73 // The page's receipt report is there from the moment it subscribed,
74 // and rewritten once an arrival has settled. Whatever it says after a
75 // generous wait is printed, including nothing arriving at all, so the
76 // harness can say which it was rather than only that time ran out.
77 print("CRAFT-E2E-PUSH-READY foreground")
78 let receipts = app.staticTexts
79 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION-RECEIVED '"))
80 .firstMatch
81 XCTAssertTrue(receipts.waitForExistence(timeout: 30), "the page never reported what onReceive handed it")
82 let arrived = app.staticTexts
83 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION-RECEIVED ' AND label CONTAINS %@", "\"received\":[{"))
84 .firstMatch
85 _ = arrived.waitForExistence(timeout: 30)
86 print("CRAFT-E2E-NOTIFICATION-RECEIVED-RESULT \(receipts.label)")
87 }
5188
52 // The banner, found by this run's body so a notification left over
53 // from an earlier run cannot be the one tapped.
89 /// Asks the host for the cold push and taps its banner.
90 ///
91 /// The banner, not Notification Center: on iOS 26 Notification Center is
92 /// pulled down as the Lock Screen, and a tap there only offers Open. The
93 /// banner is found by this run's text, so a notification left over from
94 /// an earlier run cannot be the one tapped.
95 ///
96 /// iOS takes a banner down after a few seconds, and a tap first waits for
97 /// SpringBoard to go idle and then looks the banner up again, about a
98 /// second on CI. On a machine at a load average of 16 that was once long
99 /// enough for the banner to go, and the test fails saying the tap could
100 /// not find it. There is no retrying around that: a tap that misses ends
101 /// the test method, even inside XCTExpectFailure.
102 private func tapColdPush(_ springboard: XCUIApplication, app: XCUIApplication) {
54103 let banner = springboard.descendants(matching: .any)
55104 .matching(NSPredicate(format: "label CONTAINS %@", pushBody))
56105 .firstMatch
106 print("CRAFT-E2E-PUSH-READY cold")
57107 XCTAssertTrue(banner.waitForExistence(timeout: 120), "no banner for the push ever appeared")
58108 banner.tap()
59109 XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "tapping the banner did not start the app")
60
61 let report = app.staticTexts
62 .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFICATION '"))
63 .firstMatch
64 XCTAssertTrue(report.waitForExistence(timeout: 120), "the page never reported what it was handed")
65 print("CRAFT-E2E-NOTIFICATION-RESULT \(report.label)")
66110 }
67111}
scripts/mobile-e2e/ios.tsmodified+46-27
Changes to scripts/mobile-e2e/ios.ts
@@ -1,8 +1,9 @@
1import type { PushStage } from './protocol'
12import type { LegOutcome, RunnerOptions } from './types'
23import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
34import { join } from 'node:path'
45import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index'
5import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationPayload, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
6import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationBody, notificationPayload, notificationReceiptProblems, notificationReceiptReport, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
67import { command, driverPage, waitForFile } from './support'
78
89/** The scheme the probe registers, and the one its cold-start link uses. */
@@ -66,14 +67,19 @@ const UI_TESTS = `${APP_NAME}UITests`
6667const UI_TEST_CLASSES = ['DeepLinkColdStartTests', 'NotificationTapColdStartTests'] as const
6768
6869/**
69 * How long the notification UI test gets to have the app killed and ready for
70 * a push, and then to tap it. It starts a test runner, launches the app twice
71 * and answers a permission prompt, so it is given more than one launch.
70 * How long the notification UI test gets in all. It starts a test runner,
71 * launches the app twice and answers a permission prompt; it usually takes
72 * under half a minute.
7273 */
7374const NOTIFICATION_TEST_TIMEOUT_MS = 300_000
7475
75/** The line the notification UI test prints once the app is gone. */
76const PUSH_READY = /^CRAFT-E2E-PUSH-READY\s*$/m
76/**
77 * The pushes the notification UI test has asked for, in order, one per
78 * `CRAFT-E2E-PUSH-READY <stage>` line.
79 */
80function pushesAskedFor(output: string): PushStage[] {
81 return [...output.matchAll(/^CRAFT-E2E-PUSH-READY (cold|foreground)\s*$/gm)].map(match => match[1] as PushStage)
82}
7783
7884/**
7985 * One leg is one answer to "who served the call".
@@ -316,44 +322,57 @@ schemes:
316322 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
317323 failures.push(...deepLinkProblems(deepLinkResults(`${coldStart.stdout}\n${coldStart.stderr}`), link))
318324
319 // #255: a tap on a push that launches the app, judged by what the page was
320 // handed. The UI test allows notifications, kills the app and prints
321 // CRAFT-E2E-PUSH-READY; `simctl push` runs on the host, so the push is sent
322 // from here when that line appears, and the test taps the banner.
325 // #255 and #256: a tap on a push that launches the app, then a push that
326 // arrives while it is open, each judged by what the page was handed. The UI
327 // test prints CRAFT-E2E-PUSH-READY <stage> when it is ready for each one:
328 // once it has allowed notifications and killed the app, and once the page
329 // it tapped open is listening. `simctl push` runs on the host, so each push
330 // is sent from here when its line appears.
323331 //
324332 // Spawned rather than run through `command`, which returns only once the
325 // process has exited, and this one waits on the push.
326 const payloadPath = join(evidence, 'notification.apns')
327 writeFileSync(payloadPath, notificationPayload(nonce))
333 // process has exited, and this one waits on the pushes.
328334 const notificationLog = join(evidence, 'xcodebuild-notification.log')
335 const notificationEvidence = `${label}/xcodebuild-notification.log`
329336 const tapping = Bun.spawn(uiTest('NotificationTapColdStartTests'), {
330337 cwd: project,
331338 env: {
332339 ...process.env,
333340 TEST_RUNNER_PROBE_BUNDLE_ID: BUNDLE_ID,
334341 TEST_RUNNER_PROBE_NOTIFY_LINK: `${DEEP_LINK_SCHEME}://e2e/notify?run=${encodeURIComponent(nonce)}`,
335 TEST_RUNNER_PROBE_PUSH_BODY: nonce,
342 TEST_RUNNER_PROBE_PUSH_BODY: notificationBody(nonce, 'cold'),
336343 },
337344 stdin: 'ignore',
338345 stdout: Bun.file(notificationLog),
339346 stderr: Bun.file(join(evidence, 'xcodebuild-notification-stderr.log')),
340347 })
341 const readyForPush = await waitForFile(
342 notificationLog,
343 NOTIFICATION_TEST_TIMEOUT_MS,
344 text => PUSH_READY.test(text) || tapping.exitCode !== null,
345 ) && PUSH_READY.test(readFileSync(notificationLog, 'utf8'))
346 if (readyForPush)
347 await command(['xcrun', 'simctl', 'push', device.udid, BUNDLE_ID, payloadPath], { logPath: join(evidence, 'simctl.log') })
348 const timer = setTimeout(() => tapping.kill(), NOTIFICATION_TEST_TIMEOUT_MS)
348 // One push per line asked for, sent as each line appears, until the test
349 // exits or runs out of time.
350 const pushed: PushStage[] = []
351 const deadline = Date.now() + NOTIFICATION_TEST_TIMEOUT_MS
352 while (tapping.exitCode === null && Date.now() < deadline) {
353 for (const stage of pushesAskedFor(readFileSync(notificationLog, 'utf8')).slice(pushed.length)) {
354 const payloadPath = join(evidence, `notification-${stage}.apns`)
355 writeFileSync(payloadPath, notificationPayload(nonce, stage))
356 await command(['xcrun', 'simctl', 'push', device.udid, BUNDLE_ID, payloadPath], { logPath: join(evidence, 'simctl.log') })
357 pushed.push(stage)
358 }
359 await Bun.sleep(250)
360 }
361 if (tapping.exitCode === null) tapping.kill()
349362 await tapping.exited
350 clearTimeout(timer)
351363 await command(['xcrun', 'simctl', 'io', device.udid, 'screenshot', join(evidence, 'notification-screen.png')], { allowFailure: true })
352364 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
353 if (!readyForPush)
354 failures.push(`the notification UI test never had the app killed and ready for a push; see ${label}/xcodebuild-notification.log`)
355 else
356 failures.push(...notificationTapProblems(notificationTapReport(readFileSync(notificationLog, 'utf8')), nonce, `${label}/xcodebuild-notification.log`))
365 const notificationOutput = readFileSync(notificationLog, 'utf8')
366 if (!pushed.includes('cold')) {
367 failures.push(`the notification UI test never had the app killed and ready for a push; see ${notificationEvidence}`)
368 }
369 else {
370 failures.push(...notificationTapProblems(notificationTapReport(notificationOutput), nonce, notificationEvidence))
371 if (!pushed.includes('foreground'))
372 failures.push(`the notification UI test never got to the push that arrives while the app is open; see ${notificationEvidence}`)
373 else
374 failures.push(...notificationReceiptProblems(notificationReceiptReport(notificationOutput), nonce, notificationEvidence))
375 }
357376
358377 const dispatched = zigDispatchedActions(consoleText)
359378 const refused = zigRefusals(consoleText)
scripts/mobile-e2e/protocol.test.tsmodified+52-7
Changes to scripts/mobile-e2e/protocol.test.ts
@@ -1,7 +1,7 @@
11import { describe, expect, it } from 'bun:test'
22import { readFileSync } from 'node:fs'
33import { join } from 'node:path'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, NOTIFICATION_ROUTE, notificationPayload, notificationTapProblems, notificationTapReport, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
4import { ANDROID_DECLINE_PHRASES, androidDeclines, awaitedNeeds, deepLinkProblems, deepLinkReports, deepLinkResults, DISMISS_SHARE_MENU, elfSectionNames, evaluateRun, hasTerminated, NOTIFICATION_ROUTE, notificationBody, notificationPayload, notificationReceiptProblems, notificationReceiptReport, notificationTapProblems, notificationTapReport, parseDriverOutput, REQUIRED_CASES, requiredCaseProblems, runtimePermissionGranted, shareMenuInFront, strippedLibraryProblems, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
55
66const ESC = String.fromCharCode(27)
77
@@ -422,12 +422,57 @@ describe('notification tap cold start', () => {
422422 .toEqual(['the notification tap cold start never reported; see ios-shim/xcodebuild-notification.log'])
423423 })
424424
425 it('sends a push simctl accepts, whose body is the run the banner is found by', () => {
426 const payload = notificationPayload(RUN)
427 expect(new TextEncoder().encode(payload).length).toBeLessThanOrEqual(4096)
428 const parsed = JSON.parse(payload)
429 expect(parsed.aps.alert.body).toBe(RUN)
430 expect(parsed).toMatchObject({ craftE2E: RUN, route: NOTIFICATION_ROUTE })
425 it('sends pushes simctl accepts, each with the banner text its stage is found by', () => {
426 for (const stage of ['cold', 'foreground'] as const) {
427 const payload = notificationPayload(RUN, stage)
428 expect(new TextEncoder().encode(payload).length).toBeLessThanOrEqual(4096)
429 const parsed = JSON.parse(payload)
430 expect(parsed.aps.alert.body).toBe(notificationBody(RUN, stage))
431 expect(parsed).toMatchObject({ craftE2E: RUN, stage, route: NOTIFICATION_ROUTE })
432 }
433 // The cold banner is searched for by its text; the other stage's must not match it.
434 expect(notificationBody(RUN, 'foreground')).not.toContain(notificationBody(RUN, 'cold'))
435 })
436})
437
438describe('notification that arrives while the app is open', () => {
439 const RUN = 'craft-e2e-ios-shim-1'
440 const arrival = { aps: { alert: { title: 'Craft E2E', body: `${RUN} foreground` } }, craftE2E: RUN, stage: 'foreground', route: NOTIFICATION_ROUTE }
441 const judge = (report: object) => notificationReceiptProblems(
442 notificationReceiptReport(`CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION {"taps":[]}\nCRAFT-E2E-NOTIFICATION-RECEIVED-RESULT CRAFT-E2E-NOTIFICATION-RECEIVED ${JSON.stringify(report)}`),
443 RUN,
444 )
445
446 it('passes a page handed the push once through onReceive, and not as a tap', () => {
447 expect(judge({ received: [arrival], taps: 1, onReceive: 'function', unsubscribe: 'function' })).toEqual([])
448 })
449
450 // The bug: willPresent showed the banner and told the page nothing.
451 it('fails a page that was never told', () => {
452 expect(judge({ received: [], taps: 1, onReceive: 'function', unsubscribe: 'function' }))
453 .toEqual(['a page that was open when a push arrived was handed [] by onReceive, expected that push once'])
454 })
455
456 it('fails the cold push reported as an arrival, a duplicate, or one that lost its route', () => {
457 const cold = { ...arrival, stage: 'cold' }
458 expect(judge({ received: [cold, arrival], taps: 1, onReceive: 'function', unsubscribe: 'function' })[0]).toContain('expected that push once')
459 expect(judge({ received: [arrival, arrival], taps: 1, onReceive: 'function', unsubscribe: 'function' })[0]).toContain('expected that push once')
460 expect(judge({ received: [{ ...arrival, route: undefined }], taps: 1, onReceive: 'function', unsubscribe: 'function' })[0]).toContain('without its route')
461 })
462
463 it('fails an arrival handed to onTap as well', () => {
464 expect(judge({ received: [arrival], taps: 2, onReceive: 'function', unsubscribe: 'function' }))
465 .toEqual(['the push that arrived while the app was open was handed to onTap as well; onTap had 2 taps'])
466 })
467
468 it('fails a bridge with no onReceive, or one that returns no way to unsubscribe, and names a run that never reported', () => {
469 expect(judge({ received: [], taps: 1, onReceive: 'undefined', unsubscribe: null }))
470 .toEqual(['notifications.onReceive is undefined, so a page cannot hear about a push that arrives while it is open'])
471 expect(judge({ received: [arrival], taps: 1, onReceive: 'function', unsubscribe: 'undefined' }))
472 .toEqual(['notifications.onReceive returned undefined, expected a function that unsubscribes'])
473 // The tap's report line is not a receipt report.
474 expect(notificationReceiptProblems(notificationReceiptReport('CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION {"taps":[]}'), RUN))
475 .toEqual(['the push that arrived while the app was open never reported; see xcodebuild-notification.log'])
431476 })
432477})
433478
scripts/mobile-e2e/protocol.tsmodified+78-5
Changes to scripts/mobile-e2e/protocol.ts
@@ -447,13 +447,26 @@ export function deepLinkProblems(
447447export const NOTIFICATION_ROUTE = '/e2e/notification-tap'
448448
449449/**
450 * The push the harness sends with `simctl push` once the app is killed.
450 * The two pushes the notification UI test asks for: `cold` once the app is
451 * killed, for its banner to be tapped (#255), and `foreground` once the app
452 * that tap launched is open (#256).
453 */
454export type PushStage = 'cold' | 'foreground'
455
456/** The banner text of a stage's push, which the UI test finds the banner by. */
457export function notificationBody(run: string, stage: PushStage): string {
458 return `${run} ${stage}`
459}
460
461/**
462 * The push the harness sends with `simctl push` for one stage.
451463 *
452 * The body is the run's nonce, which the UI test finds the banner by, and
453 * `craftE2E` carries it again as data, for the page to hand back.
464 * `craftE2E` and `stage` carry the run and the stage again as data, for the
465 * page to hand back, so a push from another run or the other stage cannot
466 * pass for this one.
454467 */
455export function notificationPayload(run: string): string {
456 return `${JSON.stringify({ aps: { alert: { title: 'Craft E2E', body: run } }, craftE2E: run, route: NOTIFICATION_ROUTE })}\n`
468export function notificationPayload(run: string, stage: PushStage): string {
469 return `${JSON.stringify({ aps: { alert: { title: 'Craft E2E', body: notificationBody(run, stage) } }, craftE2E: run, stage, route: NOTIFICATION_ROUTE })}\n`
457470}
458471
459472/** What a page that subscribed late was handed after a tap launched the app. */
@@ -521,6 +534,66 @@ export function notificationTapProblems(
521534 return problems
522535}
523536
537/** What a page that was open when a push arrived was handed by onReceive. */
538export interface NotificationReceiptReport {
539 received: Record<string, unknown>[]
540 /** How many taps onTap had handed the page when it reported. */
541 taps: number
542 /** `typeof` notifications.onReceive. */
543 onReceive: string
544 /** `typeof` what onReceive returned, or null when there was no onReceive to call. */
545 unsubscribe: string | null
546}
547
548/**
549 * The receipt report the UI test printed, as a
550 * `CRAFT-E2E-NOTIFICATION-RECEIVED-RESULT CRAFT-E2E-NOTIFICATION-RECEIVED {json}`
551 * line. Null when there is none, or it does not parse.
552 */
553export function notificationReceiptReport(output: string): NotificationReceiptReport | null {
554 for (const line of output.split('\n')) {
555 const match = line.match(/CRAFT-E2E-NOTIFICATION-RECEIVED-RESULT CRAFT-E2E-NOTIFICATION-RECEIVED (\{.*\})\s*$/)
556 if (!match) continue
557 try {
558 const report = JSON.parse(match[1]!) as NotificationReceiptReport
559 if (Array.isArray(report.received)) return report
560 }
561 catch {}
562 }
563 return null
564}
565
566/**
567 * Why the push that arrived while the app was open is not a pass (#256).
568 *
569 * The page must be handed that push once, through onReceive, with its data
570 * intact. Not the cold push, which arrived while the app was not running and
571 * was the tap's to deliver, and not as a tap: an arrival is not a tap, and a
572 * page routing on onTap would jump to a screen nobody asked for.
573 */
574export function notificationReceiptProblems(
575 report: NotificationReceiptReport | null,
576 run: string,
577 evidence = 'xcodebuild-notification.log',
578): string[] {
579 if (!report)
580 return [`the push that arrived while the app was open never reported; see ${evidence}`]
581 if (report.onReceive !== 'function')
582 return [`notifications.onReceive is ${report.onReceive}, so a page cannot hear about a push that arrives while it is open`]
583
584 const problems: string[] = []
585 if (report.unsubscribe !== 'function')
586 problems.push(`notifications.onReceive returned ${report.unsubscribe}, expected a function that unsubscribes`)
587 const ours = report.received.filter(entry => entry.craftE2E === run && entry.stage === 'foreground')
588 if (ours.length !== 1 || report.received.length !== 1)
589 problems.push(`a page that was open when a push arrived was handed ${JSON.stringify(report.received)} by onReceive, expected that push once`)
590 else if (ours[0]!.route !== NOTIFICATION_ROUTE)
591 problems.push(`the push reached onReceive without its route: it carried ${JSON.stringify(ours[0])}`)
592 if (report.taps > 1)
593 problems.push(`the push that arrived while the app was open was handed to onTap as well; onTap had ${report.taps} taps`)
594 return problems
595}
596
524597/** The need the Android share case announces before it opens the menu. */
525598export const DISMISS_SHARE_MENU = 'dismiss-share-menu'
526599