also looking at this
feat(ios): tell the page about a notification that arrives while the app is open
#259
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.
| @@ -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 | |