From 4e70bd5c5e52f9229d5f85033e5e7685996dea3e Mon Sep 17 00:00:00 2001 From: chrisnojima Date: Mon, 21 Sep 2026 14:44:20 -0400 Subject: [PATCH] test(ios): simulator lifecycle e2e flows for app state, links, push and location Three Appium flows drive a real simulator through the lifecycle work in this stack: app-state transitions and the local http server, deep links and push taps, and native live location. The shared harness reads the Metro and simulator logs rather than driving the UI to assert, so a flow fails on what the app actually did. The app-state flow matches "Srv: start: addr: " and "kbhttp.Srv: server starting on: ", which are the lines the reconciling server logs. tsc now type-checks the suite as a third project, so an e2e file that stops compiling fails lint:all instead of only failing when someone runs the flows. eslint stops walking android/app/build, a gitignored gradle output that a local test run leaves behind. --- shared/eslint.config.mjs | 1 + shared/package.json | 3 +- .../flows/lifecycle-app-state.test.ts | 184 ++++++ .../flows/lifecycle-links-push.test.ts | 214 +++++++ .../flows/lifecycle-location.test.ts | 213 +++++++ shared/tests/e2e/ios-appium/helpers/app.ts | 18 + .../tests/e2e/ios-appium/helpers/lifecycle.ts | 558 ++++++++++++++++++ .../tests/e2e/ios-appium/helpers/navigate.ts | 2 +- shared/tests/e2e/ios-appium/lifecycle.test.ts | 6 + .../e2e/ios-appium/wdio.lifecycle.conf.ts | 54 ++ shared/tests/e2e/run-ios-appium-parallel.sh | 3 +- shared/tests/e2e/run-ios-appium.sh | 3 +- shared/tests/e2e/run-ios-lifecycle.sh | 49 ++ shared/tools/sim-push-chat.sh | 3 +- 14 files changed, 1305 insertions(+), 6 deletions(-) create mode 100644 shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts create mode 100644 shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts create mode 100644 shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts create mode 100644 shared/tests/e2e/ios-appium/helpers/lifecycle.ts create mode 100644 shared/tests/e2e/ios-appium/lifecycle.test.ts create mode 100644 shared/tests/e2e/ios-appium/wdio.lifecycle.conf.ts create mode 100644 shared/tests/e2e/run-ios-lifecycle.sh diff --git a/shared/eslint.config.mjs b/shared/eslint.config.mjs index da278a67355a..a58274791bf4 100644 --- a/shared/eslint.config.mjs +++ b/shared/eslint.config.mjs @@ -7,6 +7,7 @@ import tseslint from 'typescript-eslint' const ignores = [ '**/*.d.ts', + 'android/app/build/**', 'babel.config.js', 'common-adapters/icon.constants-gen.desktop.tsx', 'common-adapters/icon.constants-gen.native.tsx', diff --git a/shared/package.json b/shared/package.json index 81d76e25e73f..d32996bb1ab5 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..f1f824287fdb --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-app-state.test.ts @@ -0,0 +1,184 @@ +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): "lifecycle: : …" per native UI report, +// "MobileAppState.Update: useful update: " per Go app state change, +// "Srv: start: 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): "app focus changed: " when the shell store's app state changes. +// The cold launch test requires a match of each server line, so an empty result in the +// Notification Center test means no restart, not a pattern that no longer matches Go's log. +const httpSrvStarted = /Srv: start: addr: / +const httpSrvStartedAny = /kbhttp\.Srv: server starting on: / +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) + const goLines = await waitForLinesInOrder('Go to report the launch', () => goLogSince(goMark), [ + /lifecycle: uiInactive: /, + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: uiActive: /, + ]) + expect(goLines).toHaveLength(3) + // 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() + await backgroundApp() + await waitForLinesInOrder('Go to go to the background', () => goLogSince(goMark), [ + /lifecycle: uiInactive: /, + /lifecycle: uiBackground: /, + ]) + + 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) + await waitForLinesInOrder('Go to return to the foreground', () => goLogSince(goMark), [ + /lifecycle: uiBackground: /, + /lifecycle: uiInactive: /, + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: uiActive: /, + ]) + 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 lines = goLogSince(goMark) + const backgrounds = findLines(lines, /lifecycle: uiBackground: /).length + const actives = findLines(lines, /lifecycle: uiActive: /).length + return backgrounds >= cycles && actives >= cycles && goAppStateUpdates(goMark).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 and keeps images served', async () => { + const user = requireSmokeUser() + const before = await waitForAppState('active') + const goMark = goLogMark() + const metroMark = metroLogMark() + + await openNotificationCenter() + const inactive = await waitForAppState('inactive', undefined, 15000) + await waitForLinesInOrder('Go to go inactive', () => goLogSince(goMark), [ + /MobileAppState\.Update: useful update: INACTIVE/, + /lifecycle: uiInactive: /, + ]) + // INACTIVE is not background: the image server keeps serving at the same address. + expect(inactive.httpSrv.address).toBe(before.httpSrv.address) + await waitForAvatar200(user) + expect(findLines(goLogSince(goMark), /lifecycle: uiBackground: /)).toEqual([]) + + await closeNotificationCenter() + await waitForAppState('active', undefined, 15000) + await waitForLinesInOrder('Go to become active again', () => goLogSince(goMark), [ + /MobileAppState\.Update: useful update: FOREGROUND/, + /lifecycle: uiActive: /, + ]) + const focus = findLines(metroClientLogSince(metroMark), /app focus changed: /) + expect(focus).toEqual([ + expect.stringMatching(/app focus changed: inactive$/), + expect.stringMatching(/app focus changed: active$/), + ]) + expect(findLines(goLogSince(goMark), httpSrvStartedAny)).toEqual([]) + expect((await appSnapshot()).httpSrv.address).toBe(before.httpSrv.address) + }) +}) 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..2aa0657a74c1 --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-links-push.test.ts @@ -0,0 +1,214 @@ +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. +const waitForBackground = async (goMark: ReturnType) => + waitForLinesInOrder('the app to enter the background', () => goLogSince(goMark), [/lifecycle: uiBackground: /]) + +// 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): "lifecycle: uiBackground: " 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: /) + + 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() + const body = `e2e-push-foreground-${Date.now()}` + sendPush(pushFor(body)) + + await browser.pause(5000) + expect(tapLines(metroClientLogSince(metroMark))).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') + expect(tapLines(metroClientLogSince(metroMark))).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..8a3cd491737a --- /dev/null +++ b/shared/tests/e2e/ios-appium/flows/lifecycle-location.test.ts @@ -0,0 +1,213 @@ +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, + goLogMark, + goLogSince, + jsEval, + metroBundlingStartedSince, + metroClientLogSince, + metroLogMark, + nativeLogSince, + openSelfConversation, + setLocation, + simctl, + terminateApp, + waitFor, + waitForAppState, + waitForLinesInOrder, + BUNDLE_ID, +} from '../helpers/lifecycle' + +// Live location on iOS runs natively: Go asks the Swift watcher to start, each fix goes +// straight to Go, and Go 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 Go records (native hands it every fix), +// - "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, +// - "lifecycle: acquire: liveLocation hold " when a fix holds a backgrounded app up. +// The relaunch flow also reads Metro's start.log: a background launch must start no JS at all, so +// neither a bundle request nor a JS log line may appear while it runs, and activating the app +// afterwards must make both appear from the same mark. +// And in the app's unified log (com.keybase.app, category location): "starting location updates" +// and "stopping location updates" when the Swift watcher turns the OS service on and off. +// 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', () => nativeLogSince('location', 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), [ + /lifecycle: uiBackground: /, + ]) + // JS doesn't run in the background, so anything after this comes from native. + 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(findLines(goLogSince(moveMark), /lifecycle: ui(Inactive|Active): /)).toEqual([]) + await activateApp() + await waitForAppState('active') + }) + + it('a move relaunches the app after it was killed and posts from the background', async function () { + // iOS delivers significant location changes on its own schedule: seconds to minutes. The + // budget covers all three waits below: the relaunch, the Go log, and the control's activation. + this.timeout(510000) + 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(moves[2]!.lat, moves[2]!.lon) + + // iOS relaunches the app in the background for the significant location change. + const pid = await waitFor('iOS to relaunch the app for the move', () => appPid(), {interval: 1000, timeout: 300000}) + const lines = await waitForLinesInOrder( + 'the relaunched app to restore sharing and post the move', + () => goLogSince(goMark), + [ + /LiveLocationTracker: restoreLocked: restored [1-9]\d* trackers/, + /\+ LiveLocationTracker: LocationUpdate/, + /tracker\[\d+\]: got coords/, + /\+ LiveLocationTracker: updateMapUnfurl/, + ], + 120000 + ) + expect(lines).toHaveLength(4) + // Launched for location, not by the user: no scene came to the foreground. + const relaunched = goLogSince(goMark) + expect(findLines(relaunched, /lifecycle: acquire: liveLocation hold /).length).toBeGreaterThan(0) + expect(findLines(relaunched, /lifecycle: ui(Inactive|Active): /)).toEqual([]) + expect(appPid()).toBe(pid) + // No scene connected, so React Native never started: no bundle request and no JS logging. + 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) + }) + + 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', () => nativeLogSince('location', 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) + }) +}) 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..5d20fe8c46b0 --- /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 own os_log lines for a category (subsystem com.keybase.app) since a time, read from +// the simulator's unified log. +export const nativeLogSince = (category: 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', + `subsystem == "com.keybase.app" AND category == "${category}"` + ) + .split('\n') + .filter(l => l.includes(`[com.keybase.app:${category}]`)) +} 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]}" diff --git a/shared/tools/sim-push-chat.sh b/shared/tools/sim-push-chat.sh index cf4b48293b46..4de2e06e7740 100755 --- a/shared/tools/sim-push-chat.sh +++ b/shared/tools/sim-push-chat.sh @@ -12,8 +12,7 @@ PAYLOAD=$(cat <