also looking at this
test(ios): tap a real pushed notification that launches the app, and judge what the page gets
#257
5 files
+308
-10
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.
| @@ -15,6 +15,8 @@ | ||
| 15 | 15 | <h1 id="headline">Craft mobile E2E</h1> |
| 16 | 16 | <ul id="results"></ul> |
| 17 | 17 | <p id="deeplink"></p> |
| 18 | <p id="notification-permission"></p> | |
| 19 | <p id="notification"></p> | |
| 18 | 20 | <script> |
| 19 | 21 | // The page drives itself and reports through the bridge's own `log` action, |
| 20 | 22 | // which is the one channel both runtimes already forward off-device: iOS |
| @@ -36,6 +38,13 @@ | ||
| 36 | 38 | var dispatchedLinks = [] |
| 37 | 39 | window.addEventListener('craftDeepLink', function (event) { dispatchedLinks.push(event.detail || {}) }) |
| 38 | 40 | |
| 41 | // Every notification tap the native side dispatches, counted the same way, | |
| 42 | // so a missing tap can be put on the right side of the bridge: none here | |
| 43 | // means the app never handed it over, one here and none through onTap | |
| 44 | // means the page's replay lost it. | |
| 45 | var dispatchedTaps = 0 | |
| 46 | window.addEventListener('craftNotificationResponse', function () { dispatchedTaps++ }) | |
| 47 | ||
| 39 | 48 | var NONCE = '__CRAFT_E2E_NONCE__' |
| 40 | 49 | // Which platform the harness built this leg for. Substituted in from |
| 41 | 50 | // outside, because the page comparing window.craft.platform against a |
| @@ -487,6 +496,40 @@ | ||
| 487 | 496 | setTimeout(report, 250) |
| 488 | 497 | } |
| 489 | 498 | |
| 499 | // #255: a tap on a push that launches the app reaches a page that | |
| 500 | // subscribes late, through notifications.onTap. | |
| 501 | // | |
| 502 | // The harness does on iOS what a person does, from an XCUITest: it opens | |
| 503 | // the permission link and allows the prompt, kills the app, sends a real | |
| 504 | // push with `simctl push`, and taps the banner. The page subscribes after | |
| 505 | // the suite, seconds after the bridge appeared, and reports what it was | |
| 506 | // handed. On any other launch it was handed nothing, and says so. | |
| 507 | // | |
| 508 | // Permission is asked for only when the link asks, because nothing | |
| 509 | // answers the prompt on the launches simctl makes. iOS only: Android has | |
| 510 | // no tap path to test yet. | |
| 511 | function probeNotifications() { | |
| 512 | if (window.craft.platform !== 'ios') return | |
| 513 | var launch = dispatchedLinks.filter(function (detail) { return detail.initial === true })[0] || null | |
| 514 | if (launch && launch.url.indexOf('://e2e/notify') !== -1) { | |
| 515 | var said = function (text) { document.getElementById('notification-permission').textContent = 'CRAFT-E2E-NOTIFY-PERMISSION ' + text } | |
| 516 | window.craft.permissions.request('notifications').then(said, function (error) { said('rejected ' + describeError(error).message) }) | |
| 517 | } | |
| 518 | var onTap = window.craft.notifications && window.craft.notifications.onTap | |
| 519 | var seen = { dispatched: 0, taps: [], onTap: typeof onTap, unsubscribe: null } | |
| 520 | if (typeof onTap === 'function') { | |
| 521 | seen.unsubscribe = typeof window.craft.notifications.onTap(function (detail) { seen.taps.push(detail || {}) }) | |
| 522 | } | |
| 523 | // Long enough for a replay, or a second delivery, to have arrived. | |
| 524 | setTimeout(function () { | |
| 525 | seen.dispatched = dispatchedTaps | |
| 526 | var line = 'CRAFT-E2E-NOTIFICATION ' + JSON.stringify(seen) | |
| 527 | document.getElementById('notification').textContent = line | |
| 528 | try { console.log(line) } catch (ignored) {} | |
| 529 | try { if (window.craft && window.craft.log) window.craft.log(line) } catch (ignored) {} | |
| 530 | }, 1500) | |
| 531 | } | |
| 532 | ||
| 490 | 533 | function waitForBridge(attempt) { |
| 491 | 534 | if (window.craft && window.craft.getDeviceInfo) return Promise.resolve(true) |
| 492 | 535 | if (attempt >= 150) return Promise.resolve(false) |
| @@ -517,6 +560,7 @@ | ||
| 517 | 560 | emit({ event: 'done', passed: passed, failed: failed }) |
| 518 | 561 | document.getElementById('headline').textContent = failed ? 'FAILED' : 'PASSED' |
| 519 | 562 | probeDeepLinks() |
| 563 | probeNotifications() | |
| 520 | 564 | return |
| 521 | 565 | } |
| 522 | 566 | |
| @@ -0,0 +1,67 @@ | ||
| 1 | import XCTest | |
| 2 | ||
| 3 | /// Taps a pushed notification that launches the probe app, and reports what | |
| 4 | /// its page was handed (#255). | |
| 5 | /// | |
| 6 | /// A person's sequence, end to end: the app asks for permission and is | |
| 7 | /// 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. | |
| 11 | /// | |
| 12 | /// As with the deep-link test, no assertions about the answer live here. The | |
| 13 | /// page's report is printed and `scripts/mobile-e2e/protocol.ts` judges it. | |
| 14 | final class NotificationTapColdStartTests: XCTestCase { | |
| 15 | private let environment = ProcessInfo.processInfo.environment | |
| 16 | private var bundleId: String { environment["PROBE_BUNDLE_ID"] ?? "" } | |
| 17 | private var permissionLink: String { environment["PROBE_NOTIFY_LINK"] ?? "" } | |
| 18 | private var pushBody: String { environment["PROBE_PUSH_BODY"] ?? "" } | |
| 19 | ||
| 20 | func testTapOnAPushLaunchesTheApp() throws { | |
| 21 | XCTAssertFalse(bundleId.isEmpty, "PROBE_BUNDLE_ID is not set") | |
| 22 | XCTAssertFalse(permissionLink.isEmpty, "PROBE_NOTIFY_LINK is not set") | |
| 23 | XCTAssertFalse(pushBody.isEmpty, "PROBE_PUSH_BODY is not set") | |
| 24 | ||
| 25 | let app = XCUIApplication(bundleIdentifier: bundleId) | |
| 26 | let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") | |
| 27 | ||
| 28 | // Permission, answered the way a person answers it. Without it iOS | |
| 29 | // accepts the push and shows nothing, so there is no banner to tap. | |
| 30 | app.terminate() | |
| 31 | XCUIDevice.shared.system.open(URL(string: permissionLink)!) | |
| 32 | let open = springboard.buttons["Open"] | |
| 33 | if open.waitForExistence(timeout: 5) { open.tap() } | |
| 34 | XCTAssertTrue(app.wait(for: .runningForeground, timeout: 60), "the permission link did not start the app") | |
| 35 | ||
| 36 | let allow = springboard.alerts.buttons["Allow"] | |
| 37 | XCTAssertTrue(allow.waitForExistence(timeout: 60), "the page never asked for notification permission") | |
| 38 | allow.tap() | |
| 39 | let permission = app.staticTexts | |
| 40 | .containing(NSPredicate(format: "label BEGINSWITH 'CRAFT-E2E-NOTIFY-PERMISSION '")) | |
| 41 | .firstMatch | |
| 42 | XCTAssertTrue(permission.waitForExistence(timeout: 30), "the page never said how the permission request ended") | |
| 43 | XCTAssertEqual(permission.label, "CRAFT-E2E-NOTIFY-PERMISSION granted") | |
| 44 | ||
| 45 | // Killed, so the tap below is what starts it. A running app would be | |
| 46 | // handed the tap by a Coordinator that already exists, which is the | |
| 47 | // case that always worked. | |
| 48 | app.terminate() | |
| 49 | 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") | |
| 51 | ||
| 52 | // The banner, found by this run's body so a notification left over | |
| 53 | // from an earlier run cannot be the one tapped. | |
| 54 | let banner = springboard.descendants(matching: .any) | |
| 55 | .matching(NSPredicate(format: "label CONTAINS %@", pushBody)) | |
| 56 | .firstMatch | |
| 57 | XCTAssertTrue(banner.waitForExistence(timeout: 120), "no banner for the push ever appeared") | |
| 58 | banner.tap() | |
| 59 | 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 | } | |
| 67 | } | |
| @@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types' | ||
| 2 | 2 | import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs' |
| 3 | 3 | import { join } from 'node:path' |
| 4 | 4 | import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index' |
| 5 | import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 5 | import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationPayload, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol' | |
| 6 | 6 | import { command, driverPage, waitForFile } from './support' |
| 7 | 7 | |
| 8 | 8 | /** The scheme the probe registers, and the one its cold-start link uses. */ |
| @@ -56,12 +56,25 @@ const APP_NAME = 'CraftE2EProbe' | ||
| 56 | 56 | * The UI test target the harness adds to the generated project. |
| 57 | 57 | * |
| 58 | 58 | * Added here rather than by the generator, because no app wants it: it exists |
| 59 | * to cold-start the probe through a link, which is the one thing neither | |
| 60 | * `simctl launch` nor `simctl openurl` can do unattended. See | |
| 61 | * `ios-uitests/DeepLinkColdStartTests.swift` for why. | |
| 59 | * to cold-start the probe through a link, and through a tap on a pushed | |
| 60 | * notification, which neither `simctl launch` nor `simctl openurl` can do | |
| 61 | * unattended. See `ios-uitests/` for why. | |
| 62 | 62 | */ |
| 63 | 63 | const UI_TESTS = `${APP_NAME}UITests` |
| 64 | 64 | |
| 65 | /** The UI test classes, each run on its own with `-only-testing`. */ | |
| 66 | const UI_TEST_CLASSES = ['DeepLinkColdStartTests', 'NotificationTapColdStartTests'] as const | |
| 67 | ||
| 68 | /** | |
| 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. | |
| 72 | */ | |
| 73 | const NOTIFICATION_TEST_TIMEOUT_MS = 300_000 | |
| 74 | ||
| 75 | /** The line the notification UI test prints once the app is gone. */ | |
| 76 | const PUSH_READY = /^CRAFT-E2E-PUSH-READY\s*$/m | |
| 77 | ||
| 65 | 78 | /** |
| 66 | 79 | * One leg is one answer to "who served the call". |
| 67 | 80 | * |
| @@ -114,9 +127,10 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> { | ||
| 114 | 127 | if (!leg.requireZigDispatch && linksRuntime) |
| 115 | 128 | failures.push(`the ${leg.name} leg was supposed to have no runtime but its project links one`) |
| 116 | 129 | |
| 117 | // The cold-start UI test, as a target in the generated project. | |
| 130 | // The cold-start UI tests, as a target in the generated project. | |
| 118 | 131 | mkdirSync(join(project, 'UITests'), { recursive: true }) |
| 119 | copyFileSync(join(import.meta.dir, 'ios-uitests', 'DeepLinkColdStartTests.swift'), join(project, 'UITests', 'DeepLinkColdStartTests.swift')) | |
| 132 | for (const testClass of UI_TEST_CLASSES) | |
| 133 | copyFileSync(join(import.meta.dir, 'ios-uitests', `${testClass}.swift`), join(project, 'UITests', `${testClass}.swift`)) | |
| 120 | 134 | writeFileSync(join(project, 'project.yml'), `${projectYml.trimEnd()} |
| 121 | 135 | ${UI_TESTS}: |
| 122 | 136 | type: bundle.ui-testing |
| @@ -279,16 +293,18 @@ schemes: | ||
| 279 | 293 | // #198: cold-start the app through a link, twice, and judge what the page |
| 280 | 294 | // received. After the suite and its evidence, because the UI test |
| 281 | 295 | // terminates and relaunches the app. |
| 282 | const link = `${DEEP_LINK_SCHEME}://e2e/cold?run=${encodeURIComponent(nonce)}` | |
| 283 | const coldStart = await command([ | |
| 296 | const uiTest = (testClass: typeof UI_TEST_CLASSES[number]) => [ | |
| 284 | 297 | 'xcodebuild', |
| 285 | 298 | '-project', `${APP_NAME}.xcodeproj`, |
| 286 | 299 | '-scheme', APP_NAME, |
| 287 | 300 | '-destination', `id=${device.udid}`, |
| 288 | 301 | '-derivedDataPath', derived, |
| 302 | `-only-testing:${UI_TESTS}/${testClass}`, | |
| 289 | 303 | 'CODE_SIGNING_ALLOWED=NO', |
| 290 | 304 | 'test-without-building', |
| 291 | ], { | |
| 305 | ] | |
| 306 | const link = `${DEEP_LINK_SCHEME}://e2e/cold?run=${encodeURIComponent(nonce)}` | |
| 307 | const coldStart = await command(uiTest('DeepLinkColdStartTests'), { | |
| 292 | 308 | cwd: project, |
| 293 | 309 | // xcodebuild hands TEST_RUNNER_-prefixed variables to the runner without |
| 294 | 310 | // the prefix. |
| @@ -300,6 +316,45 @@ schemes: | ||
| 300 | 316 | await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true }) |
| 301 | 317 | failures.push(...deepLinkProblems(deepLinkResults(`${coldStart.stdout}\n${coldStart.stderr}`), link)) |
| 302 | 318 | |
| 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. | |
| 323 | // | |
| 324 | // 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)) | |
| 328 | const notificationLog = join(evidence, 'xcodebuild-notification.log') | |
| 329 | const tapping = Bun.spawn(uiTest('NotificationTapColdStartTests'), { | |
| 330 | cwd: project, | |
| 331 | env: { | |
| 332 | ...process.env, | |
| 333 | TEST_RUNNER_PROBE_BUNDLE_ID: BUNDLE_ID, | |
| 334 | TEST_RUNNER_PROBE_NOTIFY_LINK: `${DEEP_LINK_SCHEME}://e2e/notify?run=${encodeURIComponent(nonce)}`, | |
| 335 | TEST_RUNNER_PROBE_PUSH_BODY: nonce, | |
| 336 | }, | |
| 337 | stdin: 'ignore', | |
| 338 | stdout: Bun.file(notificationLog), | |
| 339 | stderr: Bun.file(join(evidence, 'xcodebuild-notification-stderr.log')), | |
| 340 | }) | |
| 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) | |
| 349 | await tapping.exited | |
| 350 | clearTimeout(timer) | |
| 351 | await command(['xcrun', 'simctl', 'io', device.udid, 'screenshot', join(evidence, 'notification-screen.png')], { allowFailure: true }) | |
| 352 | 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`)) | |
| 357 | ||
| 303 | 358 | const dispatched = zigDispatchedActions(consoleText) |
| 304 | 359 | const refused = zigRefusals(consoleText) |
| 305 | 360 | const handedBack = zigHandBacks(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, 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, notificationPayload, 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 | |
| @@ -382,6 +382,55 @@ describe('cold-start deep links', () => { | ||
| 382 | 382 | }) |
| 383 | 383 | }) |
| 384 | 384 | |
| 385 | describe('notification tap cold start', () => { | |
| 386 | const RUN = 'craft-e2e-ios-shim-1' | |
| 387 | const line = (report: object) => `CRAFT-E2E-NOTIFICATION-RESULT CRAFT-E2E-NOTIFICATION ${JSON.stringify(report)}` | |
| 388 | const tap = { aps: { alert: { title: 'Craft E2E', body: RUN } }, craftE2E: RUN, route: NOTIFICATION_ROUTE } | |
| 389 | const judge = (report: object) => notificationTapProblems(notificationTapReport(`Test Case started.\n${line(report)}\n** TEST SUCCEEDED **`), RUN) | |
| 390 | ||
| 391 | it('passes a late subscriber handed the tap that launched the app, once', () => { | |
| 392 | expect(judge({ dispatched: 1, taps: [tap], onTap: 'function', unsubscribe: 'function' })).toEqual([]) | |
| 393 | }) | |
| 394 | ||
| 395 | // The two halves of #255, told apart. | |
| 396 | it('blames native code when the app never dispatched the tap', () => { | |
| 397 | expect(judge({ dispatched: 0, taps: [], onTap: 'function', unsubscribe: 'function' })) | |
| 398 | .toEqual(['the app never dispatched the tap that launched it; native code dropped it before the page existed']) | |
| 399 | }) | |
| 400 | ||
| 401 | it('blames the page when the tap was dispatched before anyone subscribed', () => { | |
| 402 | const problems = judge({ dispatched: 1, taps: [], onTap: 'function', unsubscribe: 'function' }) | |
| 403 | expect(problems).toHaveLength(1) | |
| 404 | expect(problems[0]).toContain('subscribed to notifications.onTap after launch was handed []') | |
| 405 | }) | |
| 406 | ||
| 407 | it('fails a tap handed over twice, one from another run, or one that lost its route', () => { | |
| 408 | expect(judge({ dispatched: 1, taps: [tap, tap], onTap: 'function', unsubscribe: 'function' })[0]).toContain('expected the tap that launched the app once') | |
| 409 | expect(judge({ dispatched: 1, taps: [{ ...tap, craftE2E: 'an-earlier-run' }], onTap: 'function', unsubscribe: 'function' })[0]).toContain('expected the tap that launched the app once') | |
| 410 | expect(judge({ dispatched: 1, taps: [{ ...tap, route: undefined }], onTap: 'function', unsubscribe: 'function' })[0]).toContain('without the push\'s route') | |
| 411 | }) | |
| 412 | ||
| 413 | it('fails a bridge with no onTap, or one that returns no way to unsubscribe', () => { | |
| 414 | expect(judge({ dispatched: 1, taps: [], onTap: 'undefined', unsubscribe: null })) | |
| 415 | .toEqual(['notifications.onTap is undefined, so a page has no way to be handed a tap']) | |
| 416 | expect(judge({ dispatched: 1, taps: [tap], onTap: 'function', unsubscribe: 'undefined' })) | |
| 417 | .toEqual(['notifications.onTap returned undefined, expected a function that unsubscribes']) | |
| 418 | }) | |
| 419 | ||
| 420 | it('names a run that never reported', () => { | |
| 421 | expect(notificationTapProblems(notificationTapReport('** TEST FAILED **'), RUN, 'ios-shim/xcodebuild-notification.log')) | |
| 422 | .toEqual(['the notification tap cold start never reported; see ios-shim/xcodebuild-notification.log']) | |
| 423 | }) | |
| 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 }) | |
| 431 | }) | |
| 432 | }) | |
| 433 | ||
| 385 | 434 | describe('cold-start deep links on Android', () => { |
| 386 | 435 | const LINK = 'crafte2eprobe://e2e/cold?run=craft-e2e-android-shim-1' |
| 387 | 436 | const report = { launch: `${LINK}&receive=subscribe`, receive: 'subscribe', onLink: [{ url: `${LINK}&receive=subscribe`, initial: true }], initialURL: null, unsubscribe: 'function' } |
| @@ -438,6 +438,89 @@ export function deepLinkProblems( | ||
| 438 | 438 | return problems |
| 439 | 439 | } |
| 440 | 440 | |
| 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 | */ | |
| 447 | export 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 | */ | |
| 455 | export 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. */ | |
| 460 | export 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 | */ | |
| 478 | export 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 | */ | |
| 498 | export 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 | ||
| 441 | 524 | /** The need the Android share case announces before it opens the menu. */ |
| 442 | 525 | export const DISMISS_SHARE_MENU = 'dismiss-share-menu' |
| 443 | 526 | |