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/ios.tsmodified+64-9
Changes to scripts/mobile-e2e/ios.ts
@@ -2,7 +2,7 @@ import type { LegOutcome, RunnerOptions } from './types'
22import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
33import { join } from 'node:path'
44import { bootSimulator, init, pickSimulator } from '../../packages/ios/src/index'
5import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
5import { deepLinkProblems, deepLinkResults, evaluateRun, hasTerminated, notificationPayload, notificationTapProblems, notificationTapReport, ZIG_REFUSED_ACTIONS, ZIG_SERVED_ACTIONS, ZIG_TESTED_ACTIONS, zigDispatchedActions, zigHandBacks, zigRefusals } from './protocol'
66import { command, driverPage, waitForFile } from './support'
77
88/** The scheme the probe registers, and the one its cold-start link uses. */
@@ -56,12 +56,25 @@ const APP_NAME = 'CraftE2EProbe'
5656 * The UI test target the harness adds to the generated project.
5757 *
5858 * 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.
6262 */
6363const UI_TESTS = `${APP_NAME}UITests`
6464
65/** The UI test classes, each run on its own with `-only-testing`. */
66const 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 */
73const NOTIFICATION_TEST_TIMEOUT_MS = 300_000
74
75/** The line the notification UI test prints once the app is gone. */
76const PUSH_READY = /^CRAFT-E2E-PUSH-READY\s*$/m
77
6578/**
6679 * One leg is one answer to "who served the call".
6780 *
@@ -114,9 +127,10 @@ async function runLeg(leg: Leg, options: RunnerOptions): Promise<LegOutcome> {
114127 if (!leg.requireZigDispatch && linksRuntime)
115128 failures.push(`the ${leg.name} leg was supposed to have no runtime but its project links one`)
116129
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.
118131 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`))
120134 writeFileSync(join(project, 'project.yml'), `${projectYml.trimEnd()}
121135 ${UI_TESTS}:
122136 type: bundle.ui-testing
@@ -279,16 +293,18 @@ schemes:
279293 // #198: cold-start the app through a link, twice, and judge what the page
280294 // received. After the suite and its evidence, because the UI test
281295 // 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]) => [
284297 'xcodebuild',
285298 '-project', `${APP_NAME}.xcodeproj`,
286299 '-scheme', APP_NAME,
287300 '-destination', `id=${device.udid}`,
288301 '-derivedDataPath', derived,
302 `-only-testing:${UI_TESTS}/${testClass}`,
289303 'CODE_SIGNING_ALLOWED=NO',
290304 'test-without-building',
291 ], {
305 ]
306 const link = `${DEEP_LINK_SCHEME}://e2e/cold?run=${encodeURIComponent(nonce)}`
307 const coldStart = await command(uiTest('DeepLinkColdStartTests'), {
292308 cwd: project,
293309 // xcodebuild hands TEST_RUNNER_-prefixed variables to the runner without
294310 // the prefix.
@@ -300,6 +316,45 @@ schemes:
300316 await command(['xcrun', 'simctl', 'terminate', device.udid, BUNDLE_ID], { allowFailure: true })
301317 failures.push(...deepLinkProblems(deepLinkResults(`${coldStart.stdout}\n${coldStart.stderr}`), link))
302318
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
303358 const dispatched = zigDispatchedActions(consoleText)
304359 const refused = zigRefusals(consoleText)
305360 const handedBack = zigHandBacks(consoleText)