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
40 changes: 30 additions & 10 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: '26.2'
xcode-version: '26.3'

# Cache Homebrew packages
# Note: Static version key (brew-v1) - increment manually when brew dependencies change
Expand Down Expand Up @@ -117,7 +117,17 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: '26.2'
xcode-version: '26.3'

- name: Install iOS Simulator Runtime
run: |
echo "Installing iOS simulator runtime..."
# Initialize CoreSimulator service before downloading
xcrun simctl list runtimes > /dev/null 2>&1 || true
sleep 5
xcodebuild -downloadPlatform iOS
echo "Available iOS simulator runtimes:"
xcrun simctl list runtimes | grep iOS

# Cache Homebrew packages
# Note: Static version key (brew-v1) - increment manually when brew dependencies change
Expand Down Expand Up @@ -174,16 +184,16 @@ jobs:

- name: Pre-boot simulator
run: |
echo "Pre-booting simulator..."
xcrun simctl boot "iPhone Air" || echo "Simulator already booted or unavailable"
chmod +x scripts/boot-simulator.sh
./scripts/boot-simulator.sh "iPhone Air" 30

- name: Build for testing (PulseDev)
run: |
echo "Building PulseDev scheme for testing..."
xcodebuild build-for-testing \
-project Pulse.xcodeproj \
-scheme PulseDev \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=latest' \
-derivedDataPath ./DerivedData \
-skipMacroValidation \
ENABLE_ONLY_ACTIVE_RESOURCES=NO
Expand All @@ -194,7 +204,7 @@ jobs:
xcodebuild build-for-testing \
-project Pulse.xcodeproj \
-scheme PulseSnapshotTests \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=latest' \
-derivedDataPath ./DerivedData \
-skipMacroValidation \
ENABLE_ONLY_ACTIVE_RESOURCES=NO
Expand Down Expand Up @@ -237,7 +247,17 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: '26.2'
xcode-version: '26.3'

- name: Install iOS Simulator Runtime
run: |
echo "Installing iOS simulator runtime..."
# Initialize CoreSimulator service before downloading
xcrun simctl list runtimes > /dev/null 2>&1 || true
sleep 5
xcodebuild -downloadPlatform iOS
echo "Available iOS simulator runtimes:"
xcrun simctl list runtimes | grep iOS

# Cache Homebrew packages
# Note: Static version key (brew-v1) - increment manually when brew dependencies change
Expand Down Expand Up @@ -280,8 +300,8 @@ jobs:

- name: Pre-boot simulator
run: |
echo "Pre-booting simulator..."
xcrun simctl boot "iPhone Air" || echo "Simulator already booted or unavailable"
chmod +x scripts/boot-simulator.sh
./scripts/boot-simulator.sh "iPhone Air" 30

- name: Run ${{ matrix.test-name }}
timeout-minutes: ${{ matrix.timeout }}
Expand All @@ -293,7 +313,7 @@ jobs:
-project Pulse.xcodeproj \
-scheme ${{ matrix.scheme }} \
-only-testing:${{ matrix.test-target }} \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=latest' \
-derivedDataPath ./DerivedData \
-resultBundlePath test-results/${{ matrix.artifact-name }}.xcresult \
-disableAutomaticPackageResolution \
Expand Down
52 changes: 41 additions & 11 deletions .github/workflows/scheduled-tests.yml
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,17 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: '26.2'
xcode-version: '26.3'

- name: Install iOS Simulator Runtime
run: |
echo "Installing iOS simulator runtime..."
# Initialize CoreSimulator service before downloading
xcrun simctl list runtimes > /dev/null 2>&1 || true
sleep 5
xcodebuild -downloadPlatform iOS
echo "Available iOS simulator runtimes:"
xcrun simctl list runtimes | grep iOS

# Cache Homebrew packages
# Note: Static version key (brew-v1) - increment manually when brew dependencies change
Expand Down Expand Up @@ -79,16 +89,16 @@ jobs:

- name: Pre-boot simulator
run: |
echo "Pre-booting simulator..."
xcrun simctl boot "iPhone Air" || echo "Simulator already booted or unavailable"
chmod +x scripts/boot-simulator.sh
./scripts/boot-simulator.sh "iPhone Air" 30

- name: Build for testing (PulseDev)
run: |
echo "Building PulseDev scheme for testing..."
xcodebuild build-for-testing \
-project Pulse.xcodeproj \
-scheme PulseDev \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=latest' \
-derivedDataPath ./DerivedData \
-skipMacroValidation

Expand All @@ -98,7 +108,7 @@ jobs:
xcodebuild build-for-testing \
-project Pulse.xcodeproj \
-scheme PulseSnapshotTests \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=latest' \
-derivedDataPath ./DerivedData \
-skipMacroValidation

Expand Down Expand Up @@ -140,7 +150,17 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: '26.2'
xcode-version: '26.3'

- name: Install iOS Simulator Runtime
run: |
echo "Installing iOS simulator runtime..."
# Initialize CoreSimulator service before downloading
xcrun simctl list runtimes > /dev/null 2>&1 || true
sleep 5
xcodebuild -downloadPlatform iOS
echo "Available iOS simulator runtimes:"
xcrun simctl list runtimes | grep iOS

# Cache Homebrew packages
# Note: Static version key (brew-v1) - increment manually when brew dependencies change
Expand Down Expand Up @@ -183,8 +203,8 @@ jobs:

- name: Pre-boot simulator
run: |
echo "Pre-booting simulator..."
xcrun simctl boot "iPhone Air" || echo "Simulator already booted or unavailable"
chmod +x scripts/boot-simulator.sh
./scripts/boot-simulator.sh "iPhone Air" 30

- name: Run ${{ matrix.test-name }}
timeout-minutes: ${{ matrix.timeout }}
Expand All @@ -196,7 +216,7 @@ jobs:
-project Pulse.xcodeproj \
-scheme ${{ matrix.scheme }} \
-only-testing:${{ matrix.test-target }} \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=latest' \
-derivedDataPath ./DerivedData \
-resultBundlePath test-results/${{ matrix.artifact-name }}.xcresult \
-disableAutomaticPackageResolution \
Expand Down Expand Up @@ -451,7 +471,17 @@ jobs:
- name: Setup Xcode
uses: maxim-lobanov/setup-xcode@60606e260d2fc5762a71e64e74b2174e8ea3c8bd # v1
with:
xcode-version: '26.2'
xcode-version: '26.3'

- name: Install iOS Simulator Runtime
run: |
echo "Installing iOS simulator runtime..."
# Initialize CoreSimulator service before downloading
xcrun simctl list runtimes > /dev/null 2>&1 || true
sleep 5
xcodebuild -downloadPlatform iOS
echo "Available iOS simulator runtimes:"
xcrun simctl list runtimes | grep iOS

- name: Cache Homebrew packages
uses: actions/cache@v4
Expand Down Expand Up @@ -653,7 +683,7 @@ jobs:
xcodebuild build \
-project Pulse.xcodeproj \
-scheme PulseDev \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=latest' \
-quiet \
CODE_SIGNING_ALLOWED=NO \
-skipMacroValidation || {
Expand Down
30 changes: 15 additions & 15 deletions Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ help:
@echo " build-release - Build for release"
@echo " lint - Run SwiftLint and SwiftFormat checks"
@echo " format - Auto-fix formatting with SwiftFormat"
@echo " test - Run all tests on iOS 26.2 iPhone Air"
@echo " test - Run all tests on iOS 26.3.1 iPhone Air"
@echo " test-unit - Run only unit tests"
@echo " test-ui - Run only UI tests"
@echo " test-snapshot - Run only snapshot tests"
Expand Down Expand Up @@ -101,7 +101,7 @@ build:
@xcodebuild build \
-project Pulse.xcodeproj \
-scheme PulseDev \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' \
-configuration Debug \
CODE_SIGNING_ALLOWED=NO -skipMacroValidation

Expand All @@ -110,15 +110,15 @@ build-release:
@xcodebuild build \
-project Pulse.xcodeproj \
-scheme PulseProd \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' \
-destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' \
-configuration Release \
CODE_SIGNING_ALLOWED=NO -skipMacroValidation

# Run all tests
test:
@echo "Running all tests on iOS 26.2 iPhone Air..."
@echo "Running all tests on iOS 26.3.1 iPhone Air..."
@make clean-packages
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
echo "All tests completed successfully!"; \
grep -E "(Test run.*passed|Test run.*failed)" /tmp/test_output.log | tail -2; \
else \
Expand All @@ -132,9 +132,9 @@ test:

# Run tests with coverage and print app target percent
coverage:
@echo "Running tests with coverage on iOS 26.2 iPhone Air..."
@echo "Running tests with coverage on iOS 26.3.1 iPhone Air..."
@rm -rf build/TestResults.xcresult
@xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' -enableCodeCoverage YES -resultBundlePath build/TestResults.xcresult CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/coverage_output.log | grep -E '(Testing|Test Suite|Test Case|passed|failed)' || true
@xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' -enableCodeCoverage YES -resultBundlePath build/TestResults.xcresult CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/coverage_output.log | grep -E '(Testing|Test Suite|Test Case|passed|failed)' || true
@echo ""
@if grep -E "Executed .* tests, with [1-9][0-9]* failures" /tmp/coverage_output.log > /dev/null; then \
echo "Tests failed! Coverage report may be incomplete."; \
Expand Down Expand Up @@ -170,9 +170,9 @@ coverage-badge:

# Run only unit tests
test-unit:
@echo "Running unit tests on iOS 26.2 iPhone Air..."
@echo "Running unit tests on iOS 26.3.1 iPhone Air..."
@make clean-packages
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
echo "Unit tests completed successfully!"; \
grep -E "(Test run.*passed|Test run.*failed)" /tmp/test_output.log | tail -5; \
else \
Expand All @@ -186,9 +186,9 @@ test-unit:

# Run only UI tests
test-ui:
@echo "Running UI tests on iOS 26.2 iPhone Air..."
@echo "Running UI tests on iOS 26.3.1 iPhone Air..."
@make clean-packages
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseUITests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseUITests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
echo "UI tests completed successfully!"; \
grep -E "(Test run.*passed|Test run.*failed)" /tmp/test_output.log | tail -1; \
else \
Expand All @@ -202,9 +202,9 @@ test-ui:

# Run only snapshot tests
test-snapshot:
@echo "Running snapshot tests on iOS 26.2 iPhone Air..."
@echo "Running snapshot tests on iOS 26.3.1 iPhone Air..."
@make clean-packages
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseSnapshotTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
@if xcodebuild clean test -project Pulse.xcodeproj -scheme PulseSnapshotTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_output.log; then \
echo "Snapshot tests completed successfully!"; \
grep -E "(Test run.*passed|Test run.*failed)" /tmp/test_output.log | tail -1; \
else \
Expand All @@ -221,15 +221,15 @@ test-debug:
@echo "Running unit tests with full verbose output for debugging..."
@echo "This will show all test output including passing tests"
@make clean-packages
@xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_debug.log
@xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' CODE_SIGNING_ALLOWED=NO -skipMacroValidation 2>&1 | tee /tmp/test_debug.log
@echo ""
@echo "Full debug output saved to /tmp/test_debug.log"

# Test deeplink functionality specifically
deeplink-test:
@echo "Testing deeplink functionality..."
@make clean-packages
@xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseTests/DeeplinkManagerTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.2' CODE_SIGNING_ALLOWED=NO -skipMacroValidation
@xcodebuild clean test -project Pulse.xcodeproj -scheme PulseDev -only-testing:PulseTests/DeeplinkManagerTests -destination 'platform=iOS Simulator,name=iPhone Air,OS=26.3.1' CODE_SIGNING_ALLOWED=NO -skipMacroValidation
@echo "Deeplink tests completed!"

# Clean generated files
Expand Down
29 changes: 23 additions & 6 deletions PulseUITests/BaseUITestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -54,8 +54,21 @@ class BaseUITestCase: XCTestCase {

app.launch()

// Launch verification - uses longer timeout as app startup takes time
_ = app.wait(for: .runningForeground, timeout: Self.launchTimeout)
// If the app didn't reach foreground (e.g., previous instance failed to terminate),
// retry with a fresh XCUIApplication instance
if !app.wait(for: .runningForeground, timeout: Self.launchTimeout) {
app = XCUIApplication()
app.launchEnvironment["UI_TESTING"] = "1"
app.launchEnvironment["DISABLE_ANIMATIONS"] = "1"
app.launchArguments += ["-UIViewAnimationDuration", "0.01"]
app.launchArguments += ["-CATransactionAnimationDuration", "0.01"]
app.launchArguments += ["-pulse.hasCompletedOnboarding", "YES"]
app.launchArguments += ["-AppleLanguages", "(en)"]
app.launchArguments += ["-AppleLocale", "en_US"]
configureLaunchEnvironment()
app.launch()
_ = app.wait(for: .runningForeground, timeout: Self.launchTimeout)
}

// Wait for UI to stabilize after launch
// CI cold starts need more time for accessibility services to be ready
Expand Down Expand Up @@ -98,10 +111,14 @@ class BaseUITestCase: XCTestCase {
override func tearDown() {
defer { app = nil }
XCUIDevice.shared.orientation = .portrait
// Do NOT call app.terminate() — on Xcode 26+ it triggers internal
// accessibility snapshot queries that throw uncatchable C++ exceptions,
// crashing the test runner. XCTest handles app lifecycle automatically
// between tests, so explicit termination is unnecessary.
// Terminate the app explicitly to prevent "Failed to terminate" errors
// in the next test's setUp when app.launch() tries to kill a lingering instance.
// With continueAfterFailure = true, any C++ exception from Xcode 26's
// accessibility queries during termination is recorded as a non-fatal issue
// rather than crashing the test runner.
if let app, app.state != .notRunning {
app.terminate()
}
}

// MARK: - Subclass Hooks
Expand Down
11 changes: 7 additions & 4 deletions PulseUITests/FeedUITests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -147,7 +147,7 @@ final class FeedUITests: BaseUITestCase {
// Look for source articles section
let sourceArticlesText = app.staticTexts["Source Articles"]

if sourceArticlesText.waitForExistence(timeout: Self.defaultTimeout) {
if safeWaitForExistence(sourceArticlesText, timeout: Self.defaultTimeout) {
// Tap to expand
sourceArticlesText.tap()

Expand All @@ -164,19 +164,22 @@ final class FeedUITests: BaseUITestCase {
if buttonCount > 0 {
// Tap the first available source article
let firstButton = chevronButtons.element(boundBy: 0)
if firstButton.waitForExistence(timeout: Self.shortTimeout), firstButton.isHittable {
if safeWaitForExistence(firstButton, timeout: Self.shortTimeout), firstButton.isHittable {
firstButton.tap()

// Check if we navigated to article detail
let backButton = app.buttons["backButton"]
if backButton.waitForExistence(timeout: Self.defaultTimeout) {
if safeWaitForExistence(backButton, timeout: Self.defaultTimeout) {
XCTAssertTrue(backButton.exists, "Should navigate to article detail")

// Navigate back
backButton.tap()

let navTitle = app.navigationBars["Daily Digest"]
XCTAssertTrue(navTitle.waitForExistence(timeout: Self.defaultTimeout), "Should return to Feed")
XCTAssertTrue(
safeWaitForExistence(navTitle, timeout: Self.defaultTimeout),
"Should return to Feed"
)
}
}
}
Expand Down
Loading
Loading