Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .github/workflows/e2e-ios.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
67 changes: 67 additions & 0 deletions .github/workflows/ios-testflight.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
16 changes: 16 additions & 0 deletions ios/QA_CHECKLIST.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
26 changes: 25 additions & 1 deletion ios/StillPointApp/ViewModels/AppViewModel.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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))")
}
}
Comment on lines +73 to +82
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Remove PII from auth diagnostic logging and gate it to UI-test mode.

Line 78 logs currentUser email directly. That’s user identifier leakage in runtime logs outside CI test contexts.

Proposed fix
     func checkAuth() async {
         let startedAt = Date()
         isLoading = true
         defer {
             isLoading = false
-            // Diagnostic for issue `#266` / PR `#261` — log the post-checkAuth view
-            // state so we can correlate with APIClient.init's [E2E-DIAG] line
-            // when iOS UI tests fail on the auth-screen waitForExistence.
-            print("[E2E-DIAG] checkAuth.done currentView=\(viewSlug(currentView)) currentUser=\(currentUser?.email ?? "nil") elapsedMs=\(Int(Date().timeIntervalSince(startedAt) * 1_000))")
+            if shouldEmitE2EDiagnostics {
+                print("[E2E-DIAG] checkAuth.done currentView=\(viewSlug(currentView)) currentUserPresent=\(currentUser != nil) elapsedMs=\(Int(Date().timeIntervalSince(startedAt) * 1_000))")
+            }
         }
@@
     }
+
+    private var shouldEmitE2EDiagnostics: Bool {
+        let raw = ProcessInfo.processInfo.environment["SP_UI_TEST_MODE"]?.lowercased()
+        return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
+    }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
defer {
isLoading = false
// Diagnostic for issue #266 / PR #261 — log the post-checkAuth view
// state so we can correlate with APIClient.init's [E2E-DIAG] line
// when iOS UI tests fail on the auth-screen waitForExistence.
print("[E2E-DIAG] checkAuth.done currentView=\(viewSlug(currentView)) currentUser=\(currentUser?.email ?? "nil") elapsedMs=\(Int(Date().timeIntervalSince(startedAt) * 1_000))")
}
defer {
isLoading = false
if shouldEmitE2EDiagnostics {
print("[E2E-DIAG] checkAuth.done currentView=\(viewSlug(currentView)) currentUserPresent=\(currentUser != nil) elapsedMs=\(Int(Date().timeIntervalSince(startedAt) * 1_000))")
}
}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@ios/StillPointApp/ViewModels/AppViewModel.swift` around lines 73 - 79, Remove
PII from the diagnostic print in the defer block of checkAuth by stopping direct
logging of currentUser.email and only emitting a non-identifying marker when not
in UI-test mode; instead, when running UI tests (detect via a test flag like
ProcessInfo.processInfo.arguments/Environment or an existing isUITest flag),
allow diagnostic output but mask the email (e.g., replace with "<redacted>" or a
hashed/obfuscated token). Update the defer block where isLoading,
viewSlug(currentView), currentUser and startedAt are referenced so the print
uses a redactedCurrentUser variable (or omits currentUser) unless the UI-test
gate is true, and ensure you reference viewSlug(currentView), currentUser, and
startedAt when making this change.


do {
if let user = try await APIClient.shared.me() {
Expand All @@ -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
Expand Down
12 changes: 12 additions & 0 deletions ios/StillPointShared/Sources/StillPointShared/APIClient.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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),
Expand All @@ -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")
}
Comment on lines +61 to 63
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: This diagnostic print runs whenever UI-test mode is not enabled, which includes normal production app launches. That causes unconditional runtime logging in non-test builds and contradicts the stated intent that diagnostics are test-only. Gate this branch behind a debug/test condition (or remove it) so production builds do not emit E2E diagnostic logs. [logic error]

Severity Level: Minor 🧹
- ⚠️ Production launches emit internal "[E2E-DIAG]" diagnostic log lines.
- ⚠️ Test-only diagnostics pollute normal device and crash logs.
- ⚠️ Minor startup overhead from unconditional console printing.
Steps of Reproduction ✅
1. Launch the iOS app built from this PR in a normal (non-UI-test) configuration so that
the main entry point `StillPointApp` runs (ios/StillPointApp/StillPointApp.swift:5-13),
which creates a `WindowGroup` showing `RootView()`.

2. Observe that `RootView` (ios/StillPointApp/Views/RootView.swift:4-7) holds `@State
private var appVM = AppViewModel()` and attaches a `.task { await appVM.checkAuth() }`
modifier (RootView.swift:12-13), so `AppViewModel.checkAuth()` is invoked on initial view
appearance on every cold start.

3. In `checkAuth()` (ios/StillPointApp/ViewModels/AppViewModel.swift:70-87), the method
calls `APIClient.shared.me()` (AppViewModel.swift:85), which lazily initializes the
singleton `APIClient.shared` defined in `APIClient`
(ios/StillPointShared/Sources/StillPointShared/APIClient.swift:5-8, 16-23).

4. During `APIClient.init` (APIClient.swift:16-63), `UITestConfig.fromProcessInfo()` is
evaluated (line 29) and typically returns `nil` for normal production/dev launches as
described by the comment at lines 55-59 ("UITestConfig is nil in production"). That causes
the `else` branch at lines 61-63 to execute, which runs `print("[E2E-DIAG] APIClient.init
uiTestMode=NO")` unconditionally for every non-UI-test startup, emitting E2E diagnostic
logging in non-test builds.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** ios/StillPointShared/Sources/StillPointShared/APIClient.swift
**Line:** 61:63
**Comment:**
	*Logic Error: This diagnostic print runs whenever UI-test mode is not enabled, which includes normal production app launches. That causes unconditional runtime logging in non-test builds and contradicts the stated intent that diagnostics are test-only. Gate this branch behind a debug/test condition (or remove it) so production builds do not emit E2E diagnostic logs.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

Expand Down
86 changes: 70 additions & 16 deletions scripts/e2e/run-ios-tests.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand All @@ -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"
Comment on lines +64 to +86
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: Simulator auto-selection only checks a fixed shortlist and then falls back to a hardcoded iPhone 17. On hosts where none of these names exist (but other iPhone simulators do), the script will fail unnecessarily instead of using an available simulator. Add a true generic fallback that parses simctl output for any available iPhone device before defaulting to a hardcoded model. [possible bug]

Severity Level: Major ⚠️
- ⚠️ iOS smoke lane can fail on future runner images.
- ⚠️ TestFlight pre-flight gate may block uploads unnecessarily.
- ⚠️ Local `npm run e2e:ios:smoke` can fail on some Macs.
Steps of Reproduction ✅
1. Note that `resolve_test_destination()` in `scripts/e2e/run-ios-tests.sh:50-87` calls
`xcrun simctl list devices available` and stores the output in `simctl_output` at lines
62-63.

2. The function then iterates a fixed `preferred` list of device names (`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`) defined at
`scripts/e2e/run-ios-tests.sh:64-75`, picking the first name whose exact line appears in
`simctl_output`.

3. On a host whose available simulators (from `xcrun simctl list devices available`)
include only other iPhone models (for example, `iPhone 14` or a future `iPhone 18`) and
none of the hardcoded `preferred` names, the loop at lines 77-81 finds no match and falls
through.

4. In that case, `resolve_test_destination()` returns the hardcoded fallback `platform=iOS
Simulator,name=iPhone 17,OS=latest` at `scripts/e2e/run-ios-tests.sh:84-86`, even though
an `iPhone` simulator is available but named differently.

5. When `run_ios_tests.sh` is invoked via `npm run e2e:ios:smoke` (defined in
`package.json:23` and used by `.github/workflows/ios-testflight.yml:15-23`),
`TEST_DESTINATION` (lines 89-90) is set to the unsupported `iPhone 17` destination,
causing the `xcodebuild` invocation at `scripts/e2e/run-ios-tests.sh:120-127` to fail with
"No devices are available that match the requested destination", and the
`pre-flight-tests` job fails even though a valid iPhone simulator exists.

Fix in Cursor | Fix in VSCode Claude

(Use Cmd/Ctrl + Click for best experience)

Prompt for AI Agent 🤖
This is a comment left during a code review.

**Path:** scripts/e2e/run-ios-tests.sh
**Line:** 64:86
**Comment:**
	*Possible Bug: Simulator auto-selection only checks a fixed shortlist and then falls back to a hardcoded `iPhone 17`. On hosts where none of these names exist (but other iPhone simulators do), the script will fail unnecessarily instead of using an available simulator. Add a true generic fallback that parses `simctl` output for any available iPhone device before defaulting to a hardcoded model.

Validate the correctness of the flagged issue. If correct, How can I resolve this? If you propose a fix, implement it and please make it concise.
Once fix is implemented, also check other comments on the same PR, and ask user if the user wants to fix the rest of the comments as well. if said yes, then fetch all the comments validate the correctness and implement a minimal fix
👍 | 👎

}

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
}
Expand All @@ -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
Expand Down
Loading