diff --git a/.github/workflows/e2e-ios.yml b/.github/workflows/e2e-ios.yml index 343b0c6f..72d77045 100644 --- a/.github/workflows/e2e-ios.yml +++ b/.github/workflows/e2e-ios.yml @@ -76,3 +76,12 @@ jobs: path: artifacts/e2e/ios/** if-no-files-found: warn retention-days: 14 + + - name: Upload simulator diagnostic reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: e2e-ios-${{ matrix.lane }}-diagreports + path: ~/Library/Logs/DiagnosticReports/** + if-no-files-found: ignore + retention-days: 14 diff --git a/.github/workflows/ios-testflight.yml b/.github/workflows/ios-testflight.yml index 520e993c..c7afdcc5 100644 --- a/.github/workflows/ios-testflight.yml +++ b/.github/workflows/ios-testflight.yml @@ -13,7 +13,74 @@ concurrency: cancel-in-progress: false jobs: + pre-flight-tests: + name: Release-config UI smoke gate + runs-on: macos-26 + timeout-minutes: 30 + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + + - name: Install dependencies + run: npm ci + + - name: Install XcodeGen + env: + HOMEBREW_NO_AUTO_UPDATE: "1" + shell: bash + run: | + for attempt in 1 2 3; do + if brew install xcodegen; then + exit 0 + fi + if [[ "$attempt" -lt 3 ]]; then + sleep $((attempt * 10)) + fi + done + echo "Failed to install xcodegen after 3 attempts." + exit 1 + + - name: Generate Xcode project + working-directory: ios + run: xcodegen generate + + - name: Run iOS smoke lane in Release configuration + # iOS UI tests run fully in-app via SP_UI_TEST_MODE=1 (set in + # StillPointAppUITests.makeApp); no real backend is reached, so + # we deliberately omit E2E_BASE_URL and credentials here. + env: + E2E_ENV: ci + E2E_SECRETS_REQUIRED: "false" + IOS_TEST_CONFIGURATION: "Release" + run: npm run e2e:ios:smoke + + - name: Upload simulator diagnostic reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-testflight-preflight-diagreports + path: ~/Library/Logs/DiagnosticReports/** + if-no-files-found: ignore + retention-days: 14 + + - name: Upload xcresult bundle + if: always() + uses: actions/upload-artifact@v4 + with: + name: ios-testflight-preflight-xcresult + path: artifacts/e2e/ios/** + if-no-files-found: warn + retention-days: 14 + build-and-upload: + needs: pre-flight-tests runs-on: macos-26 timeout-minutes: 30 diff --git a/ios/QA_CHECKLIST.md b/ios/QA_CHECKLIST.md index 09a74255..959635b2 100644 --- a/ios/QA_CHECKLIST.md +++ b/ios/QA_CHECKLIST.md @@ -44,3 +44,19 @@ QA owner: iOS QA DRI - [x] Regression/QA pass for release candidate is completed. - [x] App Store metadata/versioning/release notes are finalized. - [ ] Updated iOS build is submitted to App Store by end of week. + +## Pre-tag manual smoke (Issue #253) + +Run these on a Release-signed device install **before** pushing the `ios-v*` tag. +The CI pre-flight gate runs the same suite in Release config on simulator, but a +real-device pass before tagging is the final guard against optimization/codegen +bugs that only surface on hardware (e.g. the build 8 Begin-tap crash). + +- [ ] Install the just-uploaded TestFlight build on a physical device via the TestFlight app (so QA exercises the exact archive that will ship). Alternative if TestFlight processing is delayed: in Xcode, **Product → Archive**, then **Distribute App → Custom → Ad Hoc** to export an `.ipa`, then drag the `.ipa` onto your device in **Window → Devices and Simulators**. A bare `.xcarchive` is not directly installable. +- [ ] Sign in with a known account. +- [ ] Tap **Begin** and verify the timer screen loads without crash. +- [ ] Let the session run to completion (or End Early) and verify CompletionView appears. +- [ ] Type a session note, tap **Save note**, and verify the green "Saved" indicator. +- [ ] Tap **Return** and verify Home reflects the new day count. +- [ ] Pull crash logs from device after the run (Settings → Privacy & Security → Analytics & Improvements → Analytics Data) and verify no `StillPointApp-*.ips` from this session. +- [ ] Record the device model + iOS version in the release PR before pushing the tag. diff --git a/ios/StillPointApp/ViewModels/AppViewModel.swift b/ios/StillPointApp/ViewModels/AppViewModel.swift index 333464cc..d8135494 100644 --- a/ios/StillPointApp/ViewModels/AppViewModel.swift +++ b/ios/StillPointApp/ViewModels/AppViewModel.swift @@ -70,7 +70,16 @@ final class AppViewModel { func checkAuth() async { let startedAt = Date() isLoading = true - defer { isLoading = false } + defer { + isLoading = false + // Diagnostic for issue #266 / PR #261, gated on UI-test mode so we + // don't leak PII (the user's email) in production logs. Only the + // UI-test fixture sets SP_UI_TEST_MODE=1, so this print is silent + // for real users. + if ProcessInfo.processInfo.environment["SP_UI_TEST_MODE"] == "1" { + print("[E2E-DIAG] checkAuth.done currentView=\(viewSlug(currentView)) currentUser=\(currentUser?.email ?? "nil") elapsedMs=\(Int(Date().timeIntervalSince(startedAt) * 1_000))") + } + } do { if let user = try await APIClient.shared.me() { @@ -97,6 +106,21 @@ final class AppViewModel { lastColdStartAuthCheckMs = Int(Date().timeIntervalSince(startedAt) * 1_000) } + private func viewSlug(_ view: AppView) -> String { + switch view { + case .auth: return "auth" + case .home: return "home" + case .session: return "session" + case .buddyHub: return "buddyHub" + case .buddySession: return "buddySession" + case .completion: return "completion" + case .history: return "history" + case .journal: return "journal" + case .board: return "board" + case .settings: return "settings" + } + } + func didLogin(user: UserDTO) { currentUser = user currentView = .home diff --git a/ios/StillPointShared/Sources/StillPointShared/APIClient.swift b/ios/StillPointShared/Sources/StillPointShared/APIClient.swift index f1727eb7..bee4a928 100644 --- a/ios/StillPointShared/Sources/StillPointShared/APIClient.swift +++ b/ios/StillPointShared/Sources/StillPointShared/APIClient.swift @@ -39,6 +39,9 @@ public actor APIClient { defaults.removeObject(forKey: uiTestStoreDefaultsKey) AudioEngine.resetPersistedPrefs() Self.clearPersistedSessionArtifacts(session: session) + // Flush the in-memory cache to cfprefsd so the immediately + // following read sees the cleared state. Issue #266 follow-up. + defaults.synchronize() } if let persistedData = defaults.data(forKey: uiTestStoreDefaultsKey), @@ -48,6 +51,15 @@ public actor APIClient { self.uiTestStore = UITestStore.makeDefault(seedAuthenticated: parsedUITestConfig.seedAuthenticated) persistUITestStore() } + + // Diagnostic for issue #266 / PR #261 — log what the app actually + // observed at init so we can see in xcresult logs whether the env + // vars + reset-store contract are doing what we expect on macos-26 + // CI runners. Cheap, noise-only in test builds since UITestConfig + // is nil in production. + print("[E2E-DIAG] APIClient.init uiTestMode=YES seedAuth=\(parsedUITestConfig.seedAuthenticated) resetStore=\(parsedUITestConfig.resetStore) forceOffline=\(parsedUITestConfig.forceLaunchOffline) forceTokenExpired=\(parsedUITestConfig.forceTokenExpired) finalIsAuthenticated=\(uiTestStore?.isAuthenticated ?? false)") + } else { + print("[E2E-DIAG] APIClient.init uiTestMode=NO") } } diff --git a/scripts/e2e/run-ios-tests.sh b/scripts/e2e/run-ios-tests.sh index bbbea057..ad833a82 100755 --- a/scripts/e2e/run-ios-tests.sh +++ b/scripts/e2e/run-ios-tests.sh @@ -4,7 +4,7 @@ set -euo pipefail LANE="${1:-smoke}" MAX_RETRIES="${2:-1}" ATTEMPT=1 -UI_TESTS_DIR="${IOS_UI_TESTS_DIR:-ios/StillPointUITests}" +UI_TESTS_DIR="${IOS_UI_TESTS_DIR:-ios/StillPointAppUITests}" SECRETS_REQUIRED="${E2E_SECRETS_REQUIRED:-true}" STATUS_FILE="artifacts/e2e/ios/${LANE}.status" FINAL_STATUS="failed" @@ -28,9 +28,13 @@ if [[ "${E2E_ENV:-}" == "prod" || "${E2E_BASE_URL:-}" =~ still-point\.me ]]; the fi if [[ ! -d "${UI_TESTS_DIR}" ]]; then - echo "iOS E2E suite not present (${UI_TESTS_DIR}); skipping ${LANE} lane." - FINAL_STATUS="skipped" - exit 0 + # Hard-fail: a missing test directory was previously silently treated as "skipped" + # which let broken-on-Release builds (e.g. build 8 / issue #250) ship with green + # CI. The contract is that the suite must be present and runnable. + echo "::error::iOS E2E suite not present at expected path '${UI_TESTS_DIR}'." + echo "::error::Set IOS_UI_TESTS_DIR to override, or restore the test bundle." + FINAL_STATUS="failed" + exit 1 fi if [[ "${SECRETS_REQUIRED}" == "true" ]] && [[ -z "${E2E_TEST_USER_EMAIL:-}" || -z "${E2E_TEST_USER_PASSWORD:-}" ]]; then @@ -43,21 +47,65 @@ if [[ "${E2E_TEST_USER_EMAIL:-}" =~ @still-point\.me$ ]]; then exit 1 fi -TEST_PLAN="${IOS_TEST_PLAN:-StillPointE2E}" -TEST_DESTINATION="${IOS_TEST_DESTINATION:-platform=iOS Simulator,name=iPhone 16,OS=latest}" +resolve_test_destination() { + # Honor explicit caller override first. + if [[ -n "${IOS_TEST_DESTINATION:-}" ]]; then + echo "${IOS_TEST_DESTINATION}" + return + fi + + # Pick the first iPhone simulator that's actually installed on this host. + # Prevents the "iPhone 16 doesn't exist on macos-26 runners" / "iPhone 17 will + # eventually disappear too" infra-failure mode CodeAnt flagged. We grep simctl + # output with a trailing " (" so "iPhone 17" doesn't accidentally match + # "iPhone 17 Pro". + local simctl_output + simctl_output="$(xcrun simctl list devices available 2>/dev/null || true)" + local preferred=( + "iPhone 17 Pro" + "iPhone 17" + "iPhone 17 Pro Max" + "iPhone 17e" + "iPhone 16e" + "iPhone 16 Pro" + "iPhone 16" + "iPhone 15 Pro" + "iPhone 15" + "iPhone Air" + ) + local name + for name in "${preferred[@]}"; do + if grep -F " ${name} (" <<<"${simctl_output}" >/dev/null 2>&1; then + echo "platform=iOS Simulator,name=${name},OS=latest" + return + fi + done + + # Last-resort fallback: keep the prior hardcoded default so we fail with a + # clear xcodebuild error instead of an empty destination. + echo "platform=iOS Simulator,name=iPhone 17,OS=latest" +} + +TEST_DESTINATION="$(resolve_test_destination)" +echo "Using iOS test destination: ${TEST_DESTINATION}" TEST_SCHEME="${IOS_TEST_SCHEME:-StillPoint}" PROJECT_PATH="${IOS_TEST_PROJECT:-ios/StillPoint.xcodeproj}" +TEST_CONFIGURATION="${IOS_TEST_CONFIGURATION:-}" resolve_test_target() { + # The test target and class are both named StillPointAppUITests. + # Smoke runs the full Begin -> Session -> Complete -> History golden path + # (the test that would have caught issue #250 if the runner had actually + # been executing tests). Critical runs the full UI test class. case "$1" in smoke) - echo "StillPointUITests/SmokeTests" + echo "StillPointAppUITests/StillPointAppUITests/testLaunchLoginCompleteSessionAndHistoryPersistence" ;; critical) - echo "StillPointUITests/CriticalPathTests" + echo "StillPointAppUITests/StillPointAppUITests" ;; *) - echo "StillPointUITests/${1}" + echo "StillPointAppUITests/${1}" ;; esac } @@ -69,14 +117,20 @@ run_lane() { test_target="$(resolve_test_target "${lane_tag}")" result_bundle="artifacts/e2e/ios/${lane_tag}-attempt-${ATTEMPT}.xcresult" + local -a xcodebuild_args=( + test + -project "${PROJECT_PATH}" + -scheme "${TEST_SCHEME}" + -destination "${TEST_DESTINATION}" + -resultBundlePath "${result_bundle}" + -only-testing:"${test_target}" + ) + if [[ -n "${TEST_CONFIGURATION}" ]]; then + xcodebuild_args+=(-configuration "${TEST_CONFIGURATION}") + fi + set +e - xcodebuild test \ - -project "${PROJECT_PATH}" \ - -scheme "${TEST_SCHEME}" \ - -testPlan "${TEST_PLAN}" \ - -destination "${TEST_DESTINATION}" \ - -resultBundlePath "${result_bundle}" \ - -only-testing:"${test_target}" \ + xcodebuild "${xcodebuild_args[@]}" \ | tee "artifacts/e2e/ios/${lane_tag}-attempt-${ATTEMPT}.log" local status=$? set -e