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
| @@ -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) |