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 <