diff --git a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt
index ffa3a03b93cc..16948a78ea12 100644
--- a/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt
+++ b/rnmodules/react-native-kb/android/src/main/java/com/reactnativekb/KbModule.kt
@@ -61,7 +61,7 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T
private fun canEmit(): Boolean = mEventEmitterCallback != null
@ReactMethod(isBlockingSynchronousMethod = true)
- override fun getAppLifecycleState(): String = appLifecycleState
+ override fun getAppLifecycleState(): String = KbModule.lastAppLifecycleState
private fun emitAppLifecycleInternal(state: String) {
if (reactContext.hasActiveReactInstance() && canEmit()) {
@@ -799,16 +799,18 @@ class KbModule(reactContext: ReactApplicationContext?) : KbSpec(reactContext), T
}
// Written on the main thread by the process lifecycle observer, read on
- // the JS thread by getAppLifecycleState.
+ // the JS thread by getAppLifecycleState. Read it qualified: inside the
+ // class, a bare name matching a spec getter (appLifecycleState) resolves
+ // to that Java getter, not to a companion field.
@Volatile
- private var appLifecycleState: String = "background"
+ private var lastAppLifecycleState: String = "background"
// Main thread only. Call next to each Go SetAppState* report with
// "active", "inactive" or "background"; the latest value is kept so JS
// can read what it missed before it listened.
@JvmStatic
fun emitAppLifecycle(state: String) {
- appLifecycleState = state
+ lastAppLifecycleState = state
instance?.emitAppLifecycleInternal(state)
}
diff --git a/shared/ios/Keybase/Info.plist b/shared/ios/Keybase/Info.plist
index 2f56065bc0e3..97f2e11e519b 100644
--- a/shared/ios/Keybase/Info.plist
+++ b/shared/ios/Keybase/Info.plist
@@ -2,6 +2,12 @@
+ BGTaskSchedulerPermittedIdentifiers
+
+ com.keybase.app.refresh
+
+ CADisableMinimumFrameDurationOnPhone
+ CFBundleDevelopmentRegionenCFBundleDisplayName
@@ -104,10 +110,6 @@
- BGTaskSchedulerPermittedIdentifiers
-
- com.keybase.app.refresh
- UIBackgroundModesfetch
@@ -137,7 +139,5 @@
UIViewControllerBasedStatusBarAppearance
- CADisableMinimumFrameDurationOnPhone
-
diff --git a/shared/package.json b/shared/package.json
index d32e33d0fa20..5df695669730 100644
--- a/shared/package.json
+++ b/shared/package.json
@@ -76,12 +76,13 @@
"test:e2e:ios:ipad": "bash tests/e2e/run-ios-appium.sh iPadTest",
"test:e2e:ios:iphone-old": "bash tests/e2e/run-ios-appium.sh iPhoneTestOld",
"test:e2e:ios:ipad-old": "bash tests/e2e/run-ios-appium.sh iPadTestOld",
+ "test:e2e:ios:lifecycle": "bash tests/e2e/run-ios-lifecycle.sh iPhoneTest",
"test:e2e:ios:report": "node tests/e2e/generate-appium-report.mts && open tests/results/ios-appium-report.html",
"test:e2e:android": "bash tests/e2e/run-android-appium.sh",
"test:e2e:android:report": "node tests/e2e/generate-appium-report.mts android && open tests/results/android-appium-report.html",
"test:unit": "napi-postinstall unrs-resolver 1.11.1 check; jest --runInBand",
"test:unit:ios": "xcodebuild test -project ./ios/Keybase.xcodeproj -scheme 'Keybase For Test' -destination 'platform=iOS Simulator,name=iPhone 6s,OS=9.3'",
- "tsc": "./node_modules/typescript-native/bin/tsc --project ./tsconfig.desktop.json && ./node_modules/typescript-native/bin/tsc --project ./tsconfig.native.json"
+ "tsc": "./node_modules/typescript-native/bin/tsc --project ./tsconfig.desktop.json && ./node_modules/typescript-native/bin/tsc --project ./tsconfig.native.json && ./node_modules/typescript-native/bin/tsc --project ./tests/e2e/ios-appium/tsconfig.json"
},
"keywords": [],
"author": "",
diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts
new file mode 100644
index 000000000000..db223d7bd6bc
--- /dev/null
+++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts
@@ -0,0 +1,209 @@
+import {expect} from '@wdio/globals'
+import {requireSmokeUser} from '../helpers/app'
+import {byText, tab} from '../helpers/elements'
+import {
+ activateApp,
+ appPid,
+ appSnapshot,
+ backgroundApp,
+ closeNotificationCenter,
+ crashReportsSince,
+ findLines,
+ goAppStateUpdates,
+ goLogMark,
+ goLogSince,
+ launchApp,
+ metroClientLogSince,
+ metroLogMark,
+ openNotificationCenter,
+ openSelfConversation,
+ startSenderDevice,
+ terminateApp,
+ waitFor,
+ waitForAppState,
+ waitForAvatar200,
+ waitForLinesInOrder,
+} from '../helpers/lifecycle'
+
+// Log lines these flows rely on:
+// - Go (ios.log): "MobileAppState.Update: useful update: , …" per Go app state transition (deduped: only logged when the state actually
+// changes), "startHTTPSrv: start success: addr: " when the image
+// server starts on a new address, "kbhttp.Srv: server starting on: " on every start
+// of a Go http server.
+// - Metro (JS): "[AppState] native: " for every native lifecycle
+// report JS's listener takes (onNativeAppLifecycle in constants/init/shared.tsx) once
+// subscribed, and "app focus changed: " when the shell store's app state changes.
+const httpSrvStarted = /startHTTPSrv: start success: addr: /
+const httpSrvStartedAny = /kbhttp\.Srv: server starting on: /
+// Go dedupes state transitions, so "BACKGROUND" alone would also match "BACKGROUNDACTIVE"; the
+// trailing comma from "useful update: %v, we are currently in state: %v" disambiguates them.
+const goForeground = /useful update: FOREGROUND,/
+const goBackground = /useful update: BACKGROUND,/
+const goInactive = /useful update: INACTIVE,/
+// Before the app is active, iOS reports INACTIVE or BACKGROUNDACTIVE depending on which
+// launch callback lands first.
+const goNotYetActive = /useful update: (INACTIVE|BACKGROUNDACTIVE),/
+describe('app lifecycle: app state', () => {
+ it('cold launch reaches active under scenes and serves images', async () => {
+ const user = requireSmokeUser()
+ const since = Date.now()
+ await terminateApp()
+ const goMark = goLogMark()
+ await launchApp()
+
+ // The app restores its last screen, which may hide the tab bar, so wait on state.
+ const snap = await waitForAppState('active', undefined, 90000)
+ // Go defaults to FOREGROUND at process start; didFinishLaunching reports the real
+ // (not-yet-active) state before RN even starts, then didBecomeActive reports active. JS's
+ // own listener mounts only once the bundle has loaded, which can race either report on a
+ // cold launch, so the launch is proven from Go's deterministic two-step transition instead
+ // of a JS log line.
+ const goLines = await waitForLinesInOrder('Go to report the launch', () => goLogSince(goMark), [
+ goNotYetActive,
+ goForeground,
+ ])
+ expect(goLines).toHaveLength(2)
+ // JS must hold the address of the server Go started, not a stale one.
+ const started = findLines(goLogSince(goMark), httpSrvStarted).at(-1) ?? ''
+ expect(started).toContain(`addr: ${snap.httpSrv.address} `)
+ expect(findLines(goLogSince(goMark), httpSrvStartedAny).length).toBeGreaterThanOrEqual(1)
+
+ const avatar = await waitForAvatar200(user)
+ expect(avatar.status).toBe(200)
+
+ // The UIScene SIGTRAP crashed within a second of launch; give it several.
+ const pid = appPid()
+ await browser.pause(5000)
+ expect(appPid()).toBe(pid)
+ expect(crashReportsSince(since)).toEqual([])
+ })
+
+ it('background, then foreground: images load and chat receives a new message', async () => {
+ const user = requireSmokeUser()
+ const since = Date.now()
+ const convID = await openSelfConversation(user)
+ const sender = await startSenderDevice(user)
+ try {
+ const pid = appPid()
+ const goMark = goLogMark()
+ const metroMark = metroLogMark()
+ await backgroundApp()
+ // resignActive reports INACTIVE before didEnterBackground drops to BACKGROUND.
+ await waitForLinesInOrder('Go to go to the background', () => goLogSince(goMark), [
+ goInactive,
+ goBackground,
+ ])
+
+ const text = `e2e-lifecycle-recv-${Date.now()}`
+ await sender.send(convID, text)
+ await browser.pause(10000)
+ await activateApp()
+
+ const snap = await waitForAppState('active')
+ expect(snap.screen?.params?.['conversationIDKey']).toBe(convID)
+ // A background-fetch task (go/bind/keybase.go:956-998's BackgroundSync, run from a
+ // BGAppRefreshTask) can flip BACKGROUND -> BACKGROUNDACTIVE and, on its own ~10s timer --
+ // the same length as the pause above -- back to BACKGROUND again. So once BACKGROUNDACTIVE
+ // entries are dropped, BACKGROUND can legitimately repeat too (e.g.
+ // ['BACKGROUND', 'BACKGROUND', 'FOREGROUND']), not just appear once. Require only that the
+ // app actually reached BACKGROUND at some point, that the sequence ends at FOREGROUND, and
+ // that no FOREGROUND landed before the last BACKGROUND (so a stray return trip can't hide
+ // inside the tolerance).
+ const updates = goAppStateUpdates(goMark).filter(s => s !== 'BACKGROUNDACTIVE')
+ expect(updates).toContain('BACKGROUND')
+ expect(updates.at(-1)).toBe('FOREGROUND')
+ expect(updates.indexOf('FOREGROUND')).toBeGreaterThan(updates.lastIndexOf('BACKGROUND'))
+ // JS's own listener has been mounted since well before this test started, so unlike the
+ // cold-launch case there's no race with subscribing: it must see the same round trip
+ // directly from native (constants/init/shared.tsx's onNativeAppLifecycle), independent of
+ // Go's app-state machine. A second device is attached to the same Metro server for the
+ // send below, so this only checks containment (not an exact sequence) in case its own,
+ // unrelated activity lands in the same shared log window.
+ const jsStates = findLines(metroClientLogSince(metroMark), /\[AppState\] native: /)
+ expect(jsStates.some(l => l.endsWith('background'))).toBe(true)
+ expect(jsStates.at(-1)).toMatch(/\[AppState\] native: active$/)
+ await waitForAvatar200(user)
+
+ // The message sent from the other device while this one was in the background.
+ await byText(text).waitForExist({interval: 250, timeout: 60000, timeoutMsg: `"${text}" never arrived`})
+ expect(appPid()).toBe(pid)
+ expect(crashReportsSince(since)).toEqual([])
+ } finally {
+ // A cleanup failure must not hide the test's own failure.
+ await sender.stop().catch((e: unknown) => {
+ // eslint-disable-next-line no-console
+ console.warn(`sender device cleanup failed: ${e instanceof Error ? e.message : String(e)}`)
+ })
+ }
+ })
+
+ it('five quick background/foreground cycles leave the app healthy', async () => {
+ const user = requireSmokeUser()
+ const since = Date.now()
+ await waitForAppState('active')
+ const pid = appPid()
+ const goMark = goLogMark()
+ const metroMark = metroLogMark()
+
+ const cycles = 5
+ for (let i = 0; i < cycles; i++) {
+ await backgroundApp()
+ await browser.pause(700)
+ await activateApp()
+ await browser.pause(700)
+ }
+
+ await waitForAppState('active')
+ expect(appPid()).toBe(pid)
+ // Every cycle reaches Go, and the last word is foreground.
+ await waitFor(
+ 'Go to see every cycle',
+ () => {
+ const updates = goAppStateUpdates(goMark)
+ const backgrounds = updates.filter(s => s === 'BACKGROUND').length
+ const actives = updates.filter(s => s === 'FOREGROUND').length
+ return backgrounds >= cycles && actives >= cycles && updates.at(-1) === 'FOREGROUND' ? true : undefined
+ },
+ {interval: 500, timeout: 20000}
+ )
+ // JS saw the app go away and come back, ending active.
+ const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /)
+ expect(focus.some(l => l.endsWith('app focus changed: background'))).toBe(true)
+ expect(focus.at(-1)).toMatch(/app focus changed: active$/)
+
+ const avatar = await waitForAvatar200(user)
+ expect(avatar.status).toBe(200)
+ await expect(tab('People')).toExist()
+ expect(crashReportsSince(since)).toEqual([])
+ })
+
+ it('Notification Center makes the app inactive, then images load once active again', async () => {
+ const user = requireSmokeUser()
+ await waitForAppState('active')
+ const goMark = goLogMark()
+ const metroMark = metroLogMark()
+
+ await openNotificationCenter()
+ await waitForAppState('inactive', undefined, 15000)
+ // Notification Center only resigns active; it never backgrounds the app. Go's INACTIVE
+ // stops the image server (#29665), so images aren't checked until the app is active again.
+ await waitForLinesInOrder('Go to go inactive', () => goLogSince(goMark), [goInactive])
+ expect(findLines(goLogSince(goMark), goBackground)).toEqual([])
+
+ await closeNotificationCenter()
+ await waitForAppState('active', undefined, 15000)
+ await waitForLinesInOrder('Go to become active again', () => goLogSince(goMark), [goForeground])
+ const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /)
+ expect(focus).toEqual([
+ expect.stringMatching(/app focus changed: inactive$/),
+ expect.stringMatching(/app focus changed: active$/),
+ ])
+ // JS must hold the address of the server Go restarted, not a stale one.
+ const restarted = findLines(goLogSince(goMark), httpSrvStarted).at(-1)
+ if (restarted) {
+ expect(restarted).toContain(`addr: ${(await appSnapshot()).httpSrv.address} `)
+ }
+ await waitForAvatar200(user)
+ })
+})
diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts
new file mode 100644
index 000000000000..29b78b26be15
--- /dev/null
+++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts
@@ -0,0 +1,238 @@
+import {expect} from '@wdio/globals'
+import {requireSmokeUser} from '../helpers/app'
+import {el} from '../helpers/elements'
+import {escapeToTabs, navigateToPeople} from '../helpers/navigate'
+import * as T from '../../shared/test-ids'
+import {
+ activateApp,
+ appSnapshot,
+ backgroundApp,
+ closeNotificationCenter,
+ ensureNotificationPermission,
+ findLines,
+ findNotification,
+ goLogMark,
+ goLogSince,
+ jsEval,
+ metroClientLogSince,
+ metroLogMark,
+ openSelfConversation,
+ openUrl,
+ sendPush,
+ terminateApp,
+ waitFor,
+ waitForAppState,
+ waitForLinesInOrder,
+} from '../helpers/lifecycle'
+
+// A public account safe to deep link to.
+const profileLink = {url: 'keybase://profile/show/keybase', username: 'keybase'}
+
+const waitForScreen = async (what: string, match: (s: Awaited>['screen']) => boolean) =>
+ waitFor(
+ what,
+ async () => {
+ const {screen} = await appSnapshot()
+ return match(screen) ? screen : undefined
+ },
+ {interval: 500, timeout: 30000}
+ )
+
+// A push sent before the app is really in the background is handed to the app instead of shown.
+// The trailing comma (from Go's "useful update: %v, we are currently in state: %v") keeps this
+// from also matching a BACKGROUNDACTIVE transition.
+const waitForBackground = async (goMark: ReturnType) =>
+ waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [/useful update: BACKGROUND,/])
+
+// Terminating right after navigating can leave the previous screen as the route the app saves
+// and restores on launch (routes are saved on a delay, and on backgrounding). Leaving on People
+// and backgrounding first makes a cold launch that opens a conversation prove the launch
+// input (link or push) did it, not the restored route.
+const terminateFromPeople = async () => {
+ await escapeToTabs()
+ await navigateToPeople()
+ const goMark = goLogMark()
+ const metroMark = metroLogMark()
+ await backgroundApp()
+ await waitForBackground(goMark)
+ await waitForLinesInOrder('JS to see the background', () => metroClientLogSince(metroMark), [
+ /app focus changed: background$/,
+ ])
+ await terminateApp()
+}
+
+const onProfile = (s: Awaited>['screen']) =>
+ s?.name === 'profile' && s.params?.['username'] === profileLink.username
+
+// Log lines these flows rely on:
+// - Metro (JS): "[Startup] loadStartupDetails: Linking.getInitialURL returned in ms: " for
+// a cold deep link; "[PushTap] took a tap link: " for every tap JS takes, cold or warm
+// (only a tap reaches JS at all); "[DeepLink] url event: " for a link opened while running;
+// "[AccountLink] switching accounts" for a tap that switches accounts, which must never appear
+// in these flows.
+// - Go (ios.log): "MobileAppState.Update: useful update: BACKGROUND, …" before a push is sent to
+// a backgrounded app, so it can't arrive while the app is still in the foreground (and not be
+// shown).
+describe('app lifecycle: deep links', () => {
+ it('opens a deep link while running', async () => {
+ await waitForAppState('active')
+ await navigateToPeople()
+ openUrl(profileLink.url)
+ await waitForScreen('the linked profile', onProfile)
+ await el(T.PROFILE_PAGE).waitForExist({interval: 250, timeout: 15000})
+ })
+
+ // A cold profile link is not used here: the router builds its launch state with the profile
+ // inside the People tab, where it isn't a screen, so it opens People instead. That is
+ // router-v2/linking.tsx behavior, independent of app state; a conversation link is built
+ // at the root and exercises the same launch path.
+ it('opens a deep link that launches the app', async () => {
+ const user = requireSmokeUser()
+ await waitForAppState('active')
+ const convID = await openSelfConversation(user)
+ await terminateFromPeople()
+ const metroMark = metroLogMark()
+ const url = `keybase://convid/${convID}`
+ openUrl(url)
+ await waitForAppState('active', undefined, 90000)
+ await waitForScreen('the linked conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID)
+ const startup = findLines(metroClientLogSince(metroMark), /Linking\.getInitialURL returned in \d+ms: /)
+ expect(startup.at(-1)).toContain(url)
+ })
+
+ it('a link naming another account opens as a plain link and never switches accounts', async () => {
+ const user = requireSmokeUser()
+ await waitForAppState('active')
+ const convID = await openSelfConversation(user)
+ await navigateToPeople()
+ const uidBefore = await jsEval(`return kbModule('stores/current-user.tsx').useCurrentUserState.getState().uid`)
+ const metroMark = metroLogMark()
+ // a uid that isn't this account: a link, unlike a tap, can never act on one
+ openUrl(`keybase://convid/${convID}?uid=00000000000000000000000000000019`)
+ await waitForScreen('the linked conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID)
+ const lines = metroClientLogSince(metroMark)
+ expect(findLines(lines, /\[DeepLink\] url event: /)).toHaveLength(1)
+ expect(findLines(lines, /\[AccountLink\]/)).toEqual([])
+ expect(findLines(lines, /\[PushTap\] took a tap link: /)).toEqual([])
+ await browser.pause(3000)
+ expect(await jsEval(`return kbModule('stores/current-user.tsx').useCurrentUserState.getState().uid`)).toBe(uidBefore)
+ })
+})
+
+describe('app lifecycle: push notifications', () => {
+ let convID = ''
+ let uid = ''
+ // Real pushes name the account they are for; a payload without uid skips the account check.
+ const pushFor = (body: string) => ({
+ aps: {alert: {body, title: 'e2e'}, sound: 'default'},
+ convID,
+ m: '',
+ t: '1',
+ type: 'chat.newmessage',
+ uid,
+ })
+ const tapLink = () => `keybase://convid/${convID}`
+ // Every tap JS took. A push that was not tapped produces none.
+ const tapLines = (lines: Array) => findLines(lines, /\[PushTap\] took a tap link: /)
+
+ // An empty tapLines result only proves something if the window it's read from definitely
+ // captured JS's live console output; a quiet Metro socket or a JS runtime that never ran would
+ // pass the same empty check for the wrong reason. console.log goes through the same
+ // remote-console path logger.info does (see shared/logger/ring-logger.tsx), so a marker dropped
+ // this way is captured by metroClientLogSince exactly like any real log line would be.
+ const logMarker = async (): Promise => {
+ const token = `e2e-marker-${Date.now()}-${Math.random().toString(36).slice(2)}`
+ await jsEval(`console.log(${JSON.stringify(token)}); return true`)
+ return new RegExp(token)
+ }
+
+ before(async () => {
+ const user = requireSmokeUser()
+ await waitForAppState('active')
+ await ensureNotificationPermission()
+ convID = await openSelfConversation(user)
+ uid = await jsEval(`return kbModule('stores/current-user.tsx').useCurrentUserState.getState().uid`)
+ expect(uid).not.toBe('')
+ })
+
+ it('a visible push that arrives in the foreground does not navigate', async () => {
+ await waitForAppState('active')
+ await navigateToPeople()
+ const metroMark = metroLogMark()
+ // The app never leaves the foreground in this test, so there's no natural event (like a
+ // background/foreground transition) to prove the log window is live; drop one explicitly.
+ const marker = await logMarker()
+ const body = `e2e-push-foreground-${Date.now()}`
+ sendPush(pushFor(body))
+
+ await browser.pause(5000)
+ const lines = metroClientLogSince(metroMark)
+ expect(findLines(lines, marker).length).toBeGreaterThan(0)
+ expect(tapLines(lines)).toEqual([])
+ expect((await appSnapshot()).screen?.name).not.toBe('chatConversation')
+ })
+
+ it('a push shown in the background but not tapped does not navigate', async () => {
+ await waitForAppState('active')
+ await navigateToPeople()
+ const metroMark = metroLogMark()
+ const goMark = goLogMark()
+ await backgroundApp()
+ await waitForBackground(goMark)
+ const body = `e2e-push-untapped-${Date.now()}`
+ sendPush(pushFor(body))
+ // The notification is really shown, just not tapped.
+ const where = await findNotification(body, {tap: false})
+ expect(where).toBeDefined()
+ if (where === 'center') await closeNotificationCenter()
+ await activateApp()
+
+ await waitForAppState('active')
+ await browser.pause(3000)
+ expect((await appSnapshot()).screen?.name).not.toBe('chatConversation')
+ const lines = metroClientLogSince(metroMark)
+ // Reactivating always logs "app focus changed: active" (constants/init/index.tsx); requiring
+ // it first proves this window captured JS's live output, so an empty tapLines below means no
+ // tap happened rather than a JS runtime that silently never resumed logging.
+ expect(findLines(lines, /app focus changed: active$/).length).toBeGreaterThan(0)
+ expect(tapLines(lines)).toEqual([])
+ })
+
+ it('tapping a push shown in the background opens its conversation', async () => {
+ await waitForAppState('active')
+ await navigateToPeople()
+ const metroMark = metroLogMark()
+ const goMark = goLogMark()
+ await backgroundApp()
+ await waitForBackground(goMark)
+ const body = `e2e-push-tapped-${Date.now()}`
+ sendPush(pushFor(body))
+ expect(await findNotification(body, {tap: true})).toBeDefined()
+
+ await waitForAppState('active')
+ await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID)
+ const lines = metroClientLogSince(metroMark)
+ // Delivered once, and never through Linking.
+ expect(tapLines(lines)).toEqual([expect.stringContaining(tapLink())])
+ expect(findLines(lines, /\[DeepLink\] url event: /)).toEqual([])
+ })
+
+ it('tapping a push while the app is not running launches into its conversation', async () => {
+ await waitForAppState('active')
+ await terminateFromPeople()
+ const metroMark = metroLogMark()
+ const body = `e2e-push-cold-${Date.now()}`
+ sendPush(pushFor(body))
+ expect(await findNotification(body, {tap: true})).toBeDefined()
+
+ await waitForAppState('active', undefined, 90000)
+ await waitForScreen('the pushed conversation', s => s?.name === 'chatConversation' && s.params?.['conversationIDKey'] === convID)
+ // The tap reaches JS through the native tap slot and picks the startup route.
+ const lines = metroClientLogSince(metroMark)
+ expect(tapLines(lines)).toEqual([expect.stringContaining(tapLink())])
+ // startup's inbox load can still pick a screen after the route opens; the conversation must stay
+ await browser.pause(3000)
+ expect((await appSnapshot()).screen?.params?.['conversationIDKey']).toBe(convID)
+ expect(findLines(metroClientLogSince(metroMark), /\[AccountLink\]/)).toEqual([])
+ })
+})
diff --git a/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts
new file mode 100644
index 000000000000..31cf7905a214
--- /dev/null
+++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts
@@ -0,0 +1,231 @@
+import {expect} from '@wdio/globals'
+import {requireSmokeUser} from '../helpers/app'
+import {el, enterText, waitForTestID} from '../helpers/elements'
+import * as T from '../../shared/test-ids'
+import {
+ activateApp,
+ appPid,
+ backgroundApp,
+ deviceUdid,
+ findLines,
+ goAppStateUpdates,
+ goLogMark,
+ goLogSince,
+ jsEval,
+ metroBundlingStartedSince,
+ metroClientLogSince,
+ metroLogMark,
+ nsLogSince,
+ openSelfConversation,
+ setLocation,
+ simctl,
+ terminateApp,
+ waitFor,
+ waitForAppState,
+ waitForLinesInOrder,
+ BUNDLE_ID,
+} from '../helpers/lifecycle'
+
+// Live location on iOS: Go asks JS to watch (chatWatchPosition), JS starts react-native-kb's
+// native watcher, and each fix goes native -> JS -> localLocationUpdate -> Go, which posts it to
+// the conversation as a map unfurl. These flows move the simulated location and follow that in
+// the Go log (ios.log):
+// - "LiveLocationTracker: StartTracking" / "StopAllTracking" when sharing starts and stops,
+// - "+ LiveLocationTracker: LocationUpdate" for each fix JS hands Go,
+// - "LiveLocationTracker: tracker[]: got coords" when the tracker takes it,
+// - "+ LiveLocationTracker: updateMapUnfurl" when Go posts the location to the conversation,
+// - "LiveLocationTracker: restoreLocked: restored trackers" when a relaunch restores sharing,
+// - "AppDidEnterBackground: setting background active" when the app backgrounds while sharing:
+// Go keeps running for the share, so resign-active reports INACTIVE, the background reports
+// BACKGROUNDACTIVE, and Go never goes to BACKGROUND. Only a connecting scene reports FOREGROUND
+// (see goAppStateUpdates in helpers/lifecycle.ts).
+// And in the app's NSLog lines: "KbLocationWatcher: starting location updates" and
+// "KbLocationWatcher: stopping location updates" when the native watcher turns the OS service on
+// and off.
+// A kill while sharing leaves significant-change monitoring on, so a move relaunches the app in
+// the background. No scene connects there, so no JS runs (Metro's start.log sees neither a bundle
+// request nor a JS log line), and since every fix reaches Go through JS, none does. Go restores
+// the share on that launch, but its watch request finds no chat UI (NullChatUI), so nothing
+// starts a watch; once the user opens the app, JS init stops the leftover OS monitoring
+// (initIOSLocation). Not ported from the native-Go branch: a fix posted by the background
+// relaunch itself, which needed Go to own the watcher.
+// The posted map itself never renders here: the maps server rejects the render request, so the
+// unfurl fails after Go posts it. The flows stop at the post.
+//
+// Only the smoke user's conversation with themselves is used.
+
+// The simulator reports no fix when the watcher starts at the location it already has, so
+// each run starts somewhere new.
+const start = {lat: 37.7749 + Math.random() * 0.01, lon: -122.4194}
+// Far enough apart that Go, which in the background waits for real movement, records them,
+// and the last far enough for iOS to count it as a significant change.
+const moves = [
+ {lat: start.lat + 0.01, lon: start.lon},
+ {lat: start.lat + 0.02, lon: start.lon},
+ {lat: start.lat + 0.06, lon: start.lon + 0.04},
+]
+
+const locationUpdates = (lines: Array) => findLines(lines, /\+ LiveLocationTracker: LocationUpdate/)
+
+const sendCommand = async (text: string) => {
+ await waitForTestID(T.CHAT_INPUT, 10000)
+ await enterText(T.CHAT_INPUT, text)
+ await waitForTestID(T.CHAT_SEND_BUTTON, 5000)
+ await el(T.CHAT_SEND_BUTTON).click()
+}
+
+describe('app lifecycle: live location', () => {
+ let convID = ''
+ let sharing = false
+
+ before(() => {
+ const udid = deviceUdid()
+ simctl('privacy', udid, 'grant', 'location-always', BUNDLE_ID)
+ setLocation(start.lat, start.lon)
+ })
+
+ // Sharing must never be left on, even when a flow fails partway.
+ after(async () => {
+ if (!sharing) return
+ await activateApp()
+ await waitForAppState('active', undefined, 90000)
+ await jsEval(
+ `kbModule('chat/conversation/send-actions.tsx').sendTextToConversation(${JSON.stringify(convID)}, ${JSON.stringify(requireSmokeUser())}, '/location stop'); return true`
+ )
+ })
+
+ it('shares live location and posts a move while in the foreground', async () => {
+ const user = requireSmokeUser()
+ convID = await openSelfConversation(user)
+ const goMark = goLogMark()
+ const since = new Date(Date.now() - 1000)
+ await sendCommand('/location live 15m')
+ sharing = true
+ await waitForLinesInOrder('Go to start tracking', () => goLogSince(goMark), [/LiveLocationTracker: StartTracking/], 30000)
+ await waitForLinesInOrder('the native watcher to start', () => nsLogSince('KbLocationWatcher: ', since), [
+ /starting location updates/,
+ ])
+
+ const moveMark = goLogMark()
+ setLocation(moves[0]!.lat, moves[0]!.lon)
+ // The tracker can be busy posting the first fix for up to a minute before it posts this one.
+ await waitForLinesInOrder(
+ 'the foreground move to be posted',
+ () => goLogSince(moveMark),
+ [/\+ LiveLocationTracker: LocationUpdate/, /tracker\[\d+\]: got coords/, /\+ LiveLocationTracker: updateMapUnfurl/],
+ 180000
+ )
+ })
+
+ it('posts a move while in the background', async () => {
+ await waitForAppState('active')
+ const goMark = goLogMark()
+ await backgroundApp()
+ await waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [
+ /MobileAppState\.Update: useful update: INACTIVE,/,
+ /AppDidEnterBackground: setting background active/,
+ /MobileAppState\.Update: useful update: BACKGROUNDACTIVE,/,
+ ])
+ // JS keeps running in the background while the watcher is on, and relays the fix.
+ const moveMark = goLogMark()
+ setLocation(moves[1]!.lat, moves[1]!.lon)
+ await waitForLinesInOrder(
+ 'the background move to be posted',
+ () => goLogSince(moveMark),
+ [/\+ LiveLocationTracker: LocationUpdate/, /tracker\[\d+\]: got coords/, /\+ LiveLocationTracker: updateMapUnfurl/],
+ 180000
+ )
+ expect(goAppStateUpdates(goMark)).not.toContain('FOREGROUND')
+ await activateApp()
+ await waitForAppState('active')
+ })
+
+ it('stops sharing, stops the OS location service, and a move no longer relaunches the app', async function () {
+ this.timeout(420000)
+ const user = requireSmokeUser()
+ await activateApp()
+ await waitForAppState('active', undefined, 90000)
+ await openSelfConversation(user)
+ const goMark = goLogMark()
+ const since = new Date(Date.now() - 1000)
+ await sendCommand('/location stop')
+ // The tracker posts a final "done" update before it lets go of the watcher, and that post
+ // waits up to a minute for the unfurl that fails here.
+ await waitForLinesInOrder(
+ 'Go to stop tracking',
+ () => goLogSince(goMark),
+ [
+ /LiveLocationTracker: StopAllTracking/,
+ /tracker\[\d+\]: stopped, updating with done status/,
+ /- LiveLocationTracker: updateMapUnfurl -> /,
+ ],
+ 150000
+ )
+ sharing = false
+ await waitForLinesInOrder('the native watcher to stop', () => nsLogSince('KbLocationWatcher: ', since), [
+ /stopping location updates/,
+ ])
+
+ // A move reaches neither Go nor, once the app is killed, a relaunch.
+ const moveMark = goLogMark()
+ setLocation(moves[0]!.lat, moves[0]!.lon)
+ await browser.pause(15000)
+ expect(locationUpdates(goLogSince(moveMark))).toEqual([])
+
+ // A relaunch took up to a minute and a half while sharing, so wait longer than that.
+ await terminateApp()
+ setLocation(moves[2]!.lat, moves[2]!.lon)
+ await browser.pause(120000)
+ expect(appPid()).toBeUndefined()
+ await activateApp()
+ await waitForAppState('active', undefined, 90000)
+ })
+
+ it('a move relaunches the killed app in the background, where no JS runs to relay it', async function () {
+ // iOS delivers significant location changes on its own schedule: seconds to minutes. The
+ // budget covers sharing again, the relaunch, the Go log, the quiet wait and the activation.
+ this.timeout(600000)
+ const user = requireSmokeUser()
+ await activateApp()
+ await waitForAppState('active', undefined, 90000)
+ await openSelfConversation(user)
+ const shareMark = goLogMark()
+ const since = new Date(Date.now() - 1000)
+ await sendCommand('/location live 15m')
+ sharing = true
+ await waitForLinesInOrder('Go to start tracking', () => goLogSince(shareMark), [/LiveLocationTracker: StartTracking/], 30000)
+ await waitForLinesInOrder('the native watcher to start', () => nsLogSince('KbLocationWatcher: ', since), [
+ /starting location updates/,
+ ])
+
+ await terminateApp()
+ const goMark = goLogMark()
+ // start.log is shared by every device attached to Metro, so this device must be the only
+ // one running while the relaunch is watched.
+ const metroMark = metroLogMark()
+ setLocation(start.lat, start.lon)
+
+ const pid = await waitFor('iOS to relaunch the app for the move', () => appPid(), {interval: 1000, timeout: 300000})
+ await waitForLinesInOrder(
+ 'the relaunched service to restore sharing',
+ () => goLogSince(goMark),
+ [/LiveLocationTracker: restoreLocked: restored [1-9]\d* trackers/],
+ 120000
+ )
+ // Long enough for a fix the relaunch was for to have reached Go, had anything relayed it.
+ await browser.pause(30000)
+ expect(appPid()).toBe(pid)
+ expect(locationUpdates(goLogSince(goMark))).toEqual([])
+ // Launched for location, not by the user: no scene came to the foreground.
+ expect(goAppStateUpdates(goMark)).not.toContain('FOREGROUND')
+ expect(metroBundlingStartedSince(metroMark)).toEqual([])
+ expect(metroClientLogSince(metroMark)).toEqual([])
+
+ // Control: starting a scene makes both readers, from that same mark, see the JS start they
+ // just reported absent, so neither can go quietly blind.
+ await activateApp()
+ await waitForAppState('active', undefined, 90000)
+ expect(metroBundlingStartedSince(metroMark).length).toBeGreaterThan(0)
+ expect(metroClientLogSince(metroMark).length).toBeGreaterThan(0)
+ })
+})
diff --git a/shared/tests/e2e/ios-appium/helpers/app.ts b/shared/tests/e2e/ios-appium/helpers/app.ts
index b25966673e75..21fdafdfa648 100644
--- a/shared/tests/e2e/ios-appium/helpers/app.ts
+++ b/shared/tests/e2e/ios-appium/helpers/app.ts
@@ -1,4 +1,6 @@
import {execSync} from 'child_process'
+import {existsSync} from 'fs'
+import * as path from 'path'
export function udidForName(name: string): string {
const json = execSync('xcrun simctl list devices available -j', {encoding: 'utf8'})
@@ -64,6 +66,21 @@ export function androidCapabilities(serial: string) {
}
}
+// Xcode 27 replaced Simulator.app with DeviceHub.app. With a simulator running and no
+// Simulator.app window, the xcuitest driver (appium-ios-simulator run()) shuts the simulator
+// down and tries to open Simulator.app to show it, which fails the session. isHeadless makes it
+// accept the booted simulator as is instead. It must stay off wherever Simulator.app exists: in
+// headless mode the driver kills the Simulator.app window and reboots the device without one.
+// It never touches DeviceHub, which the runners open, so the window stays up either way.
+function hasSimulatorApp(): boolean {
+ try {
+ const developerDir = execSync('xcode-select -p', {encoding: 'utf8'}).trim()
+ return existsSync(path.join(developerDir, 'Applications', 'Simulator.app'))
+ } catch {
+ return true
+ }
+}
+
interface IosCapsOpts {
wdaLocalPort?: number
// false for old-iOS sims (e.g. iOS 16.4): the single prebuilt WDA is built
@@ -85,6 +102,7 @@ export function iosCapabilities(udid: string, opts: IosCapsOpts = {}) {
'appium:bundleId': 'keybase.ios',
'appium:noReset': true,
'appium:newCommandTimeout': 120,
+ ...(hasSimulatorApp() ? {} : {'appium:isHeadless': true}),
// A fresh WDA build (prebuilt: false) runs xcodebuild and can take minutes
// the first time; the prebuilt path launches in seconds.
'appium:wdaLaunchTimeout': prebuilt ? 120000 : 600000,
diff --git a/shared/tests/e2e/ios-appium/helpers/lifecycle.ts b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts
new file mode 100644
index 000000000000..d5cef62ab4c0
--- /dev/null
+++ b/shared/tests/e2e/ios-appium/helpers/lifecycle.ts
@@ -0,0 +1,558 @@
+import {execFileSync} from 'child_process'
+import * as fs from 'fs'
+import * as os from 'os'
+import * as path from 'path'
+import {udidForName} from './app'
+import * as T from '../../shared/test-ids'
+import {waitForTestID} from './elements'
+import {atTabs, escapeToTabs, navigateToChat} from './navigate'
+
+// Lifecycle flows assert on state and logs, never on screenshots:
+// - JS state is read from the running app through the Metro inspector (Runtime.evaluate).
+// - Native and Go transitions come from the app container's Go log (ios.log).
+// - JS log lines (logger.info/warn) come from Metro's start.log as metro:client_log events.
+// start.log is shared by every device attached to Metro, so flows that read it keep a
+// single app running.
+
+export const BUNDLE_ID = 'keybase.ios'
+
+export const deviceName = () => process.env['KB_IOS_DEVICE'] ?? 'iPhoneTest'
+export const deviceUdid = () => process.env['KB_IOS_UDID'] ?? udidForName(deviceName())
+
+const sleep = async (ms: number) => new Promise(resolve => setTimeout(resolve, ms))
+
+export const simctl = (...args: Array): string =>
+ execFileSync('xcrun', ['simctl', ...args], {encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe']})
+
+// Retries until check returns a value, or throws with the last error once the timeout passes.
+export const waitFor = async (
+ what: string,
+ check: () => Promise | R | undefined,
+ {timeout = 20000, interval = 250}: {timeout?: number; interval?: number} = {}
+): Promise => {
+ const end = Date.now() + timeout
+ let lastErr: unknown
+ for (;;) {
+ try {
+ const r = await check()
+ if (r !== undefined) return r
+ } catch (e) {
+ lastErr = e
+ }
+ if (Date.now() > end) {
+ const detail = lastErr instanceof Error ? `: ${lastErr.message}` : ''
+ throw new Error(`timed out after ${timeout}ms waiting for ${what}${detail}`)
+ }
+ await sleep(interval)
+ }
+}
+
+const statOrUndefined = (file: string) => {
+ try {
+ return fs.statSync(file)
+ } catch {
+ return undefined
+ }
+}
+
+// -- app process ------------------------------------------------------------
+
+// The pid of the running app on the simulator, or undefined when it isn't running.
+export const appPid = (udid = deviceUdid()): number | undefined => {
+ const out = simctl('spawn', udid, 'launchctl', 'list')
+ for (const line of out.split('\n')) {
+ if (line.includes(`UIKitApplication:${BUNDLE_ID}[`)) {
+ const pid = Number(line.trim().split(/\s+/)[0])
+ return Number.isFinite(pid) && pid > 0 ? pid : undefined
+ }
+ }
+ return undefined
+}
+
+export const terminateApp = async (udid = deviceUdid()) => {
+ try {
+ simctl('terminate', udid, BUNDLE_ID)
+ } catch {}
+ await waitFor('the app to exit', () => (appPid(udid) === undefined ? true : undefined), {timeout: 10000})
+}
+
+// Crash reports for the iOS app written after `since`. Simulator crashes land in the host's
+// DiagnosticReports next to the host's own, including the desktop Keybase app's, so match on
+// the bundle id in the report's JSON header line.
+export const crashReportsSince = (since: number): Array => {
+ const dir = path.join(os.homedir(), 'Library/Logs/DiagnosticReports')
+ let names: Array
+ try {
+ names = fs.readdirSync(dir)
+ } catch {
+ return []
+ }
+ const bundleOf = (file: string) => {
+ try {
+ const header = fs.readFileSync(file, 'utf8').split('\n', 1)[0] ?? ''
+ return (JSON.parse(header) as {bundleID?: string}).bundleID
+ } catch {
+ return undefined
+ }
+ }
+ return names
+ .filter(n => n.endsWith('.ips'))
+ .map(n => path.join(dir, n))
+ .filter(p => (statOrUndefined(p)?.mtimeMs ?? 0) >= since && bundleOf(p) === BUNDLE_ID)
+}
+
+// -- Metro inspector ----------------------------------------------------------
+
+const metroOrigin = 'http://127.0.0.1:8081'
+
+type InspectorPage = {deviceName?: string; appId?: string; webSocketDebuggerUrl: string}
+
+type EvalResponse = {
+ id: number
+ result?: {result?: {value?: unknown}; exceptionDetails?: {text?: string}}
+}
+
+// Metro keeps a page per JS runtime the device has started; the newest is last.
+const inspectorUrl = async (device: string) => {
+ const res = await fetch(`${metroOrigin}/json/list`)
+ const pages = (await res.json()) as Array
+ const page = pages.filter(p => p.deviceName === device && p.appId === BUNDLE_ID).at(-1)
+ if (!page) throw new Error(`no Metro inspector page for ${device}`)
+ return page.webSocketDebuggerUrl.replace('ws://localhost:', 'ws://127.0.0.1:')
+}
+
+// Metro dev bundles register modules by path; this finds and requires one by that path.
+const prelude = `const kbModule = name => { for (const [id, m] of __r.getModules()) if (m.verboseName === name) return __r(id); throw new Error('no module ' + name) };`
+
+// Evaluates a synchronous function body in the app's JS runtime and returns its value.
+// Only works against a debug build served by Metro.
+export const jsEval = async (body: string, device = deviceName()): Promise => {
+ const url = await inspectorUrl(device)
+ // The inspector proxy rejects connections without a local Origin; Node's WebSocket
+ // takes headers as a non-standard option.
+ const WS = WebSocket as unknown as new (url: string, opts: {headers: Record}) => WebSocket
+ const ws = new WS(url, {headers: {Origin: metroOrigin}})
+ try {
+ return await new Promise((resolve, reject) => {
+ const timer = setTimeout(() => reject(new Error('inspector evaluate timed out')), 10000)
+ ws.addEventListener('error', () => {
+ clearTimeout(timer)
+ reject(new Error('inspector connection failed'))
+ })
+ ws.addEventListener('open', () => {
+ const expression = `(() => { ${prelude} ${body} })()`
+ ws.send(JSON.stringify({id: 1, method: 'Runtime.evaluate', params: {expression, returnByValue: true}}))
+ })
+ ws.addEventListener('message', (e: MessageEvent) => {
+ const m = JSON.parse(String(e.data)) as EvalResponse
+ if (m.id !== 1) return
+ clearTimeout(timer)
+ if (m.result?.exceptionDetails) {
+ reject(new Error(`app evaluate threw: ${m.result.exceptionDetails.text ?? 'unknown'}`))
+ } else {
+ resolve(m.result?.result?.value as R)
+ }
+ })
+ })
+ } finally {
+ ws.close()
+ }
+}
+
+export type AppSnapshot = {
+ loggedIn: boolean
+ mobileAppState: string
+ httpSrv: {address: string; token: string}
+ screen?: {name?: string; params?: Record}
+}
+
+// JS app state (shell store, fed by the service's appState notification), the http
+// server address JS uses for images, and the visible screen.
+export const appSnapshot = async (device = deviceName()) =>
+ jsEval(
+ `const shell = kbModule('stores/shell.tsx').useShellState.getState()
+ const config = kbModule('stores/config.tsx').useConfigState.getState()
+ const screen = kbModule('constants/router.tsx').getVisibleScreen()
+ return {
+ httpSrv: config.httpSrv,
+ loggedIn: config.loggedIn,
+ mobileAppState: shell.mobileAppState,
+ screen: screen ? {name: screen.name, params: screen.params} : undefined,
+ }`,
+ device
+ )
+
+// Waits for a relaunched JS runtime to be logged in and report `state`. There is only one
+// derivation of it now, so there is no second value to agree with.
+export const waitForAppState = async (state: string, device = deviceName(), timeout = 60000) =>
+ waitFor(
+ `JS app state ${state}`,
+ async () => {
+ const s = await appSnapshot(device)
+ return s.loggedIn && s.mobileAppState === state && s.httpSrv.address ? s : undefined
+ },
+ {interval: 500, timeout}
+ )
+
+// -- avatars over the local http server --------------------------------------
+
+// Fetches the smoke user's avatar from the app's local http server using the address and
+// token JS currently holds, the same URL shape Go hands to image components.
+export const fetchAvatar = async (httpSrv: {address: string; token: string}, username: string) => {
+ const url = `http://${httpSrv.address}/av?typ=user&name=${encodeURIComponent(username)}&format=square_192&token=${httpSrv.token}`
+ try {
+ const res = await fetch(url, {signal: AbortSignal.timeout(5000)})
+ const body = await res.arrayBuffer()
+ return {bytes: body.byteLength, contentType: res.headers.get('content-type') ?? '', status: res.status}
+ } catch (e) {
+ return {bytes: 0, contentType: '', error: e instanceof Error ? e.message : String(e), status: 0}
+ }
+}
+
+// Images load only once JS holds the running server's address; after a restart that can
+// take a moment to arrive, so re-read it on each try.
+export const waitForAvatar200 = async (username: string, device = deviceName(), timeout = 20000) =>
+ waitFor(
+ 'the avatar to load from the local http server',
+ async () => {
+ const {httpSrv} = await appSnapshot(device)
+ const r = await fetchAvatar(httpSrv, username)
+ return r.status === 200 && r.bytes > 0 && r.contentType.startsWith('image/') ? {...r, httpSrv} : undefined
+ },
+ {interval: 500, timeout}
+ )
+
+// -- logs --------------------------------------------------------------------
+
+type LogMark = {file: string; offset: number; ino: number}
+
+const readFrom = (file: string, offset: number) => {
+ const size = fs.statSync(file).size
+ if (size <= offset) return ''
+ const fd = fs.openSync(file, 'r')
+ try {
+ const buf = Buffer.alloc(size - offset)
+ fs.readSync(fd, buf, 0, buf.length, offset)
+ return buf.toString('utf8')
+ } finally {
+ fs.closeSync(fd)
+ }
+}
+
+const markFile = (file: string): LogMark => {
+ const st = statOrUndefined(file)
+ return {file, ino: st?.ino ?? 0, offset: st?.size ?? 0}
+}
+
+// Lines written to the file since the mark. The Go log is replaced by a new file when the app
+// launches and rotated aside when it grows too big, so when the file at the path is no longer
+// the marked one, the rest of the marked file (found by inode, if still there) is read first,
+// then the new file from its start.
+const linesSince = (mark: LogMark): Array => {
+ const st = statOrUndefined(mark.file)
+ if (!st) return []
+ let text: string
+ if (st.ino === mark.ino) {
+ text = readFrom(mark.file, mark.offset)
+ } else {
+ const dir = path.dirname(mark.file)
+ const moved = fs
+ .readdirSync(dir)
+ .map(n => path.join(dir, n))
+ .find(p => statOrUndefined(p)?.ino === mark.ino)
+ text = (moved ? readFrom(moved, mark.offset) : '') + readFrom(mark.file, 0)
+ }
+ return text.split('\n').filter(Boolean)
+}
+
+// The Go service log inside the app's data container. The container moves on reinstall,
+// so resolve it each time.
+export const goLogPath = (udid = deviceUdid()) =>
+ path.join(simctl('get_app_container', udid, BUNDLE_ID, 'data').trim(), 'Library/Caches/Keybase/logs/ios.log')
+
+export const goLogMark = (udid = deviceUdid()): LogMark => markFile(goLogPath(udid))
+
+export const goLogSince = (mark: LogMark): Array => linesSince(mark)
+
+export const metroLogPath = path.resolve('.expo/dev/logs/start.log')
+
+export const metroLogMark = (): LogMark => markFile(metroLogPath)
+
+// JS console output since the mark, one string per call (arguments joined by spaces).
+export const metroClientLogSince = (mark: LogMark): Array =>
+ linesSince(mark)
+ .filter(l => l.includes('"metro:client_log"'))
+ .map(l => {
+ try {
+ const e = JSON.parse(l) as {data?: Array}
+ return (e.data ?? []).map(d => (typeof d === 'string' ? d : JSON.stringify(d))).join(' ')
+ } catch {
+ return ''
+ }
+ })
+ .filter(Boolean)
+
+// Metro's bundle requests since the mark. A JS runtime that starts against a dev server always
+// asks for a bundle, so this catches a JS start whose own logging never reached client_log.
+export const metroBundlingStartedSince = (mark: LogMark): Array =>
+ linesSince(mark).filter(l => l.includes('"metro:bundling:started"'))
+
+export const findLines = (lines: Array, re: RegExp) => lines.filter(l => re.test(l))
+
+// Waits until `read` yields a line matching every pattern, in order. Returns the matched lines.
+export const waitForLinesInOrder = async (
+ what: string,
+ read: () => Array,
+ patterns: Array,
+ timeout = 20000
+) =>
+ waitFor(
+ what,
+ () => {
+ const lines = read()
+ const matched: Array = []
+ let i = 0
+ for (const re of patterns) {
+ while (i < lines.length && !re.test(lines[i]!)) i++
+ if (i === lines.length) return undefined
+ matched.push(lines[i]!)
+ i++
+ }
+ return matched
+ },
+ {interval: 500, timeout}
+ )
+
+// Go's MobileAppState transitions since the mark, e.g. ['FOREGROUND', 'BACKGROUND'].
+export const goAppStateUpdates = (mark: LogMark) =>
+ goLogSince(mark)
+ .map(l => /MobileAppState\.Update: useful update: (\w+)/.exec(l)?.[1])
+ .filter((s): s is string => !!s)
+
+// -- simulator actions -------------------------------------------------------
+
+export const openUrl = (url: string, udid = deviceUdid()) => simctl('openurl', udid, url)
+
+export const sendPush = (payload: object, udid = deviceUdid()) => {
+ const file = path.join(os.tmpdir(), `kb-e2e-push-${process.pid}-${Date.now()}.json`)
+ fs.writeFileSync(file, JSON.stringify(payload))
+ try {
+ simctl('push', udid, BUNDLE_ID, file)
+ } finally {
+ fs.rmSync(file, {force: true})
+ }
+}
+
+export const setLocation = (lat: number, lon: number, udid = deviceUdid()) =>
+ simctl('location', udid, 'set', `${lat},${lon}`)
+
+export const isBooted = (udid: string) => simctl('list', 'devices', 'booted').includes(udid)
+
+// -- app, springboard and chat actions -----------------------------------------
+
+export const backgroundApp = async () => browser.execute('mobile: backgroundApp', {seconds: -1})
+
+export const activateApp = async () => browser.execute('mobile: activateApp', {bundleId: BUNDLE_ID})
+
+export const launchApp = async () => browser.execute('mobile: launchApp', {bundleId: BUNDLE_ID})
+
+// Runs fn with element lookups pointed at the home screen / system UI instead of the app.
+export const withSpringboard = async (fn: () => Promise): Promise => {
+ await browser.updateSettings({defaultActiveApplication: 'com.apple.springboard'})
+ try {
+ return await fn()
+ } finally {
+ await browser.updateSettings({defaultActiveApplication: BUNDLE_ID})
+ }
+}
+
+const labelContains = (text: string) => browser.$$(`-ios predicate string:label CONTAINS "${text}"`)
+
+// Pulling down from the top-left edge opens Notification Center over the app, which
+// deactivates its scene without backgrounding it (the same inactive state Control Center
+// and system alerts cause).
+export const openNotificationCenter = async () => {
+ const {width, height} = await browser.getWindowRect()
+ const x = Math.round(width * 0.3)
+ await browser
+ .action('pointer')
+ .move({x, y: 2})
+ .down()
+ .move({x, y: Math.round(height * 0.6), duration: 400})
+ .up()
+ .perform()
+}
+
+export const closeNotificationCenter = async () => {
+ const {width, height} = await browser.getWindowRect()
+ const x = Math.round(width * 0.5)
+ await browser
+ .action('pointer')
+ .move({x, y: height - 5})
+ .down()
+ .move({x, y: Math.round(height * 0.2), duration: 300})
+ .up()
+ .perform()
+}
+
+// Waits for the system to show a notification whose text contains `body`, as a banner or,
+// once the banner is gone, a row in Notification Center, and taps it when asked. Returns where
+// it was found; a caller that doesn't tap must close Notification Center when it was opened.
+export const findNotification = async (body: string, {tap}: {tap: boolean}) =>
+ withSpringboard(async (): Promise<'banner' | 'center' | undefined> => {
+ const shown = async () => {
+ const els = await labelContains(body).getElements()
+ // The banner exposes a container, a button and the text; the button takes the tap.
+ for (const e of els) {
+ if ((await e.getAttribute('type')) === 'XCUIElementTypeButton') return e
+ }
+ return els[0]
+ }
+ const inBanner = await waitFor('the notification banner', shown, {interval: 300, timeout: 8000}).catch(() => undefined)
+ if (inBanner) {
+ if (tap) await inBanner.click()
+ return 'banner'
+ }
+ await openNotificationCenter()
+ const inCenter = await waitFor('the notification in Notification Center', shown, {interval: 300, timeout: 8000}).catch(
+ () => undefined
+ )
+ if (!inCenter) {
+ await closeNotificationCenter()
+ return undefined
+ }
+ if (tap) await inCenter.click()
+ return 'center'
+ })
+
+// Notification banners need the user's permission. Grants it through the app's own request
+// and the system prompt when the simulator hasn't been asked yet.
+export const ensureNotificationPermission = async () => {
+ const has = async () => jsEval(`return kbModule('stores/push.tsx').usePushState.getState().hasPermissions`)
+ if (await has()) return
+ await jsEval(`kbModule('stores/push.tsx').usePushState.getState().dispatch.requestPermissions(); return true`)
+ await withSpringboard(async () => {
+ const allow = browser.$('-ios predicate string:type == "XCUIElementTypeButton" AND label == "Allow"')
+ await allow.waitForExist({interval: 250, timeout: 15000})
+ await waitFor(
+ 'the notification prompt to close',
+ async () => {
+ if (!(await allow.isExisting())) return true
+ await allow.click().catch(() => {})
+ return undefined
+ },
+ {interval: 1000, timeout: 15000}
+ )
+ })
+ await waitFor(
+ 'notification permission',
+ async () => {
+ await jsEval(`kbModule('stores/push.tsx').usePushState.getState().dispatch.checkPermissions(); return true`)
+ return (await has()) ? true : undefined
+ },
+ {interval: 1000, timeout: 15000}
+ )
+}
+
+// Opens the smoke user's conversation with themselves and returns its id. The id comes from
+// the inbox layout, and the conversation is opened by id: a keybase://chat/ link resolves
+// the conversation through a lookup that can sit on a placeholder id for a long time.
+export const openSelfConversation = async (username: string) => {
+ // The inbox layout loads with the inbox.
+ await escapeToTabs()
+ await navigateToChat()
+ const convID = await waitFor(
+ 'the self conversation in the inbox',
+ async () =>
+ jsEval(
+ `const layout = kbModule('chat/inbox/layout-state.tsx').useInboxLayoutState.getState().layout
+ const row = layout && layout.smallTeams.find(t => !t.isTeam && t.name === ${JSON.stringify(username)})
+ return row ? row.convID : null`
+ ).then(id => id ?? undefined),
+ {interval: 500, timeout: 30000}
+ )
+ openUrl(`keybase://convid/${convID}`)
+ await waitFor(
+ 'the self conversation to open',
+ async () => {
+ const {screen} = await appSnapshot()
+ return screen?.name === 'chatConversation' && screen.params?.['conversationIDKey'] === convID ? true : undefined
+ },
+ {interval: 500, timeout: 20000}
+ )
+ // JS names the screen before the native push lands. Until it does, the tab root still looks
+ // current, so a reset right after this would skip popping the conversation and leave the tab
+ // bar hidden under it. iPad's split view never shows a back button here, hence the catch.
+ await waitForTestID(T.CHAT_INPUT, 10000)
+ await browser.waitUntil(async () => !(await atTabs()), {interval: 150, timeout: 5000}).catch(() => {})
+ return convID
+}
+
+// A second simulator signed in to the same account sends the message, so it reaches this
+// device as an incoming message from another device.
+export const senderDeviceName = () => process.env['KB_IOS_SENDER_DEVICE'] ?? 'iPadTest'
+
+export const startSenderDevice = async (username: string) => {
+ const name = senderDeviceName()
+ const udid = udidForName(name)
+ const bootedHere = !isBooted(udid)
+ const stop = async () => {
+ await terminateApp(udid).catch(() => {})
+ if (bootedHere) simctl('shutdown', udid)
+ }
+ // Every step after a boot cleans up on failure, so a failed start never leaves a second
+ // simulator running (it render-throttles the one under test).
+ const ready = async () => {
+ if (bootedHere) {
+ simctl('boot', udid)
+ simctl('bootstatus', udid, '-b')
+ }
+ simctl('launch', udid, BUNDLE_ID)
+ await waitFor(
+ `${name} to be logged in as ${username}`,
+ async () =>
+ (await jsEval(
+ `return kbModule('stores/config.tsx').useConfigState.getState().loggedIn &&
+ kbModule('stores/current-user.tsx').useCurrentUserState.getState().username === ${JSON.stringify(username)}`,
+ name
+ ))
+ ? true
+ : undefined,
+ {interval: 1000, timeout: 180000}
+ )
+ }
+ try {
+ await ready()
+ } catch (e) {
+ await stop().catch(() => {})
+ throw e
+ }
+ const send = async (conversationIDKey: string, text: string) =>
+ jsEval(
+ `kbModule('chat/conversation/send-actions.tsx').sendTextToConversation(${JSON.stringify(conversationIDKey)}, ${JSON.stringify(username)}, ${JSON.stringify(text)}); return true`,
+ name
+ )
+ return {send, stop}
+}
+
+// The app's NSLog lines starting with a prefix since a time, read from the simulator's unified
+// log. NSLog lines carry no subsystem, so the prefix is what picks them out.
+export const nsLogSince = (prefix: string, since: Date, udid = deviceUdid()) => {
+ const pad = (n: number) => String(n).padStart(2, '0')
+ const start = `${since.getFullYear()}-${pad(since.getMonth() + 1)}-${pad(since.getDate())} ${pad(since.getHours())}:${pad(since.getMinutes())}:${pad(since.getSeconds())}`
+ return simctl(
+ 'spawn',
+ udid,
+ 'log',
+ 'show',
+ '--start',
+ start,
+ '--info',
+ '--style',
+ 'compact',
+ '--predicate',
+ `eventMessage BEGINSWITH "${prefix}"`
+ )
+ .split('\n')
+ .filter(l => l.includes(prefix))
+}
diff --git a/shared/tests/e2e/ios-appium/helpers/navigate.ts b/shared/tests/e2e/ios-appium/helpers/navigate.ts
index 2733c1b725e2..f88640093e27 100644
--- a/shared/tests/e2e/ios-appium/helpers/navigate.ts
+++ b/shared/tests/e2e/ios-appium/helpers/navigate.ts
@@ -62,7 +62,7 @@ export async function tapSettingsRow(text: string): Promise {
// True once we're at the root of a tab. The tab bar alone isn't proof: on iPad
// it stays visible inside pushed stack screens, so also require that no back
// button (app-custom or native) is present.
-async function atTabs(): Promise {
+export async function atTabs(): Promise {
if (browser.isAndroid) {
// tab() scopes to the native BottomNavigationView's label resource-ids with
// EXACT text — a looser contains-match gets fooled by screen content (the
diff --git a/shared/tests/e2e/ios-appium/lifecycle.test.ts b/shared/tests/e2e/ios-appium/lifecycle.test.ts
new file mode 100644
index 000000000000..cf1adae7bd6f
--- /dev/null
+++ b/shared/tests/e2e/ios-appium/lifecycle.test.ts
@@ -0,0 +1,6 @@
+// App lifecycle flows, run in one session by wdio.lifecycle.conf.ts. Live location runs
+// last: the map posts it makes fail on the maps server and retry from the outbox for up to
+// fifteen minutes, which keeps background task windows open for the flows after it.
+import './flows/lifecycle-app-state.test'
+import './flows/lifecycle-links-push.test'
+import './flows/lifecycle-location.test'
diff --git a/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts b/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts
new file mode 100644
index 000000000000..bc98e388846b
--- /dev/null
+++ b/shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts
@@ -0,0 +1,54 @@
+import * as fs from 'fs'
+import * as path from 'path'
+import {config as base} from './wdio.conf'
+import {BUNDLE_ID, waitForAppState} from './helpers/lifecycle'
+import {escapeToTabs} from './helpers/navigate'
+
+// App lifecycle flows (launch, background, deep links, push, live location). They
+// kill, background and relaunch the app, so they run in their own session instead of
+// the main suite. They validate with app state and logs only: no screenshots.
+const debugDir = process.env['KB_IOS_APPIUM_DEBUG_DIR'] ?? 'tests/results/ios-appium-lifecycle-iphone'
+
+export const config: WebdriverIO.Config = {
+ ...base,
+ specs: [process.env['KB_IOS_SPEC'] ?? './lifecycle.test.ts'],
+ // Flows wait minutes on logs without sending a command (a relaunch for a location change,
+ // a map post that times out), so the session must outlive the default idle timeout.
+ capabilities: (base.capabilities as Array>).map(c => ({
+ ...c,
+ 'appium:newCommandTimeout': 900,
+ })),
+ // A lifecycle regression is often intermittent, so a retry would hide exactly what
+ // these flows exist to catch.
+ mochaOpts: {bail: false, retries: 0, timeout: 420000, ui: 'bdd'},
+ // A failed flow can leave the app in the background or not running; bring it back before
+ // resetting to the tab root, or every later flow fails in its reset.
+ beforeTest: async test => {
+ // eslint-disable-next-line no-console
+ console.log(`▶ ${new Date().toLocaleTimeString()} starting: ${test.title}`)
+ const foreground = 4
+ if ((await browser.execute('mobile: queryAppState', {bundleId: BUNDLE_ID})) !== foreground) {
+ await browser.execute('mobile: activateApp', {bundleId: BUNDLE_ID})
+ }
+ // A just-launched app is still loading its screens; resetting before then misses taps.
+ await waitForAppState('active', undefined, 90000)
+ await escapeToTabs()
+ },
+ afterTest: (test, _context, result: {passed: boolean; duration: number; error?: Error}) => {
+ // eslint-disable-next-line no-console
+ console.log(
+ `${result.passed ? '✓' : '✗'} ${new Date().toLocaleTimeString()} ${test.title} (${(result.duration / 1000).toFixed(1)}s)`
+ )
+ fs.mkdirSync(debugDir, {recursive: true})
+ const slug = `${test.parent} ${test.title}`.replace(/[^\w]+/g, '-').replace(/^-|-$/g, '')
+ fs.writeFileSync(
+ path.join(debugDir, `${slug}.json`),
+ JSON.stringify({
+ durationMs: result.duration,
+ error: result.error?.message ?? null,
+ label: `${test.parent} › ${test.title}`,
+ passed: result.passed,
+ })
+ )
+ },
+}
diff --git a/shared/tests/e2e/run-ios-appium-parallel.sh b/shared/tests/e2e/run-ios-appium-parallel.sh
index 2fdf1e829f13..13788069f8d4 100755
--- a/shared/tests/e2e/run-ios-appium-parallel.sh
+++ b/shared/tests/e2e/run-ios-appium-parallel.sh
@@ -47,7 +47,8 @@ for NAME in "${DEVICES[@]}"; do xcrun simctl boot "$NAME" 2>/dev/null || true; d
for NAME in "${DEVICES[@]}"; do
xcrun simctl bootstatus "$NAME" -b >/dev/null 2>&1 || echo "⚠️ $NAME failed to boot"
done
-open -a Simulator >/dev/null 2>&1 || true
+# Xcode 27 shows simulators in DeviceHub; older Xcodes in Simulator.
+open -a Simulator >/dev/null 2>&1 || open -a DeviceHub >/dev/null 2>&1 || true
BASE_PORT=4723
PIDS=()
diff --git a/shared/tests/e2e/run-ios-appium.sh b/shared/tests/e2e/run-ios-appium.sh
index ad9a48cec104..85be66218516 100755
--- a/shared/tests/e2e/run-ios-appium.sh
+++ b/shared/tests/e2e/run-ios-appium.sh
@@ -69,7 +69,8 @@ for NAME in "${DEVICES[@]}"; do
OVERALL=1
continue
fi
- open -a Simulator >/dev/null 2>&1 || true
+ # Xcode 27 shows simulators in DeviceHub; older Xcodes in Simulator.
+ open -a Simulator >/dev/null 2>&1 || open -a DeviceHub >/dev/null 2>&1 || true
# iPad runs in landscape; phones stay portrait.
ORIENT=""; case "$NAME" in *[Pp]ad*) ORIENT="LANDSCAPE";; esac
diff --git a/shared/tests/e2e/run-ios-lifecycle.sh b/shared/tests/e2e/run-ios-lifecycle.sh
new file mode 100644
index 000000000000..f9ba46d27f7d
--- /dev/null
+++ b/shared/tests/e2e/run-ios-lifecycle.sh
@@ -0,0 +1,49 @@
+#!/bin/bash
+# Run the Appium iOS app lifecycle flows (tests/e2e/ios-appium/lifecycle.test.ts) on one simulator.
+#
+# Usage:
+# KB_SMOKE_USER= tests/e2e/run-ios-lifecycle.sh [device] # default iPhoneTest
+#
+# Needs a debug build of the app installed and signed in as KB_SMOKE_USER, and Metro running:
+# the flows read app state through Metro's inspector and JS logs from .expo/dev/logs/start.log.
+# The receive flow also launches the app on a second simulator signed in to the same account
+# (KB_IOS_SENDER_DEVICE, default iPadTest), booting it if needed and shutting it down after.
+# Results (json only, no screenshots) land in tests/results/ios-appium-lifecycle-.
+#
+# Side effects on the simulators, left in place after the run:
+# - the device under test grants the app location "always" (simctl privacy) and simulated
+# locations are set on it;
+# - the app is granted notification permission through its own prompt, which makes it upload
+# an APNs sandbox push token for KB_SMOKE_USER's device to the Keybase server;
+# - Notification Center keeps the test notifications, and the smoke user's conversation with
+# themselves gains test messages and live location posts;
+# - the sender simulator must already have this build installed and signed in; the flow launches
+# it and shuts it down afterwards if it booted it.
+set -uo pipefail
+SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
+SHARED_DIR="$(cd "$SCRIPT_DIR/../.." && pwd)"
+cd "$SHARED_DIR"
+
+NAME="${1:-iPhoneTest}"
+SLUG="$(echo "$NAME" | tr '[:upper:]' '[:lower:]')"
+DBG="tests/results/ios-appium-lifecycle-$SLUG"
+LOG="tests/results/run-lifecycle-$SLUG.log"
+
+if ! curl -sf http://127.0.0.1:8081/status >/dev/null; then
+ echo "❌ Metro is not running on 8081 (yarn rn:start)"
+ exit 1
+fi
+
+xcrun simctl boot "$NAME" 2>/dev/null || true
+if ! xcrun simctl bootstatus "$NAME" -b >/dev/null 2>&1; then
+ echo "❌ Simulator not found / failed to boot: $NAME"
+ exit 1
+fi
+# Xcode 27 shows simulators in DeviceHub; older Xcodes in Simulator.
+open -a Simulator >/dev/null 2>&1 || open -a DeviceHub >/dev/null 2>&1 || true
+
+rm -rf "$DBG"; mkdir -p "$DBG"
+echo "▶ Running lifecycle flows on $NAME"
+KB_IOS_DEVICE="$NAME" KB_IOS_APPIUM_DEBUG_DIR="$DBG" \
+ yarn wdio run tests/e2e/ios-appium/wdio.lifecycle.conf.ts 2>&1 | tee "$LOG"
+exit "${PIPESTATUS[0]}"