diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml index 437c8ad..1b60c6b 100644 --- a/.github/workflows/test.yaml +++ b/.github/workflows/test.yaml @@ -24,7 +24,7 @@ jobs: test-packages: name: swift test (Packages) - runs-on: self-hosted + runs-on: [self-hosted, regular] steps: - name: Checkout code @@ -49,7 +49,7 @@ jobs: test-xcode: name: xcodebuild test (RxCode) - runs-on: self-hosted + runs-on: [self-hosted, regular] steps: - name: Checkout code @@ -136,3 +136,83 @@ jobs: CODE_SIGNING_ALLOWED=YES \ AD_HOC_CODE_SIGNING_ALLOWED=YES \ build | xcpretty + + test-mobile: + name: xcodebuild test (RxCodeMobile UI - ${{ matrix.label }}) + runs-on: [self-hosted, ui-tests] + + strategy: + fail-fast: false + matrix: + include: + - label: iPhone + device-prefix: iPhone + test-plan: MobileUITestPlan-iPhone + - label: iPad + device-prefix: iPad + test-plan: MobileUITestPlan-iPad + + steps: + - name: Checkout code + uses: actions/checkout@v6 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: latest-stable + + - name: Install xcpretty + run: gem install xcpretty + + - name: Resolve simulator + id: sim + run: | + # Pick the first available simulator matching the device family, so + # the job survives Xcode bumping its bundled simulator models. + UDID=$(xcrun simctl list devices available --json | python3 -c " + import json, sys + devices = json.load(sys.stdin)['devices'] + prefix = '${{ matrix.device-prefix }}' + for runtime in devices: + for d in devices[runtime]: + if d['name'].startswith(prefix): + print(d['udid']); sys.exit(0) + ") + if [ -z "$UDID" ]; then + echo "No '${{ matrix.device-prefix }}' simulator found. Available devices:" + xcrun simctl list devices available + exit 1 + fi + echo "Using ${{ matrix.device-prefix }} simulator $UDID" + echo "udid=$UDID" >> "$GITHUB_OUTPUT" + + - name: Prepare simulator + run: | + # Erase first: the app persists pairing in UserDefaults/Keychain, and + # a clean device guarantees the UI-test mock pairing path is taken. + xcrun simctl shutdown "${{ steps.sim.outputs.udid }}" || true + xcrun simctl erase "${{ steps.sim.outputs.udid }}" + xcrun simctl boot "${{ steps.sim.outputs.udid }}" + xcrun simctl bootstatus "${{ steps.sim.outputs.udid }}" + + - name: Run UI tests + run: | + set -o pipefail && xcodebuild \ + -project RxCode.xcodeproj \ + -scheme RxCodeMobile \ + -configuration Debug \ + -testPlan ${{ matrix.test-plan }} \ + -destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \ + -resultBundlePath "TestResults-${{ matrix.label }}.xcresult" \ + -enableCodeCoverage NO \ + CODE_SIGNING_ALLOWED=NO \ + CODE_SIGNING_REQUIRED=NO \ + test | xcpretty + + - name: Upload test results + if: always() + uses: actions/upload-artifact@v4 + with: + name: mobile-ui-test-results-${{ matrix.label }} + path: TestResults-${{ matrix.label }}.xcresult + retention-days: 14 diff --git a/MobileUITestPlan-iPad.xctestplan b/MobileUITestPlan-iPad.xctestplan new file mode 100644 index 0000000..4c44d05 --- /dev/null +++ b/MobileUITestPlan-iPad.xctestplan @@ -0,0 +1,30 @@ +{ + "configurations" : [ + { + "id" : "B1A2C3D4-0002-0002-0002-000000000002", + "name" : "iPad UI", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "selectedTests" : [ + "RxCodeMobileUITests\/testLaunchesPastPairingIntoMainUI()", + "iPadNavigationUITests\/testBriefingSplitKeepsListVisible()", + "iPadNavigationUITests\/testProjectsSplitKeepsThreadListVisible()" + ], + "target" : { + "containerPath" : "container:RxCode.xcodeproj", + "identifier" : "DF230B682FBC7368008929A6", + "name" : "RxCodeMobileUITests" + } + } + ], + "version" : 1 +} diff --git a/MobileUITestPlan-iPhone.xctestplan b/MobileUITestPlan-iPhone.xctestplan new file mode 100644 index 0000000..66ff822 --- /dev/null +++ b/MobileUITestPlan-iPhone.xctestplan @@ -0,0 +1,30 @@ +{ + "configurations" : [ + { + "id" : "B1A2C3D4-0001-0001-0001-000000000001", + "name" : "iPhone UI", + "options" : { + + } + } + ], + "defaultOptions" : { + "codeCoverage" : false, + "testTimeoutsEnabled" : true + }, + "testTargets" : [ + { + "selectedTests" : [ + "RxCodeMobileUITests\/testLaunchesPastPairingIntoMainUI()", + "iPhoneNavigationUITests\/testBriefingFlowReturnsToDetailAfterBack()", + "iPhoneNavigationUITests\/testProjectsFlowReturnsToThreadListAfterBack()" + ], + "target" : { + "containerPath" : "container:RxCode.xcodeproj", + "identifier" : "DF230B682FBC7368008929A6", + "name" : "RxCodeMobileUITests" + } + } + ], + "version" : 1 +} diff --git a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift index 330c651..d8a112b 100644 --- a/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift +++ b/Packages/Sources/RxCodeChatKit/ChatMessageListView.swift @@ -33,7 +33,13 @@ public struct ChatMessageListView: View { ForEach(chatMessageGroups(messages, minGroupSize: transientGroupMinSize)) { group in if group.isTransientGroup { ChatTransientGroupSummaryView(messages: group.messages) - .id(group.id) + .background(alignment: .top) { + ForEach(group.messages.map(\.id), id: \.self) { messageID in + Color.clear + .frame(height: 1) + .id(messageID) + } + } .transition(messageFadeTransition(role: .assistant)) .chatMessageListRowStyle() } else if let message = group.messages.first { diff --git a/Packages/Sources/RxCodeChatKit/IMETextView.swift b/Packages/Sources/RxCodeChatKit/IMETextView.swift index dd6a19a..d1c69c4 100644 --- a/Packages/Sources/RxCodeChatKit/IMETextView.swift +++ b/Packages/Sources/RxCodeChatKit/IMETextView.swift @@ -17,7 +17,6 @@ struct IMETextView: NSViewRepresentable { var textColor: NSColor var placeholder: String = "" var onReturn: () -> Void - var onShiftReturn: () -> Void var onUpArrow: () -> Bool var onDownArrow: () -> Bool var onTab: () -> Bool @@ -104,7 +103,6 @@ struct IMETextView: NSViewRepresentable { private func applyCallbacks(to textView: _IMETextView) { textView.onReturn = onReturn - textView.onShiftReturn = onShiftReturn textView.onUpArrow = onUpArrow textView.onDownArrow = onDownArrow textView.onTab = onTab @@ -217,7 +215,6 @@ fileprivate final class ChipLayoutManager: NSLayoutManager, @unchecked Sendable fileprivate final class _IMETextView: NSTextView { var onReturn: () -> Void = {} - var onShiftReturn: () -> Void = {} var onUpArrow: () -> Bool = { false } var onDownArrow: () -> Bool = { false } var onTab: () -> Bool = { false } @@ -274,10 +271,11 @@ fileprivate final class _IMETextView: NSTextView { override func keyDown(with event: NSEvent) { // Shift+Enter: NSTextView doesn't bind this to a doCommand by default. Force-commit any - // composing IME text, then fire the newline callback (which appends "\n" via the binding). + // composing IME text, then insert the newline through NSTextView so AppKit updates the + // insertion point and scroll position together. if event.keyCode == 36, event.modifierFlags.contains(.shift) { commitMarkedTextIfNeeded() - onShiftReturn() + insertShiftReturnNewline() return } // Shift+Tab: NSTextView routes this to insertBacktab: which we intercept here so it @@ -407,5 +405,23 @@ fileprivate final class _IMETextView: NSTextView { let composing = (storage.string as NSString).substring(with: range) insertText(composing, replacementRange: range) } + + private func insertShiftReturnNewline() { + insertText("\n", replacementRange: selectedRange()) + revealInsertionPoint() + } + + private func revealInsertionPoint() { + let textLength = (string as NSString).length + let location = min(max(selectedRange().location, 0), textLength) + let caretRange = NSRange(location: location, length: 0) + scrollRangeToVisible(caretRange) + DispatchQueue.main.async { [weak self] in + guard let self else { return } + let textLength = (self.string as NSString).length + let location = min(max(self.selectedRange().location, 0), textLength) + self.scrollRangeToVisible(NSRange(location: location, length: 0)) + } + } } #endif diff --git a/Packages/Sources/RxCodeChatKit/InputBarView.swift b/Packages/Sources/RxCodeChatKit/InputBarView.swift index 5ebe5af..6c8c301 100644 --- a/Packages/Sources/RxCodeChatKit/InputBarView.swift +++ b/Packages/Sources/RxCodeChatKit/InputBarView.swift @@ -315,7 +315,6 @@ struct InputBarView: View { textColor: NSColor(ClaudeTheme.textPrimary), placeholder: String(localized: "Type a message...", bundle: .module), onReturn: handleReturnKey, - onShiftReturn: handleShiftReturnKey, onUpArrow: { handleUpArrow() == .handled }, onDownArrow: { handleDownArrow() == .handled }, onTab: { handleTab() == .handled }, @@ -772,9 +771,6 @@ struct InputBarView: View { sendMessage() } - private func handleShiftReturnKey() { - windowState.inputText.append("\n") - } } // IMETextView's NSScrollView doesn't surface intrinsic height, so a hidden Text at the same diff --git a/Packages/Sources/RxCodeSync/Protocol/Payload.swift b/Packages/Sources/RxCodeSync/Protocol/Payload.swift index 62c890d..92fce56 100644 --- a/Packages/Sources/RxCodeSync/Protocol/Payload.swift +++ b/Packages/Sources/RxCodeSync/Protocol/Payload.swift @@ -137,11 +137,19 @@ public struct PairRequestPayload: Codable, Sendable { public let displayName: String public let platform: String public let appVersion: String - public init(mobilePubkeyHex: String, displayName: String, platform: String, appVersion: String) { + public let apnsEnvironment: String? + public init( + mobilePubkeyHex: String, + displayName: String, + platform: String, + appVersion: String, + apnsEnvironment: String? = nil + ) { self.mobilePubkeyHex = mobilePubkeyHex self.displayName = displayName self.platform = platform self.appVersion = appVersion + self.apnsEnvironment = apnsEnvironment } } diff --git a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift index 02d5896..8c0ddc7 100644 --- a/Packages/Tests/RxCodeSyncTests/PayloadTests.swift +++ b/Packages/Tests/RxCodeSyncTests/PayloadTests.swift @@ -5,6 +5,28 @@ import RxCodeCore @Suite("Mobile sync payloads") struct PayloadTests { + @Test("pair request carries APNs environment") + func pairRequestCarriesAPNsEnvironment() throws { + let payload = Payload.pairRequest( + PairRequestPayload( + mobilePubkeyHex: String(repeating: "a", count: 64), + displayName: "iPhone", + platform: "iOS", + appVersion: "1.2.3", + apnsEnvironment: "sandbox" + ) + ) + + let data = try JSONEncoder().encode(payload) + let decoded = try JSONDecoder().decode(Payload.self, from: data) + guard case .pairRequest(let request) = decoded else { + Issue.record("Expected pair request payload") + return + } + + #expect(request.apnsEnvironment == "sandbox") + } + @Test("snapshot carries briefing and settings data") func snapshotCarriesBriefingAndSettingsData() throws { let projectId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")! diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj index c031d70..abc4e45 100644 --- a/RxCode.xcodeproj/project.pbxproj +++ b/RxCode.xcodeproj/project.pbxproj @@ -117,6 +117,8 @@ DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RxCodeMobileNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; }; DF23F7352FB8C3EC008929A6 /* icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = icon.icon; sourceTree = ""; }; + DF5B0DDA2FC023BE000CE36F /* MobileUITestPlan-iPhone.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPhone.xctestplan"; sourceTree = ""; }; + DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPad.xctestplan"; sourceTree = ""; }; DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = ""; }; DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = ""; }; DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = ""; }; @@ -321,6 +323,8 @@ isa = PBXGroup; children = ( DF23F7352FB8C3EC008929A6 /* icon.icon */, + DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */, + DF5B0DDA2FC023BE000CE36F /* MobileUITestPlan-iPhone.xctestplan */, DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */, 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */, E673353A2F7356F600FD26C7 /* RxCode */, diff --git a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme index aebc87d..cbae7e7 100644 --- a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme +++ b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme @@ -36,6 +36,9 @@ + + - - - - - - - - - - + + + + + + URL? { + if let relayURLString = device.relayURL, let deviceRelayURL = URL(string: relayURLString) { + return Self.pushEndpointURL(from: deviceRelayURL) + } + return Self.pushEndpointURL(from: relayURL) + } + + static func apnsEnvironmentForPush(_ device: PairedDevice) -> String? { + normalizedAPNSEnvironment(device.apnsEnvironment) + } + + static func normalizedAPNSEnvironment(_ environment: String?) -> String? { + guard let raw = environment? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !raw.isEmpty + else { return nil } + + switch raw { + case "production", "prod", "release": + return "production" + case "sandbox", "development", "dev", "debug": + return "sandbox" + default: + return raw + } + } + static func responseBodyString(_ data: Data) -> String { let raw = String(data: data, encoding: .utf8)? .trimmingCharacters(in: .whitespacesAndNewlines) ?? "" @@ -803,12 +833,14 @@ struct APNsPushRequest: Codable { let encryptedAlert: String let category: String? let collapseID: String? + let apnsEnvironment: String? enum CodingKeys: String, CodingKey { case deviceToken = "device_token" case encryptedAlert = "encrypted_alert" case category case collapseID = "collapse_id" + case apnsEnvironment = "apns_environment" } } @@ -816,11 +848,13 @@ struct APNsPushResponse: Codable { let statusCode: Int let reason: String let apnsID: String? + let apnsEnvironment: String? enum CodingKeys: String, CodingKey { case statusCode = "status_code" case reason case apnsID = "apns_id" + case apnsEnvironment = "apns_environment" } } diff --git a/RxCode/Views/Settings/MobileSettingsTab.swift b/RxCode/Views/Settings/MobileSettingsTab.swift index 6f55c4c..191d12f 100644 --- a/RxCode/Views/Settings/MobileSettingsTab.swift +++ b/RxCode/Views/Settings/MobileSettingsTab.swift @@ -265,6 +265,11 @@ struct MobileSettingsTab: View { Label("Push", systemImage: "bell.fill") .font(.caption) .foregroundStyle(.green) + if let environment = apnsEnvironmentLabel(for: device) { + Text("• \(environment)") + .font(.caption) + .foregroundStyle(.secondary) + } } else { Label("Live channel only", systemImage: "bell.slash") .font(.caption) @@ -353,6 +358,23 @@ struct MobileSettingsTab: View { } return "Send a push notification to \(device.displayName)." } + + private func apnsEnvironmentLabel(for device: PairedDevice) -> String? { + guard let raw = device.apnsEnvironment? + .trimmingCharacters(in: .whitespacesAndNewlines) + .lowercased(), + !raw.isEmpty + else { return nil } + + switch raw { + case "sandbox", "development", "dev", "debug": + return "Sandbox" + case "production", "prod", "release": + return "Production" + default: + return raw.capitalized + } + } } private struct TestNotificationAlert: Identifiable { diff --git a/RxCodeMobile/AppDelegate.swift b/RxCodeMobile/AppDelegate.swift index cb92b0c..d5eb425 100644 --- a/RxCodeMobile/AppDelegate.swift +++ b/RxCodeMobile/AppDelegate.swift @@ -42,11 +42,7 @@ final class AppDelegate: NSObject, UIApplicationDelegate, UNUserNotificationCent func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) { let tokenHex = deviceToken.map { String(format: "%02x", $0) }.joined() -#if DEBUG - let environment = "sandbox" -#else - let environment = "production" -#endif + let environment = MobileAppState.currentAPNsEnvironment logger.info("[APNs] registered tokenPrefix=\(String(tokenHex.prefix(12)), privacy: .public) environment=\(environment, privacy: .public)") mobileState?.reportAPNsToken(hex: tokenHex, environment: environment) } diff --git a/RxCodeMobile/RxCodeMobileApp.swift b/RxCodeMobile/RxCodeMobileApp.swift index 960ad57..c090a8b 100644 --- a/RxCodeMobile/RxCodeMobileApp.swift +++ b/RxCodeMobile/RxCodeMobileApp.swift @@ -15,6 +15,11 @@ struct RxCodeMobileApp: App { .displayFrequency(.immediate), .datastoreLocation(.applicationDefault), ]) + // Suppress TipKit popovers during UI tests — they overlay the UI and + // intercept taps. No-op outside a UI-test launch. + if UITestSupport.isActive { + Tips.hideAllTipsForTesting() + } } var body: some Scene { diff --git a/RxCodeMobile/State/MobileAppState+Pairing.swift b/RxCodeMobile/State/MobileAppState+Pairing.swift index 64b4779..633e0b0 100644 --- a/RxCodeMobile/State/MobileAppState+Pairing.swift +++ b/RxCodeMobile/State/MobileAppState+Pairing.swift @@ -36,7 +36,8 @@ extension MobileAppState { mobilePubkeyHex: identity.publicKeyHex, displayName: displayName, platform: UIDevice.current.userInterfaceIdiom == .pad ? "iPadOS" : "iOS", - appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0" + appVersion: Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "1.0", + apnsEnvironment: Self.currentAPNsEnvironment ) do { logger.info("sending pair request via relay=\(self.relayURL.absoluteString, privacy: .public)") @@ -115,3 +116,28 @@ extension MobileAppState { } } } + +#if DEBUG +extension MobileAppState { + /// Injects a synthetic paired desktop matching the UI-test mock relay + /// server, so `RootView` shows the main UI instead of `OnboardingView` and + /// the app syncs against the mock. No-op unless launched with `-uitest-mock`. + /// Called at the end of `init()` after `loadPairedDesktops()`. + func applyUITestPairingIfNeeded() { + guard UITestSupport.isActive, + let desktopHex = UITestSupport.desktopPubkeyHex, + !desktopHex.isEmpty else { return } + let desktop = PairedDesktop( + pubkeyHex: desktopHex, + displayName: "Mock Desktop", + pairedAt: .now, + lastSeen: .now, + relayURL: relayURL.absoluteString + ) + pairedDesktops = [desktop] + setActiveDesktop(pubkeyHex: desktopHex) + savePairedDesktops() + logger.info("[UITest] injected mock pairing desktopKey=\(String(desktopHex.prefix(12)), privacy: .public) relay=\(self.relayURL.absoluteString, privacy: .public)") + } +} +#endif diff --git a/RxCodeMobile/State/MobileAppState.swift b/RxCodeMobile/State/MobileAppState.swift index aeda979..031786d 100644 --- a/RxCodeMobile/State/MobileAppState.swift +++ b/RxCodeMobile/State/MobileAppState.swift @@ -212,29 +212,50 @@ final class MobileAppState: ObservableObject { static let legacyDesktopPubkeyKey = "mobileSync.desktopPubkey" static let legacyDesktopNameKey = "mobileSync.desktopName" static let mobilePubkeyKey = "mobileSync.mobilePubkey" + static var currentAPNsEnvironment: String { + #if DEBUG + "sandbox" + #else + "production" + #endif + } init() { + // UI-test seam: rewrites the relay URL and clears stale pairing before + // the reads below. No-op outside Debug builds / UI-test launches. + UITestSupport.applyDefaultsOverrides() let stored = UserDefaults.standard.string(forKey: "mobileSync.relayURL") let initial = URL(string: stored ?? Self.defaultRelayURLString) ?? URL(string: Self.defaultRelayURLString)! self.relayURL = initial - do { - // Shared access group lets the Notification Service Extension - // read the private key for decrypting APNs alerts. The bare group - // suffix is matched against the (already-expanded) entitlement — - // never pass the literal `$(AppIdentifierPrefix)…` here, that's a - // build-time substitution and is meaningless at runtime. - self.identity = try DeviceIdentity.loadOrCreate( - accessGroup: Self.keychainAccessGroup - ) - } catch { - Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") - .error("[MobileIdentity] load failed accessGroup=\(Self.keychainAccessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") - fatalError("Failed to load mobile device identity: \(error)") + if UITestSupport.isActive { + // UI tests run an unsigned build, where the shared Keychain access + // group is unavailable and `loadOrCreate` would trap. The mock + // relay learns the mobile public key from each envelope, so an + // ephemeral per-launch identity is sufficient and skips the Keychain. + self.identity = DeviceIdentity(privateKey: Curve25519.KeyAgreement.PrivateKey()) + } else { + do { + // Shared access group lets the Notification Service Extension + // read the private key for decrypting APNs alerts. The bare + // group suffix is matched against the (already-expanded) + // entitlement — never pass the literal `$(AppIdentifierPrefix)…` + // here, that's a build-time substitution, meaningless at runtime. + self.identity = try DeviceIdentity.loadOrCreate( + accessGroup: Self.keychainAccessGroup + ) + } catch { + Logger(subsystem: "com.idealapp.RxCodeMobile", category: "MobileAppState") + .error("[MobileIdentity] load failed accessGroup=\(Self.keychainAccessGroup, privacy: .public): \(error.localizedDescription, privacy: .public)") + fatalError("Failed to load mobile device identity: \(error)") + } } self.client = SyncClient(identity: identity, relayURL: initial) logger.info("[MobileIdentity] loaded publicKey=\(String(self.identity.publicKeyHex.prefix(12)), privacy: .public) accessGroup=\(Self.keychainAccessGroup, privacy: .public)") loadPairedDesktops() + #if DEBUG + applyUITestPairingIfNeeded() + #endif } /// Bare suffix as declared (post-`$(AppIdentifierPrefix)`) in diff --git a/RxCodeMobile/Support/UITestSupport.swift b/RxCodeMobile/Support/UITestSupport.swift new file mode 100644 index 0000000..a9250c0 --- /dev/null +++ b/RxCodeMobile/Support/UITestSupport.swift @@ -0,0 +1,55 @@ +import Foundation + +#if DEBUG +/// Parses the UI-test launch arguments and applies the two overrides the UI +/// test harness depends on: pointing the relay at the in-process mock server, +/// and skipping the QR-code pairing flow. +/// +/// Entirely inert unless the app is launched with `-uitest-mock`, and compiled +/// only into Debug builds, so it never affects shipping behaviour. +enum UITestSupport { + /// `true` when the app was launched by the UI test harness. + static var isActive: Bool { + ProcessInfo.processInfo.arguments.contains("-uitest-mock") + } + + /// Value following a `-flag value` pair in the launch arguments. + static func value(for flag: String) -> String? { + let args = ProcessInfo.processInfo.arguments + guard let index = args.firstIndex(of: flag), index + 1 < args.count else { + return nil + } + return args[index + 1] + } + + /// WebSocket URL of the in-process mock relay server. + static var relayURL: String? { value(for: "-uitest-relay-url") } + + /// Public-key hex of the deterministic desktop identity the mock impersonates. + static var desktopPubkeyHex: String? { value(for: "-uitest-desktop-pubkey") } + + /// Rewrites `UserDefaults` before `MobileAppState` reads them, so the relay + /// URL points at the mock and no pairing persisted by a previous run + /// survives. Must run as the first statement of `MobileAppState.init()`. + static func applyDefaultsOverrides() { + guard isActive else { return } + let defaults = UserDefaults.standard + if let relayURL { + defaults.set(relayURL, forKey: "mobileSync.relayURL") + } + // Drop any persisted pairing so `loadPairedDesktops()` starts clean and + // the synthetic pairing injected afterwards is the only one. + defaults.removeObject(forKey: MobileAppState.mobilePubkeyKey) + defaults.removeObject(forKey: MobileAppState.pairedDesktopsKey) + defaults.removeObject(forKey: MobileAppState.activeDesktopPubkeyKey) + defaults.removeObject(forKey: MobileAppState.legacyDesktopPubkeyKey) + defaults.removeObject(forKey: MobileAppState.legacyDesktopNameKey) + } +} +#else +/// Release-build shim: the UI-test seam does not exist outside Debug builds. +enum UITestSupport { + static var isActive: Bool { false } + static func applyDefaultsOverrides() {} +} +#endif diff --git a/RxCodeMobile/Views/GlassThreadCard.swift b/RxCodeMobile/Views/GlassThreadCard.swift index 2325c2c..5812e5d 100644 --- a/RxCodeMobile/Views/GlassThreadCard.swift +++ b/RxCodeMobile/Views/GlassThreadCard.swift @@ -20,11 +20,15 @@ struct GlassThreadCard: View { } var body: some View { + // The UI-test identifier is applied directly on the button of each + // branch — applying it to an enclosing container does not reach the + // button element XCUITest queries. if usesNavigationLink { NavigationLink(value: session.id) { cardContent } .buttonStyle(GlassCardButtonStyle(isSelected: isSelected)) + .accessibilityIdentifier("thread-row-\(session.id)") } else { Button { onSelect?() @@ -32,6 +36,7 @@ struct GlassThreadCard: View { cardContent } .buttonStyle(GlassCardButtonStyle(isSelected: isSelected)) + .accessibilityIdentifier("thread-row-\(session.id)") } } diff --git a/RxCodeMobile/Views/MobileBriefingDetailView.swift b/RxCodeMobile/Views/MobileBriefingDetailView.swift index b57188e..1f57e6b 100644 --- a/RxCodeMobile/Views/MobileBriefingDetailView.swift +++ b/RxCodeMobile/Views/MobileBriefingDetailView.swift @@ -20,6 +20,7 @@ struct MobileBriefingDetailView: View { .padding(.vertical, 20) } .scrollContentBackground(.hidden) + .accessibilityIdentifier("briefing-detail-screen") .navigationTitle(projectName) .navigationBarTitleDisplayMode(.inline) .refreshable { @@ -176,6 +177,7 @@ struct MobileBriefingDetailView: View { ThreadCard(thread: thread, namespace: glassNamespace) } .buttonStyle(.plain) + .accessibilityIdentifier("briefing-thread-row-\(thread.sessionId)") } } } diff --git a/RxCodeMobile/Views/MobileBriefingView.swift b/RxCodeMobile/Views/MobileBriefingView.swift index 3de0e11..a20cd98 100644 --- a/RxCodeMobile/Views/MobileBriefingView.swift +++ b/RxCodeMobile/Views/MobileBriefingView.swift @@ -58,6 +58,7 @@ struct MobileBriefingView: View { ) } .buttonStyle(.plain) + .accessibilityIdentifier("briefing-card-\(group.id)") } } } @@ -302,6 +303,7 @@ struct BriefingListView: View { } .buttonStyle(BriefingListCardButtonStyle(isSelected: selectedGroup == group.key)) .glassEffectID(group.id, in: glassNamespace) + .accessibilityIdentifier("briefing-list-card-\(group.id)") } } } @@ -310,6 +312,7 @@ struct BriefingListView: View { .padding(.vertical, 12) } .scrollDismissesKeyboard(.interactively) + .accessibilityIdentifier("briefing-list-screen") .navigationTitle("Briefing") .toolbar { if hasAnyData { diff --git a/RxCodeMobile/Views/MobileChatView.swift b/RxCodeMobile/Views/MobileChatView.swift index 9255d7b..635a247 100644 --- a/RxCodeMobile/Views/MobileChatView.swift +++ b/RxCodeMobile/Views/MobileChatView.swift @@ -21,6 +21,9 @@ struct MobileChatView: View { @State private var composer: String = "" @State private var isNearBottom: Bool = true @State private var showingQueueSheet = false + /// Set once the thread has been scrolled to its newest message on open. + /// Until then, an arriving message jumps to the bottom without animation; + /// after, message updates animate as the streaming follow. @State private var didEstablishInitialScroll = false @State private var showingRenameSheet = false @State private var showingArchiveConfirm = false @@ -37,6 +40,22 @@ struct MobileChatView: View { /// viewport is restored to it after the page is prepended so the content /// the user was reading doesn't jump. @State private var pendingTopAnchorID: UUID? + /// Re-asserts the preserved position after SwiftUI has laid out a prepended + /// page. A single `scrollTo` can fire before the lazy stack has realized the + /// old anchor row. + @State private var loadMoreRestoreTask: Task? + /// Re-asserts the just-sent message pin while lazy row geometry, dynamic + /// spacer height, and keyboard-driven composer movement settle. + @State private var pinToTopTask: Task? + /// Re-asserts the first scroll-to-bottom while the lazy stack and composer + /// geometry settle on thread entry. + @State private var initialScrollTask: Task? + /// Prevents repeated scheduling while the delayed initial scroll is + /// waiting for the first loaded page to finish laying out. + @State private var isEstablishingInitialScroll = false + /// Hides the thread while the initial delayed scroll is pending, avoiding + /// a visible flash at the top before the view lands on the latest message. + @State private var isInitialMessageListHidden = true /// Gates the scroll-up "load more" trigger until the initial scroll-to- /// bottom has settled, so opening a thread doesn't immediately page back. @State private var didSettleInitialScroll = false @@ -62,10 +81,25 @@ struct MobileChatView: View { /// Set by `handleSend`; the next appended user message is the one we sent /// and should be pinned to the top of the viewport. @State private var awaitingSentUserMessage = false + /// User message for the active turn. Its trailing spacer persists even + /// after manual scroll and shrinks as the assistant response fills in. + @State private var activeTurnUserMessageID: UUID? + /// Immediate spacer reduction used while SwiftUI has not yet reported the + /// streaming indicator's new tail geometry. + @State private var pendingIndicatorSpacerReduction: CGFloat = 0 + /// True only for the active send flow where the just-sent user message is + /// actively kept at the top while layout and keyboard geometry settle. + @State private var isPinningLatestTurnToTop = false + /// Prevents stale/user scroll phase callbacks from immediately canceling a + /// new programmatic top-pin before it has settled. + @State private var canReleasePinnedTurnByScroll = false @State private var distanceFromBottom: CGFloat = 0 @State private var minimumThreadLoadElapsed = false + @State private var isThreadLoadingOverlayVisible = true + @State private var threadLoadingHideTask: Task? private static let bottomAnchorID = "message-list-bottom" + private static let endOfScreenAnchorID = "message-list-end-of-screen" /// Distance from the bottom past which the "scroll to bottom" button shows. private static let nearBottomThreshold: CGFloat = 120 /// Distance from the true bottom within which auto-follow re-arms. Kept @@ -78,6 +112,9 @@ struct MobileChatView: View { /// Load older messages once the viewport scrolls within this many points /// of the top. private static let loadMoreThreshold: CGFloat = 150 + /// Approximate indicator height plus LazyVStack spacing. Cleared once the + /// tail marker reports the indicator in measured geometry. + private static let streamingIndicatorEstimatedHeight: CGFloat = 36 var body: some View { activeThreadLayout @@ -91,6 +128,9 @@ struct MobileChatView: View { .task(id: sessionID) { await runThreadLoadingGate() } + .onChange(of: isThreadLoadingMessages) { _, isLoading in + handleThreadLoadingChange(isLoading) + } .sheet(isPresented: $showingQueueSheet) { QueuedMessagesSheet( messages: queuedMessages, @@ -332,28 +372,40 @@ struct MobileChatView: View { MobileStreamingIndicator(isThinking: isThinking) .transition(.opacity) } - // Dynamic tail spacer: pads the latest turn so a sent - // message can be pinned to the top, and shrinks as the - // assistant response grows so the question stays put. Color.clear - .frame(height: tailSpacerHeight) + .frame(height: 1) .onGeometryChange(for: CGFloat.self) { proxy in proxy.frame(in: .named(chatContentCoordinateSpace)).minY } action: { newValue in updateTailSpacerMinY(newValue) } + Color.clear + .frame(height: 1) + .id(Self.endOfScreenAnchorID) + Color.clear + .frame(height: minTailSpacer) + // Extra tail spacer: pads the latest turn only while a + // freshly sent user message is pinned to the top, and + // shrinks as the assistant response grows. + Color.clear + .frame(height: pinTailSpacerExtraHeight) Color.clear .frame(height: 1) .id(Self.bottomAnchorID) } .padding(.horizontal, 16) .padding(.top, 8) + .opacity(isInitialMessageListHidden && !messages.isEmpty ? 0 : 1) .animation(.easeInOut(duration: 0.2), value: isStreaming) .animation(.easeInOut(duration: 0.2), value: isLoadingMore) + .animation(.easeInOut(duration: 0.18), value: isInitialMessageListHidden) .coordinateSpace(.named(chatContentCoordinateSpace)) .environment(\.chatTrackedMessageID, trackedUserMessageID) .environment(\.chatTrackedMessageGeometry, updateLatestUserMinY) + .accessibilityElement(children: .contain) + .accessibilityIdentifier("chat-message-list") } + .accessibilityIdentifier("chat-screen") .mobileDismissesKeyboardOnScroll(.interactively) .onGeometryChange(for: CGRect.self) { proxy in proxy.frame(in: .global) @@ -380,11 +432,21 @@ struct MobileChatView: View { // Returning to the true bottom re-arms auto-follow. if distanceFromBottom <= Self.atBottomThreshold { autoScrollEnabled = true + if isUserDragging { + isPinningLatestTurnToTop = false + } } } .onScrollGeometryChange(for: CGFloat.self) { geo in geo.contentOffset.y } action: { oldOffsetY, offsetY in + if isUserDragging, + isPinningLatestTurnToTop, + canReleasePinnedTurnByScroll, + offsetY > oldOffsetY + Self.userScrollUpDelta + { + releasePinnedTurnToBottom(proxy: proxy) + } // A deliberate upward drag means the user wants to read // history — stop the stream from pulling them back down. // Gated on `isUserDragging` so keyboard/layout-induced @@ -392,12 +454,15 @@ struct MobileChatView: View { if isUserDragging, offsetY < oldOffsetY - Self.userScrollUpDelta { autoScrollEnabled = false } - if offsetY < Self.loadMoreThreshold { triggerLoadMoreIfNeeded() } + if isUserDragging, offsetY < Self.loadMoreThreshold { + triggerLoadMoreIfNeeded() + } } .onAppear { - if !didEstablishInitialScroll { - didEstablishInitialScroll = true - } + // A cached thread already has its messages — jump straight + // to the newest one. Threads loaded async are handled by + // the `messages.last?.id` change below. + establishInitialScroll(proxy: proxy, reason: "onAppear") } .task { // Let the initial scroll-to-bottom settle before arming the @@ -409,36 +474,71 @@ struct MobileChatView: View { .onChange(of: messages.last?.id) { _, _ in // Keyed on the last message id so a prepended older page // (which leaves the tail unchanged) doesn't yank the view. - guard didEstablishInitialScroll else { - didEstablishInitialScroll = true - return - } guard let last = messages.last else { return } if last.role == .user, awaitingSentUserMessage { // The message we just sent round-tripped back from the - // desktop — pin it to the top of the viewport. + // desktop — pin it to the top of the viewport. Handle + // this before initial-scroll gating so the first + // message in an empty thread is pinned too. awaitingSentUserMessage = false autoScrollEnabled = false + activeTurnUserMessageID = last.id + isPinningLatestTurnToTop = true + initialScrollTask?.cancel() + isEstablishingInitialScroll = false + didEstablishInitialScroll = true + isInitialMessageListHidden = false pinSentMessageToTop(last.id, proxy: proxy) + } else if !didEstablishInitialScroll { + // First page of messages arrived after the view + // appeared — jump straight to the newest one. + establishInitialScroll(proxy: proxy, reason: "messagesArrived") + } else if repinActiveTurnIfNeeded(proxy: proxy) { + return } else if autoScrollEnabled { withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } } .onChange(of: messages.last?.content) { _, _ in - guard didEstablishInitialScroll, autoScrollEnabled else { return } + guard didEstablishInitialScroll else { return } + if repinActiveTurnIfNeeded(proxy: proxy) { return } + guard autoScrollEnabled else { return } withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } .onChange(of: isStreaming) { _, streaming in // Keep the newly appeared loading indicator in view. - guard streaming, didEstablishInitialScroll, autoScrollEnabled else { return } + if !streaming { + pendingIndicatorSpacerReduction = 0 + } + guard streaming, didEstablishInitialScroll else { return } + if activeTurnUserMessageID != nil { + pendingIndicatorSpacerReduction = Self.streamingIndicatorEstimatedHeight + } + if repinActiveTurnIfNeeded(proxy: proxy) { return } + guard autoScrollEnabled else { return } withAnimation { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } } .onChange(of: isLoadingMore) { _, loading in - // An older page finished loading: restore the viewport to - // the message that was on top so the prepend isn't jarring. guard !loading, let anchor = pendingTopAnchorID else { return } pendingTopAnchorID = nil - proxy.scrollTo(anchor, anchor: .top) + if autoScrollEnabled { + // The page was pulled in to fill a near-empty screen + // on open — keep the newest message in view instead of + // jumping up to the freshly prepended history. + settleScroll(proxy: proxy, to: Self.bottomAnchorID, anchor: .bottom) + } else { + // The user scrolled up to read history: restore the + // viewport to the message that was on top so the + // prepend isn't jarring. + settleScroll(proxy: proxy, to: anchor, anchor: .top) + } + } + .onChange(of: composerMinY) { oldValue, newValue in + handleComposerBoundaryChange( + oldValue: oldValue, + newValue: newValue, + proxy: proxy + ) } .overlay(alignment: .bottomLeading) { if !isNearBottom { @@ -556,9 +656,13 @@ struct MobileChatView: View { state.isLoadingMoreMessages(sessionID: sessionID) } + private var isThreadLoadingMessages: Bool { + state.isLoadingThreadMessages(sessionID: sessionID) + } + private var shouldShowThreadLoading: Bool { !MobileDraftSessionID.isDraft(sessionID) - && (!minimumThreadLoadElapsed || state.isLoadingThreadMessages(sessionID: sessionID)) + && isThreadLoadingOverlayVisible } private var queuedMessages: [QueuedUserMessage] { @@ -585,6 +689,8 @@ struct MobileChatView: View { private func runThreadLoadingGate() async { minimumThreadLoadElapsed = false + threadLoadingHideTask?.cancel() + isThreadLoadingOverlayVisible = true do { try await Task.sleep(for: .seconds(1)) } catch { @@ -594,6 +700,44 @@ struct MobileChatView: View { withAnimation(.easeInOut(duration: 0.25)) { minimumThreadLoadElapsed = true } + scheduleThreadLoadingHideIfReady() + } + + private func handleThreadLoadingChange(_ isLoading: Bool) { + threadLoadingHideTask?.cancel() + guard !MobileDraftSessionID.isDraft(sessionID) else { + isThreadLoadingOverlayVisible = false + return + } + guard !isLoading else { + isThreadLoadingOverlayVisible = true + return + } + scheduleThreadLoadingHideIfReady() + } + + private func scheduleThreadLoadingHideIfReady() { + threadLoadingHideTask?.cancel() + guard !MobileDraftSessionID.isDraft(sessionID) else { + isThreadLoadingOverlayVisible = false + return + } + guard minimumThreadLoadElapsed, !isThreadLoadingMessages else { + isThreadLoadingOverlayVisible = true + return + } + isThreadLoadingOverlayVisible = true + threadLoadingHideTask = Task { @MainActor in + do { + try await Task.sleep(for: .milliseconds(500)) + } catch { + return + } + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 0.25)) { + isThreadLoadingOverlayVisible = false + } + } } // MARK: - Message paging @@ -629,6 +773,108 @@ struct MobileChatView: View { } } + /// Scroll restoration after prepending older messages needs to survive lazy + /// layout and row grouping changes. Repeating the same non-animated scroll + /// across a handful of frames keeps the original anchor visible without + /// fighting later user gestures. + private func settleScroll(proxy: ScrollViewProxy, to id: ID, anchor: UnitPoint) { + loadMoreRestoreTask?.cancel() + loadMoreRestoreTask = Task { @MainActor in + var transaction = Transaction() + transaction.animation = nil + for _ in 0 ..< 8 { + guard !Task.isCancelled else { return } + withTransaction(transaction) { + proxy.scrollTo(id, anchor: anchor) + } + try? await Task.sleep(for: .milliseconds(16)) + } + } + } + + private func scrollToBottomAfterLayout(proxy: ScrollViewProxy) { + Task { @MainActor in + try? await Task.sleep(for: .milliseconds(16)) + guard didEstablishInitialScroll, autoScrollEnabled else { return } + withAnimation(.easeInOut(duration: 0.2)) { + proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) + } + } + } + + /// Jump straight to the latest message the first time the thread's content + /// is available, so an opened thread starts at the bottom instead of the + /// top. The deferred scroll covers the lazy stack not having measured its + /// full height on the first layout pass. + private func establishInitialScroll(proxy: ScrollViewProxy, reason: String) { + guard !didEstablishInitialScroll else { + isInitialMessageListHidden = false + mobileChatLogger.debug( + "[InitialScroll] skip already-established reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" + ) + return + } + guard !isEstablishingInitialScroll else { + mobileChatLogger.debug( + "[InitialScroll] skip already-scheduled reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" + ) + return + } + guard !messages.isEmpty else { + mobileChatLogger.debug( + "[InitialScroll] waiting-empty reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public)" + ) + return + } + isEstablishingInitialScroll = true + isInitialMessageListHidden = true + isPinningLatestTurnToTop = false + mobileChatLogger.info( + "[InitialScroll] scheduled reason=\(reason, privacy: .public) session=\(sessionID, privacy: .public) messages=\(messages.count, privacy: .public) delay=0.5 scrollHeight=\(Double(scrollViewHeight), privacy: .public) available=\(Double(availableHeight), privacy: .public) minTail=\(Double(minTailSpacer), privacy: .public)" + ) + initialScrollTask?.cancel() + initialScrollTask = Task { @MainActor in + do { + try await Task.sleep(for: .milliseconds(800)) + } catch { + isEstablishingInitialScroll = false + return + } + guard !Task.isCancelled else { + isEstablishingInitialScroll = false + mobileChatLogger.debug( + "[InitialScroll] cancelled session=\(sessionID, privacy: .public)" + ) + return + } + let target = initialScrollTarget() + withAnimation(.easeInOut(duration: 0.28)) { + proxy.scrollTo(target.id, anchor: target.anchor) + } + didEstablishInitialScroll = true + isEstablishingInitialScroll = false + withAnimation(.easeInOut(duration: 0.18)) { + isInitialMessageListHidden = false + } + mobileChatLogger.info( + "[InitialScroll] scrolled target=\(target.name, privacy: .public) session=\(sessionID, privacy: .public) messages=\(messages.count, privacy: .public) distance=\(Double(distanceFromBottom), privacy: .public) tailY=\(Double(tailSpacerMinY), privacy: .public) available=\(Double(availableHeight), privacy: .public) minTail=\(Double(minTailSpacer), privacy: .public) userY=\(Double(latestUserMinY), privacy: .public)" + ) + } + } + + private func initialScrollTarget() -> (id: String, anchor: UnitPoint, name: String) { + guard availableHeight > 0, tailSpacerMinY > 0 else { + return (Self.bottomAnchorID, .bottom, "bottom-unmeasured") + } + // For real scrollable content, land on the true bottom so the newest + // message is fully reached. For short threads, avoid aligning the + // composer-clearance spacer as the primary visible content. + if tailSpacerMinY > availableHeight { + return (Self.bottomAnchorID, .bottom, "bottom") + } + return (Self.endOfScreenAnchorID, .bottom, "end-of-screen") + } + // MARK: - Send / Stop private func handleSend(_ trimmed: String) { @@ -636,6 +882,10 @@ struct MobileChatView: View { // it is pinned to the top. Suppress bottom-follow so the streaming // reply fills the space below the question instead of yanking past it. awaitingSentUserMessage = true + activeTurnUserMessageID = nil + pendingIndicatorSpacerReduction = 0 + isPinningLatestTurnToTop = false + canReleasePinnedTurnByScroll = false autoScrollEnabled = false Task { await state.sendUserMessage(trimmed, sessionID: sessionID) @@ -645,10 +895,10 @@ struct MobileChatView: View { // MARK: - Pin to top - /// The latest user message — the row pinned to the top on send and the - /// reference point for the dynamic tail spacer. + /// The user message whose geometry drives the active turn spacer. During a + /// send this stays attached to that turn even if the user manually scrolls. private var trackedUserMessageID: UUID? { - messages.last(where: { $0.role == .user })?.id + activeTurnUserMessageID ?? messages.last(where: { $0.role == .user })?.id } /// Visible chat area above the floating composer. `composerMinY` rides up @@ -672,25 +922,76 @@ struct MobileChatView: View { return coveredHeight + 12 } - /// Dynamic tail spacer height — pads the latest turn so the user message - /// can be pinned to the top, shrinking as the response grows. - private var tailSpacerHeight: CGFloat { - guard availableHeight > 0 else { return minTailSpacer } + /// Extra spacer for the active turn. It starts large enough for the sent + /// user message to sit at the top, then shrinks as the assistant response + /// grows into that space. Manual scroll releases active top-following, but + /// the spacer remains attached to the turn so the layout can recover. + private var pinTailSpacerExtraHeight: CGFloat { + guard activeTurnUserMessageID != nil else { return 0 } + guard scrollViewHeight > 0 else { return 0 } let latestTurnHeight = max(0, tailSpacerMinY - latestUserMinY) - return max(minTailSpacer, availableHeight - latestTurnHeight) + + pendingIndicatorSpacerReduction + return max(0, scrollViewHeight - latestTurnHeight - minTailSpacer) } /// Pin a freshly sent user message to the top of the viewport. private func pinSentMessageToTop(_ id: UUID, proxy: ScrollViewProxy) { - // Defer one runloop so the dynamic tail spacer grows before we scroll — - // otherwise there is not enough content to reach the top. - DispatchQueue.main.async { - withAnimation(.easeInOut(duration: 0.25)) { - proxy.scrollTo(id, anchor: .top) + pinToTopTask?.cancel() + canReleasePinnedTurnByScroll = false + pinToTopTask = Task { @MainActor in + // Give SwiftUI one frame to realize the new row, then keep + // asserting the pin while the tracked-row geometry, tail spacer, + // streaming indicator, and keyboard layout settle. + try? await Task.sleep(for: .milliseconds(16)) + for attempt in 0 ..< 12 { + guard !Task.isCancelled else { return } + if attempt == 0 { + withAnimation(.easeInOut(duration: 0.25)) { + proxy.scrollTo(id, anchor: .top) + } + } else { + var transaction = Transaction() + transaction.animation = nil + withTransaction(transaction) { + proxy.scrollTo(id, anchor: .top) + } + } + try? await Task.sleep(for: .milliseconds(16)) } + guard !Task.isCancelled, isPinningLatestTurnToTop else { return } + canReleasePinnedTurnByScroll = true } } + private func handleComposerBoundaryChange( + oldValue: CGFloat, + newValue: CGFloat, + proxy: ScrollViewProxy + ) { + guard didEstablishInitialScroll, abs(newValue - oldValue) > 0.5 else { return } + if isPinningLatestTurnToTop, let id = activeTurnUserMessageID ?? trackedUserMessageID { + pinSentMessageToTop(id, proxy: proxy) + } else if autoScrollEnabled { + scrollToBottomAfterLayout(proxy: proxy) + } + } + + private func repinActiveTurnIfNeeded(proxy: ScrollViewProxy) -> Bool { + guard isPinningLatestTurnToTop, let id = activeTurnUserMessageID else { + return false + } + pinSentMessageToTop(id, proxy: proxy) + return true + } + + private func releasePinnedTurnToBottom(proxy: ScrollViewProxy) { + pinToTopTask?.cancel() + canReleasePinnedTurnByScroll = false + isPinningLatestTurnToTop = false + autoScrollEnabled = true + scrollToBottomAfterLayout(proxy: proxy) + } + /// `minY` of the latest user message — fed back from `ChatMessageListView`. private func updateLatestUserMinY(_ value: CGFloat) { guard abs(value - latestUserMinY) > 0.5 else { return } @@ -703,9 +1004,15 @@ struct MobileChatView: View { /// height of the latest turn. private func updateTailSpacerMinY(_ value: CGFloat) { guard abs(value - tailSpacerMinY) > 0.5 else { return } + let oldValue = tailSpacerMinY var t = Transaction() t.animation = nil - withTransaction(t) { tailSpacerMinY = value } + withTransaction(t) { + tailSpacerMinY = value + if pendingIndicatorSpacerReduction > 0, value > oldValue { + pendingIndicatorSpacerReduction = 0 + } + } } private func handleStop() { @@ -750,6 +1057,9 @@ struct MobileChatView: View { private func scrollToBottomFromButton(_ proxy: ScrollViewProxy) { mobileChatLogger.debug("[ScrollButton] scroll immediate") + pinToTopTask?.cancel() + canReleasePinnedTurnByScroll = false + isPinningLatestTurnToTop = false withAnimation(.easeInOut(duration: 0.2)) { proxy.scrollTo(Self.bottomAnchorID, anchor: .bottom) } @@ -868,7 +1178,6 @@ struct MobileChatView: View { Text(planBannerText) .font(.system(size: 13, weight: .medium)) - .foregroundStyle(ClaudeTheme.textPrimary) Spacer(minLength: 8) diff --git a/RxCodeMobile/Views/ProjectsSidebar.swift b/RxCodeMobile/Views/ProjectsSidebar.swift index fe87b43..be24a2d 100644 --- a/RxCodeMobile/Views/ProjectsSidebar.swift +++ b/RxCodeMobile/Views/ProjectsSidebar.swift @@ -376,12 +376,16 @@ private struct GlassProjectCard: View { var onSelect: (() -> Void)? var body: some View { + // The UI-test identifier is applied directly on the button of each + // branch — applying it to an enclosing container does not reach the + // button element XCUITest queries. if usesNavigationLink { NavigationLink(value: project.id) { cardContent } .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) .glassEffectID(project.id.uuidString, in: namespace) + .accessibilityIdentifier("project-row-\(project.id.uuidString)") } else { Button { onSelect?() @@ -390,6 +394,7 @@ private struct GlassProjectCard: View { } .buttonStyle(GlassProjectCardButtonStyle(isSelected: isSelected)) .glassEffectID(project.id.uuidString, in: namespace) + .accessibilityIdentifier("project-row-\(project.id.uuidString)") } } diff --git a/RxCodeMobile/Views/RootView.swift b/RxCodeMobile/Views/RootView.swift index 473429d..c3daa1d 100644 --- a/RxCodeMobile/Views/RootView.swift +++ b/RxCodeMobile/Views/RootView.swift @@ -378,18 +378,27 @@ struct RootView: View { selectedTab = .projects showingBriefing = false + // Resolve the owning project so the navigation stack keeps its + // Projects → Threads → Chat hierarchy. Draft sessions encode the + // project in their ID; synced sessions are looked up in `state`. + let resolvedProjectID = projectID + ?? state.sessions.first(where: { $0.id == sessionID })?.projectId + ?? MobileDraftSessionID.projectID(from: sessionID) + if compactClass == .compact { + // Push the project level before the chat so the back button + // returns to the thread list, not the project list. var path = NavigationPath() + if let resolvedProjectID { + path.append(resolvedProjectID) + } path.append(sessionID) if projectsPath != path { projectsPath = path } } else { - guard let projectID = projectID - ?? state.sessions.first(where: { $0.id == sessionID })?.projectId - else { return } - - selectedProject = projectID + guard let resolvedProjectID else { return } + selectedProject = resolvedProjectID selectedSession = sessionID } } diff --git a/RxCodeMobile/Views/SessionsList.swift b/RxCodeMobile/Views/SessionsList.swift index 76d8f11..68d47a1 100644 --- a/RxCodeMobile/Views/SessionsList.swift +++ b/RxCodeMobile/Views/SessionsList.swift @@ -121,6 +121,7 @@ struct SessionsList: View { } .scrollDismissesKeyboard(.interactively) .animation(.spring(duration: 0.3), value: filtered.map(\.id)) + .accessibilityIdentifier("thread-list-screen") } @ViewBuilder diff --git a/RxCodeMobile/Views/SyncLoadingView.swift b/RxCodeMobile/Views/SyncLoadingView.swift index f1e53eb..c7d8a88 100644 --- a/RxCodeMobile/Views/SyncLoadingView.swift +++ b/RxCodeMobile/Views/SyncLoadingView.swift @@ -19,6 +19,8 @@ struct SyncLoadingView: View { @State private var orbRotation: Double = 0 @State private var shimmerOffset: CGFloat = -200 @State private var appeared = false + @State private var animationStartTask: Task? + @State private var shimmerTask: Task? var body: some View { let _ = logger.debug("SyncLoadingView body: isTimedOut=\(self.isTimedOut)") @@ -76,11 +78,15 @@ struct SyncLoadingView: View { .padding(.horizontal, 40) } .onAppear { + resetAnimations() startAnimations() withAnimation(.easeOut(duration: 0.6)) { appeared = true } } + .onDisappear { + stopAnimations() + } } // MARK: - Timeout View @@ -480,36 +486,55 @@ struct SyncLoadingView: View { } private func startAnimations() { - // Pulse animation - withAnimation( - .easeInOut(duration: 2.0) - .repeatForever(autoreverses: true) - ) { - pulseScale = 1.12 - } + animationStartTask?.cancel() + animationStartTask = Task { @MainActor in + await Task.yield() + guard !Task.isCancelled, !isTimedOut else { return } + + withAnimation( + .easeInOut(duration: 2.0) + .repeatForever(autoreverses: true) + ) { + pulseScale = 1.12 + } - // Orbit rotation - use a very large target value with proportionally long duration - // 5 seconds per 360 degrees = 5000 seconds for 360,000 degrees - withAnimation( - .linear(duration: 5000) - .repeatForever(autoreverses: false) - ) { - orbRotation = 360 * 1000 + withAnimation( + .linear(duration: 5.0) + .repeatForever(autoreverses: false) + ) { + orbRotation = 360 + } + + startShimmerLoop() } + } - // Shimmer animation - reset and loop manually for smooth continuous effect - startShimmerLoop() + private func resetAnimations() { + stopAnimations() + pulseScale = 1.0 + orbRotation = 0 + shimmerOffset = -200 + appeared = false + } + + private func stopAnimations() { + animationStartTask?.cancel() + animationStartTask = nil + shimmerTask?.cancel() + shimmerTask = nil } private func startShimmerLoop() { - shimmerOffset = -200 - withAnimation(.easeInOut(duration: 1.8)) { - shimmerOffset = 200 - } - // Schedule next loop - DispatchQueue.main.asyncAfter(deadline: .now() + 1.8) { [self] in - if appeared && !isTimedOut { - startShimmerLoop() + shimmerTask?.cancel() + shimmerTask = Task { @MainActor in + while !Task.isCancelled { + shimmerOffset = -200 + await Task.yield() + guard !Task.isCancelled else { return } + withAnimation(.easeInOut(duration: 1.8)) { + shimmerOffset = 200 + } + try? await Task.sleep(for: .milliseconds(1800)) } } } diff --git a/RxCodeMobileUITests/Mock/MockFixtures.swift b/RxCodeMobileUITests/Mock/MockFixtures.swift new file mode 100644 index 0000000..809bd4c --- /dev/null +++ b/RxCodeMobileUITests/Mock/MockFixtures.swift @@ -0,0 +1,134 @@ +import Foundation +import RxCodeCore +import RxCodeSync + +/// Deterministic test data served by `MockRelayServer`. +/// +/// Two projects, two threads each, one branch briefing per project, and a few +/// plain-text messages per thread — enough to drive the briefing and projects +/// navigation flows the UI tests exercise. +nonisolated struct MockFixtures: Sendable { + + let projects: [Project] + let sessions: [SessionSummary] + let branchBriefings: [MobileBranchBriefing] + let threadSummaries: [MobileThreadSummary] + /// Full message list keyed by session id. + let messagesBySession: [String: [ChatMessage]] + + /// Builds a `snapshot` payload for whichever session the app is asking + /// about. When `activeSessionID` has fixture messages they are embedded as + /// the active page; otherwise the app just gets the project/thread lists. + func snapshot(activeSessionID: String?) -> SnapshotPayload { + let messages = activeSessionID.flatMap { messagesBySession[$0] } + return SnapshotPayload( + projects: projects, + sessions: sessions, + branchBriefings: branchBriefings, + threadSummaries: threadSummaries, + settings: nil, + activeSessionID: activeSessionID, + activeSessionMessages: messages, + activeSessionHasMore: false, + projectBranches: nil, + usage: nil, + hostMetrics: nil, + runProfiles: nil, + runTasks: nil, + webProxy: nil + ) + } + + /// The standard fixture set used by every UI test. + static func standard() -> MockFixtures { + let projectAlpha = UUID(uuidString: "A0000000-0000-0000-0000-000000000001")! + let projectBeta = UUID(uuidString: "B0000000-0000-0000-0000-000000000002")! + let branch = "main" + // Fixed timestamp keeps every encoded payload byte-stable across runs. + let now = Date(timeIntervalSince1970: 1_716_000_000) + + let projects = [ + Project(id: projectAlpha, name: "Project Alpha", path: "/tmp/project-alpha"), + Project(id: projectBeta, name: "Project Beta", path: "/tmp/project-beta"), + ] + + struct ThreadSpec { + let sessionId: String + let projectId: UUID + let title: String + } + let specs = [ + ThreadSpec(sessionId: "sess-alpha-1", projectId: projectAlpha, title: "Alpha Thread One"), + ThreadSpec(sessionId: "sess-alpha-2", projectId: projectAlpha, title: "Alpha Thread Two"), + ThreadSpec(sessionId: "sess-beta-1", projectId: projectBeta, title: "Beta Thread One"), + ThreadSpec(sessionId: "sess-beta-2", projectId: projectBeta, title: "Beta Thread Two"), + ] + + let sessions = specs.map { spec in + SessionSummary( + id: spec.sessionId, + projectId: spec.projectId, + title: spec.title, + updatedAt: now, + isPinned: false, + isArchived: false + ) + } + + let threadSummaries = specs.map { spec in + MobileThreadSummary( + sessionId: spec.sessionId, + projectId: spec.projectId, + branch: branch, + title: spec.title, + summary: "Summary of \(spec.title).", + updatedAt: now + ) + } + + let branchBriefings = [ + MobileBranchBriefing( + projectId: projectAlpha, + branch: branch, + briefing: "Briefing for Project Alpha on main.", + updatedAt: now + ), + MobileBranchBriefing( + projectId: projectBeta, + branch: branch, + briefing: "Briefing for Project Beta on main.", + updatedAt: now + ), + ] + + var messagesBySession: [String: [ChatMessage]] = [:] + for spec in specs { + messagesBySession[spec.sessionId] = [ + ChatMessage( + role: .user, + content: "Hello from \(spec.title).", + timestamp: now + ), + ChatMessage( + role: .assistant, + content: "Assistant reply inside \(spec.title).", + isResponseComplete: true, + timestamp: now + ), + ChatMessage( + role: .user, + content: "Follow-up question in \(spec.title).", + timestamp: now + ), + ] + } + + return MockFixtures( + projects: projects, + sessions: sessions, + branchBriefings: branchBriefings, + threadSummaries: threadSummaries, + messagesBySession: messagesBySession + ) + } +} diff --git a/RxCodeMobileUITests/Mock/MockRelayServer.swift b/RxCodeMobileUITests/Mock/MockRelayServer.swift new file mode 100644 index 0000000..32ecf9f --- /dev/null +++ b/RxCodeMobileUITests/Mock/MockRelayServer.swift @@ -0,0 +1,245 @@ +import CryptoKit +import Foundation +import Network +import RxCodeCore +import RxCodeSync + +/// An in-process WebSocket server that stands in for both the relay router and +/// the paired desktop peer during UI tests. +/// +/// It listens on an ephemeral `localhost` port, accepts the app's WebSocket +/// connection, decrypts the envelopes the app sends using `RxCodeSync`'s own +/// crypto, and replies with canned `snapshot` payloads built from `MockFixtures`. +/// The app is paired against this server's deterministic desktop identity by the +/// UI-test launch arguments, so no real QR-code pairing handshake happens. +/// +/// The wire framing mirrors `RelayClient` exactly: a plain `JSONEncoder` (default +/// `.deferredToDate`), `SessionCrypto.seal`/`open` with no extra salt, and the +/// sealed `Envelope` sent as a binary WebSocket message. +nonisolated final class MockRelayServer: @unchecked Sendable { + + enum MockError: Error { + case startTimedOut + case noPort + } + + /// Deterministic 32-byte Curve25519 private key. The matching public-key hex + /// is handed to the app via launch arguments so it pairs against exactly + /// this identity. Any fixed 32 bytes form a valid X25519 scalar. + private static let desktopPrivateKeyBytes = [UInt8](repeating: 0x42, count: 32) + + /// The desktop identity the mock impersonates. + let desktopIdentity: DeviceIdentity + var desktopPublicKeyHex: String { desktopIdentity.publicKeyHex } + + private let fixtures: MockFixtures + private let queue = DispatchQueue(label: "com.idealapp.RxCodeMobile.MockRelayServer") + + private var listener: NWListener? + private var connection: NWConnection? + /// Learned from the first decrypted envelope's `from` field. + private var mobilePublicKey: Curve25519.KeyAgreement.PublicKey? + + /// Ephemeral port the listener bound to. Valid after `start()` returns. + private(set) var port: UInt16 = 0 + + /// WebSocket URL the app should connect to. Valid only after `start()`. + var relayURL: String { "ws://127.0.0.1:\(port)/ws" } + + init(fixtures: MockFixtures) { + self.fixtures = fixtures + // Fixed 32 bytes — guaranteed a valid private key, so `try!` is safe. + let key = try! Curve25519.KeyAgreement.PrivateKey( + rawRepresentation: Data(Self.desktopPrivateKeyBytes) + ) + self.desktopIdentity = DeviceIdentity(privateKey: key) + } + + // MARK: - Lifecycle + + /// Starts the listener and blocks until it is ready (or fails). Safe to call + /// from the test's `setUp`. + func start() throws { + let wsOptions = NWProtocolWebSocket.Options() + // Auto-answer the app's WebSocket pings — `RelayClient` only flips to + // `.connected` once a ping round-trips, which is what makes the app send + // its first `requestSnapshot`. + wsOptions.autoReplyPing = true + + let parameters = NWParameters.tcp + parameters.allowLocalEndpointReuse = true + parameters.defaultProtocolStack.applicationProtocols.insert(wsOptions, at: 0) + + let listener = try NWListener(using: parameters, on: .any) + self.listener = listener + + let ready = DispatchSemaphore(value: 0) + let failure = Locked(nil) + + listener.stateUpdateHandler = { state in + switch state { + case .ready: + ready.signal() + case .failed(let error): + failure.value = error + ready.signal() + default: + break + } + } + listener.newConnectionHandler = { [weak self] connection in + self?.accept(connection) + } + listener.start(queue: queue) + + if ready.wait(timeout: .now() + 5) == .timedOut { + listener.cancel() + throw MockError.startTimedOut + } + if let error = failure.value { + listener.cancel() + throw error + } + guard let boundPort = listener.port?.rawValue else { + listener.cancel() + throw MockError.noPort + } + port = boundPort + } + + /// Tears the server down. Safe to call from the test's `tearDown`. + func stop() { + queue.sync { + connection?.cancel() + connection = nil + listener?.cancel() + listener = nil + } + } + + // MARK: - Connection handling + + private func accept(_ connection: NWConnection) { + self.connection = connection + connection.start(queue: queue) + receive(on: connection) + } + + private func receive(on connection: NWConnection) { + connection.receiveMessage { [weak self] data, _, _, error in + guard let self else { return } + if let data, !data.isEmpty { + self.handleIncoming(data, on: connection) + } + // Stop the loop once the peer closes (no data) or errors. + if error == nil, data != nil { + self.receive(on: connection) + } + } + } + + /// Decrypt one inbound envelope and reply if it is a payload we mock. + private func handleIncoming(_ raw: Data, on connection: NWConnection) { + let decoder = JSONDecoder() + guard let envelope = try? decoder.decode(Envelope.self, from: raw), + let nonce = envelope.nonceData, + let ciphertext = envelope.ciphertextData, + let fromRaw = Self.data(fromHex: envelope.from), + let mobileKey = try? Curve25519.KeyAgreement.PublicKey(rawRepresentation: fromRaw) + else { return } + + mobilePublicKey = mobileKey + + guard let plaintext = try? SessionCrypto.open( + ciphertext: ciphertext, + nonce: nonce, + recipient: desktopIdentity.privateKey, + sender: mobileKey + ), + let payload = try? decoder.decode(Payload.self, from: plaintext) + else { return } + + switch payload { + case .requestSnapshot(let request): + send(.snapshot(fixtures.snapshot(activeSessionID: request.activeSessionID)), + on: connection) + case .subscribeSession(let request): + send(.snapshot(fixtures.snapshot(activeSessionID: request.sessionID)), + on: connection) + case .loadMoreMessages(let request): + // The fixture window is the whole thread, so there is nothing older. + send(.moreMessages(MoreMessagesPayload( + clientRequestID: request.clientRequestID, + sessionID: request.sessionID, + messages: [], + hasMore: false + )), on: connection) + default: + // pairRequest, apnsToken, userMessage, ping, … — decrypt and ignore. + break + } + } + + /// Seal `payload` for the mobile peer and send it as a binary WS message. + private func send(_ payload: Payload, on connection: NWConnection) { + guard let mobileKey = mobilePublicKey, + let plaintext = try? JSONEncoder().encode(payload), + let sealed = try? SessionCrypto.seal( + plaintext: plaintext, + sender: desktopIdentity.privateKey, + recipient: mobileKey + ) + else { return } + + let envelope = Envelope( + to: Self.hexString(mobileKey.rawRepresentation), + from: desktopPublicKeyHex, + nonce: sealed.nonce, + ct: sealed.ciphertext + ) + guard let raw = try? JSONEncoder().encode(envelope) else { return } + + let metadata = NWProtocolWebSocket.Metadata(opcode: .binary) + let context = NWConnection.ContentContext(identifier: "send", metadata: [metadata]) + connection.send(content: raw, contentContext: context, + completion: .contentProcessed { _ in }) + } + + // MARK: - Hex helpers + + /// `RxCodeSync`'s own hex helpers are `internal`, so the mock keeps its own + /// lowercase encoder/decoder that produces an identical representation. + private static func hexString(_ data: Data) -> String { + data.map { String(format: "%02x", $0) }.joined() + } + + private static func data(fromHex hex: String) -> Data? { + let chars = Array(hex) + guard chars.count % 2 == 0 else { return nil } + var bytes = [UInt8]() + bytes.reserveCapacity(chars.count / 2) + var index = 0 + while index < chars.count { + guard let byte = UInt8(String(chars[index..: @unchecked Sendable { + private let lock = NSLock() + private var stored: Value + + init(_ value: Value) { stored = value } + + var value: Value { + get { lock.withLock { stored } } + set { lock.withLock { stored = newValue } } + } +} diff --git a/RxCodeMobileUITests/RxCodeMobileUITests.swift b/RxCodeMobileUITests/RxCodeMobileUITests.swift index f2f01a3..7c7e1e1 100644 --- a/RxCodeMobileUITests/RxCodeMobileUITests.swift +++ b/RxCodeMobileUITests/RxCodeMobileUITests.swift @@ -1,41 +1,19 @@ -// -// RxCodeMobileUITests.swift -// RxCodeMobileUITests -// -// Created by Qiwei Li on 5/19/26. -// - import XCTest +/// Smoke test for the mock-server pipeline: confirms the app skips pairing, +/// connects to the in-process mock relay, applies the snapshot, and reaches the +/// main UI. Runs on any device idiom. final class RxCodeMobileUITests: XCTestCase { - override func setUpWithError() throws { - // Put setup code here. This method is called before the invocation of each test method in the class. - - // In UI tests it is usually best to stop immediately when a failure occurs. - continueAfterFailure = false - - // In UI tests it’s important to set the initial state - such as interface orientation - required for your tests before they run. The setUp method is a good place to do this. - } - - override func tearDownWithError() throws { - // Put teardown code here. This method is called after the invocation of each test method in the class. - } - - @MainActor - func testExample() throws { - // UI tests must launch the application that they test. - let app = XCUIApplication() - app.launch() - - // Use XCTAssert and related functions to verify your tests produce the correct results. - } - @MainActor - func testLaunchPerformance() throws { - // This measures how long it takes to launch your application. - measure(metrics: [XCTApplicationLaunchMetric()]) { - XCUIApplication().launch() - } + func testLaunchesPastPairingIntoMainUI() throws { + let session = try UITestRunner.launch(.any, on: self) + + // A fixture project name on screen proves the whole pipeline worked: + // launch args → skip pairing → mock relay handshake → snapshot applied. + XCTAssertTrue( + session.app.staticTexts["Project Alpha"].waitForExistence(timeout: 25), + "Main UI never appeared — the mock pairing/snapshot pipeline failed." + ) } } diff --git a/RxCodeMobileUITests/Support/MobileAppRobot.swift b/RxCodeMobileUITests/Support/MobileAppRobot.swift new file mode 100644 index 0000000..d9c8629 --- /dev/null +++ b/RxCodeMobileUITests/Support/MobileAppRobot.swift @@ -0,0 +1,164 @@ +import CoreGraphics +import XCTest + +/// Page object over the app's accessibility identifiers. Keeps the test cases +/// readable and free of raw `XCUIElement` queries. +@MainActor +struct MobileAppRobot { + let app: XCUIApplication + + /// Generous timeout — the app gates content behind a ~2s splash plus the + /// mock snapshot round-trip. + var timeout: TimeInterval = 25 + + // MARK: - Tabs (iPhone) + + /// The legacy `TabView` API exposes tab-bar buttons by their label, not by + /// an accessibility identifier, so tabs are matched by title. `firstMatch` + /// guards against the tab bar surfacing more than one element per tab. + var briefingTab: XCUIElement { app.tabBars.buttons["Briefing"].firstMatch } + var projectsTab: XCUIElement { app.tabBars.buttons["Projects"].firstMatch } + + // MARK: - Briefing + + var anyBriefingCard: XCUIElement { firstButton(prefix: "briefing-card-") } + var anyBriefingListCard: XCUIElement { firstButton(prefix: "briefing-list-card-") } + var briefingListScreen: XCUIElement { element(id: "briefing-list-screen") } + var briefingDetailScreen: XCUIElement { element(id: "briefing-detail-screen") } + var anyBriefingThreadRow: XCUIElement { firstButton(prefix: "briefing-thread-row-") } + + // MARK: - Projects / threads + + var anyProjectRow: XCUIElement { firstButton(prefix: "project-row-") } + var threadListScreen: XCUIElement { element(id: "thread-list-screen") } + var anyThreadRow: XCUIElement { firstButton(prefix: "thread-row-") } + + // MARK: - Chat + + var chatScreen: XCUIElement { element(id: "chat-screen") } + var chatMessageList: XCUIElement { element(id: "chat-message-list") } + + // MARK: - Queries + + private func firstButton(prefix: String) -> XCUIElement { + app.buttons + .matching(NSPredicate(format: "identifier BEGINSWITH %@", prefix)) + .firstMatch + } + + /// Looks an identifier up across element types — the list/detail "screens" + /// are scroll views, which surface under different queries by idiom. + private func element(id: String) -> XCUIElement { + app.descendants(matching: .any).matching(identifier: id).firstMatch + } + + // MARK: - Actions + + /// Waits for `element`, then taps it. + /// + /// Prefers a normal hittable tap (which also lets navigation/splash + /// animations settle first). If the hittability heuristic never settles — + /// observed on CI iPad simulators after a device rotation, where elements + /// render and exist but `isHittable` stays `false` — it falls back to a + /// coordinate tap on the element's centre, which still delivers a real + /// touch to the rendered element. + @discardableResult + func tap( + _ element: XCUIElement, + _ description: String, + file: StaticString = #filePath, + line: UInt = #line + ) -> XCUIElement { + XCTAssertTrue( + element.waitForExistence(timeout: timeout), + "Element never appeared: \(description)", + file: file, line: line + ) + tapWhenReady(element) + return element + } + + /// Taps the leading navigation-bar button (the back button). + func navigateBack(file: StaticString = #filePath, line: UInt = #line) { + let backButton = app.navigationBars.buttons.element(boundBy: 0) + XCTAssertTrue( + backButton.waitForExistence(timeout: timeout), + "Back button never appeared", + file: file, line: line + ) + tapWhenReady(backButton) + } + + /// Taps `element` once it is hittable, or — if hittability never settles — + /// via a coordinate tap on its centre. + private func tapWhenReady(_ element: XCUIElement) { + if waitForHittable(element, timeout: 8) { + element.tap() + } else { + element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() + } + } + + /// Waits up to `timeout` for `element` to report itself hittable. Returns + /// whether it did — callers use the result to choose a tap strategy rather + /// than to fail the test. + @discardableResult + func waitForHittable(_ element: XCUIElement, timeout: TimeInterval) -> Bool { + let expectation = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "hittable == true"), + object: element + ) + return XCTWaiter().wait(for: [expectation], timeout: timeout) == .completed + } + + // MARK: - Assertions + + func assertExists( + _ element: XCUIElement, + _ description: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + XCTAssertTrue( + element.waitForExistence(timeout: timeout), + "Expected to be visible: \(description)", + file: file, line: line + ) + } + + /// Waits until `element` is gone — used to confirm a pushed view was popped. + func assertDisappears( + _ element: XCUIElement, + _ description: String, + file: StaticString = #filePath, + line: UInt = #line + ) { + let predicate = NSPredicate(format: "exists == false") + let expectation = XCTNSPredicateExpectation(predicate: predicate, object: element) + let result = XCTWaiter().wait(for: [expectation], timeout: timeout) + XCTAssertEqual( + result, .completed, + "Expected to disappear: \(description)", + file: file, line: line + ) + } + + /// Asserts the chat screen is shown with at least one message rendered. + func assertMessagesShown(file: StaticString = #filePath, line: UInt = #line) { + XCTAssertTrue( + chatScreen.waitForExistence(timeout: timeout), + "Chat screen was not shown", + file: file, line: line + ) + XCTAssertTrue( + chatMessageList.waitForExistence(timeout: timeout), + "Message list was not shown", + file: file, line: line + ) + XCTAssertGreaterThan( + chatMessageList.staticTexts.count, 0, + "Message list rendered no text", + file: file, line: line + ) + } +} diff --git a/RxCodeMobileUITests/Support/UITestRunner.swift b/RxCodeMobileUITests/Support/UITestRunner.swift new file mode 100644 index 0000000..f97fa33 --- /dev/null +++ b/RxCodeMobileUITests/Support/UITestRunner.swift @@ -0,0 +1,91 @@ +import UIKit +import XCTest + +/// A launched app under test, paired with the mock relay server feeding it. +@MainActor +struct MockAppSession { + let app: XCUIApplication + let server: MockRelayServer + + /// Page object for driving the app's navigation. + var robot: MobileAppRobot { MobileAppRobot(app: app) } +} + +/// Boots the app for a UI test: starts the in-process mock relay server, launches +/// the app in mock mode (skips pairing, points the relay at the mock), and +/// registers teardown. +/// +/// Implemented as a free helper rather than an `XCTestCase` subclass so the test +/// classes never override `setUp`/`tearDown` — that keeps them clear of the +/// actor-isolation pitfalls around overriding `nonisolated` XCTest hooks. +@MainActor +enum UITestRunner { + + enum Idiom { + case phone + case pad + /// Runs regardless of device idiom. + case any + } + + /// Starts the mock server, launches the app, and registers teardown on + /// `testCase`. Throws `XCTSkip` when the running simulator's idiom does not + /// match `idiom`. + static func launch( + _ idiom: Idiom, + on testCase: XCTestCase + ) throws -> MockAppSession { + testCase.continueAfterFailure = false + + let isPad = UIDevice.current.userInterfaceIdiom == .pad + switch idiom { + case .phone: + try XCTSkipIf(isPad, "iPhone-only test skipped on iPad simulator.") + case .pad: + try XCTSkipUnless(isPad, "iPad-only test skipped on iPhone simulator.") + case .any: + break + } + + let server = MockRelayServer(fixtures: .standard()) + try server.start() + // Stop the server at teardown. `MockRelayServer` is Sendable; the app is + // re-terminated automatically by the next `XCUIApplication.launch()`. + testCase.addTeardownBlock { + server.stop() + } + + let app = XCUIApplication() + app.launchArguments += [ + "-uitest-mock", + "-uitest-relay-url", server.relayURL, + "-uitest-desktop-pubkey", server.desktopPublicKeyHex, + ] + app.launch() + + // The iPad uses a three-column split view; landscape keeps the sidebar, + // content, and detail columns all on screen. Rotating after launch, with + // the app foregrounded, applies more reliably than rotating SpringBoard. + if isPad { + XCUIDevice.shared.orientation = .landscapeLeft + } + + // Wait for the mock snapshot to populate the main UI, then give the + // launch splash a bounded window to finish fading so the first tap lands + // on interactive UI. The hittable wait is best-effort: on some CI + // simulators it never settles, and `MobileAppRobot` handles that with a + // coordinate-tap fallback. + let readyMarker = app.staticTexts["Project Alpha"].firstMatch + XCTAssertTrue( + readyMarker.waitForExistence(timeout: 30), + "Main UI never appeared — the mock pairing/snapshot pipeline failed." + ) + let settled = XCTNSPredicateExpectation( + predicate: NSPredicate(format: "hittable == true"), + object: readyMarker + ) + _ = XCTWaiter().wait(for: [settled], timeout: 8) + + return MockAppSession(app: app, server: server) + } +} diff --git a/RxCodeMobileUITests/iPadNavigationUITests.swift b/RxCodeMobileUITests/iPadNavigationUITests.swift new file mode 100644 index 0000000..3e759a6 --- /dev/null +++ b/RxCodeMobileUITests/iPadNavigationUITests.swift @@ -0,0 +1,45 @@ +import XCTest + +/// iPad navigation flows (UI test cases 2 & 4). +/// +/// On iPad the app uses a three-column `NavigationSplitView`. Opening a thread +/// swaps in the chat without unwinding navigation, so the list column the user +/// came from stays visible — that persistence is what these tests assert, +/// instead of navigating back as the iPhone tests do. +final class iPadNavigationUITests: XCTestCase { + + /// Case 2: Briefing → briefing detail → thread → messages, and the briefing + /// list column remains visible (split view keeps context). + @MainActor + func testBriefingSplitKeepsListVisible() throws { + let r = try UITestRunner.launch(.pad, on: self).robot + + // The iPad briefing split view is shown at launch. + r.tap(r.anyBriefingListCard, "a briefing card in the list column") + r.assertExists(r.briefingDetailScreen, "briefing detail screen") + + r.tap(r.anyBriefingThreadRow, "a thread row in the briefing detail") + r.assertMessagesShown() + + // The chat is pushed inside the detail column; the briefing list column + // stays on screen — the split view never lost the briefing context. + r.assertExists(r.briefingListScreen, "briefing list column still visible") + } + + /// Case 4: Projects → project → thread list → thread → messages, and the + /// thread list column remains visible (split view keeps context). + @MainActor + func testProjectsSplitKeepsThreadListVisible() throws { + let r = try UITestRunner.launch(.pad, on: self).robot + + // Selecting a project in the sidebar switches to the projects split view. + r.tap(r.anyProjectRow, "a project row in the sidebar") + r.assertExists(r.threadListScreen, "thread list column") + + r.tap(r.anyThreadRow, "a thread row") + r.assertMessagesShown() + + // The chat fills the detail column; the thread list column stays visible. + r.assertExists(r.threadListScreen, "thread list column still visible") + } +} diff --git a/RxCodeMobileUITests/iPhoneNavigationUITests.swift b/RxCodeMobileUITests/iPhoneNavigationUITests.swift new file mode 100644 index 0000000..f4dd2e4 --- /dev/null +++ b/RxCodeMobileUITests/iPhoneNavigationUITests.swift @@ -0,0 +1,44 @@ +import XCTest + +/// iPhone navigation flows (UI test cases 1 & 3). +/// +/// On iPhone the app uses a `TabView` with `NavigationStack` tabs, so each flow +/// pushes a detail screen and navigates back with the navigation-bar back button. +final class iPhoneNavigationUITests: XCTestCase { + + /// Case 1: Briefing tab → briefing detail → thread → messages → back → + /// briefing detail is shown again. + @MainActor + func testBriefingFlowReturnsToDetailAfterBack() throws { + let r = try UITestRunner.launch(.phone, on: self).robot + + r.tap(r.briefingTab, "Briefing tab") + r.tap(r.anyBriefingCard, "a briefing card") + r.assertExists(r.briefingDetailScreen, "briefing detail screen") + + r.tap(r.anyBriefingThreadRow, "a thread row in the briefing detail") + r.assertMessagesShown() + + r.navigateBack() + r.assertExists(r.briefingDetailScreen, "briefing detail screen after back") + r.assertDisappears(r.chatScreen, "chat screen after navigating back") + } + + /// Case 3: Projects tab → project → thread list → thread → messages → back → + /// thread list is shown again. + @MainActor + func testProjectsFlowReturnsToThreadListAfterBack() throws { + let r = try UITestRunner.launch(.phone, on: self).robot + + r.tap(r.projectsTab, "Projects tab") + r.tap(r.anyProjectRow, "a project row") + r.assertExists(r.threadListScreen, "thread list screen") + + r.tap(r.anyThreadRow, "a thread row") + r.assertMessagesShown() + + r.navigateBack() + r.assertExists(r.threadListScreen, "thread list screen after back") + r.assertDisappears(r.chatScreen, "chat screen after navigating back") + } +} diff --git a/relay-server/README.md b/relay-server/README.md index 77cc9b3..03f74a8 100644 --- a/relay-server/README.md +++ b/relay-server/README.md @@ -19,6 +19,7 @@ envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. ```json { "device_token": "", + "apns_environment": "sandbox", // "sandbox" or "production" "encrypted_alert": "", "category": "permission_request", // optional "collapse_id": "" // optional @@ -30,6 +31,7 @@ envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. ```json { "device_token": "", + "apns_environment": "sandbox", "push_type": "liveactivity", "apns_payload": { "aps": { "event": "update", "content-state": { … } } }, "collapse_id": "" // optional @@ -39,10 +41,14 @@ envelopes (`{v, to, from, nonce, ct}`) and a destination pubkey. `.push-type.liveactivity`. Live Activity content-state is **not** E2E encrypted — ActivityKit consumes it directly. - **`background`** — silent `content-available` push used to refresh the - home-screen widget. Body is `{ "device_token", "push_type": "background", - "apns_payload" }`; the payload is forwarded verbatim at low priority. + home-screen widget. Body is `{ "device_token", "apns_environment", + "push_type": "background", "apns_payload" }`; the payload is forwarded + verbatim at low priority. The relay signs a JWT with the configured APNs auth key and forwards the push. + It keeps both sandbox and production APNs clients alive, then routes each + push by `apns_environment`. If older desktop clients omit the field, + `APNS_PRODUCTION` is used as a compatibility default. - `GET /healthz` — liveness probe. ## Run locally @@ -71,7 +77,7 @@ missing file is non-fatal — the relay just uses whatever's in the process env. | `-apns-key-id` | `APNS_KEY_ID` | 10-char Key ID from the Apple developer portal. | | `-apns-team-id` | `APNS_TEAM_ID` | 10-char Team ID. | | `-apns-topic` | `APNS_TOPIC` | iOS app bundle identifier (e.g. `app.rxlab.rxcodemobile`). | -| `-apns-production` | `APNS_PRODUCTION` | `true` for production endpoint, else sandbox. | +| `-apns-production` | `APNS_PRODUCTION` | Compatibility default when a push omits `apns_environment`. | | `-redis-url` | `REDIS_URL` | Redis URL for the multi-node backplane. Empty = single-node. | `APNS_KEY_B64` wins over `APNS_KEY_PATH` when both are set. Both standard and @@ -88,7 +94,7 @@ APNS_KEY_B64=MIGTAgEAMBM... # base64 of your .p8 file APNS_KEY_ID=ABCDE12345 APNS_TEAM_ID=YYYYYYYYYY APNS_TOPIC=app.rxlab.rxcodemobile -APNS_PRODUCTION=false +APNS_PRODUCTION=false # fallback only for old desktop builds ``` Encode the `.p8` ready for the file: @@ -129,9 +135,10 @@ No bind-mount needed — the key lives only in the container environment. - Generate a `.p8` auth key in https://developer.apple.com/account/resources/authkeys/list. - `-apns-topic` must equal the iOS app's bundle identifier exactly (`com.idealapp.RxCode.Mobile`). -- Sandbox (`-apns-production=false`) is required for `xcrun simctl push` and - development builds; production builds and TestFlight require - `-apns-production=true`. +- Sandbox APNs is required for development/debug builds; production APNs is + required for TestFlight/App Store builds. The mobile app reports this as + `sandbox` or `production`, the desktop stores it per paired device, and the + relay chooses the matching APNs endpoint for every push. - The auth key file is sensitive — mount it via a secrets manager / Docker secret in production. Never commit `.p8` files. diff --git a/relay-server/k8s/configmap.yaml b/relay-server/k8s/configmap.yaml index 7d59ab4..f2884a4 100644 --- a/relay-server/k8s/configmap.yaml +++ b/relay-server/k8s/configmap.yaml @@ -10,6 +10,8 @@ data: RELAY_ADDR: ":8787" # APNs topic must equal the RxCode Mobile bundle identifier exactly. APNS_TOPIC: "com.idealapp.RxCode.Mobile" + # Fallback only for old desktop builds that omit per-device APNs environment. + # Current desktop clients send sandbox/production per paired device. APNS_PRODUCTION: "true" # Cluster-level Redis (namespace `redis`), reused as the pub/sub backplane # so the relay can run multiple replicas. Reachable from any namespace via diff --git a/relay-server/main.go b/relay-server/main.go index de6d684..e0856f7 100644 --- a/relay-server/main.go +++ b/relay-server/main.go @@ -79,7 +79,7 @@ func main() { if len(keyPEM) > 0 && *apnsKeyPath == "" { source = "env(APNS_KEY_B64)" } - log.Printf("APNs sender enabled (topic=%s production=%v key=%s)", *apnsTopic, *apnsProduction, source) + log.Printf("APNs sender enabled (topic=%s default_production=%v key=%s)", *apnsTopic, *apnsProduction, source) } else { log.Printf("APNs sender disabled (set APNS_KEY_B64 or -apns-key to enable)") } diff --git a/relay-server/push.go b/relay-server/push.go index 2e05746..402e517 100644 --- a/relay-server/push.go +++ b/relay-server/push.go @@ -8,6 +8,7 @@ import ( "log" "net/http" "os" + "strings" "github.com/sideshow/apns2" "github.com/sideshow/apns2/token" @@ -15,10 +16,19 @@ import ( // PushSender wraps APNs HTTP/2 client. type PushSender struct { - client *apns2.Client - topic string + developmentClient *apns2.Client + productionClient *apns2.Client + topic string + defaultEnvironment APNSEnvironment } +type APNSEnvironment string + +const ( + apnsEnvironmentSandbox APNSEnvironment = "sandbox" + apnsEnvironmentProduction APNSEnvironment = "production" +) + // NewPushSender loads the APNs auth key and prepares the HTTP/2 client. // // Exactly one of `keyPath` or `keyPEM` must be non-empty. `keyPEM` is the raw @@ -49,13 +59,16 @@ func NewPushSender(keyPath string, keyPEM []byte, keyID, teamID, topic string, p KeyID: keyID, TeamID: teamID, } - client := apns2.NewTokenClient(tok) + defaultEnvironment := apnsEnvironmentSandbox if production { - client = client.Production() - } else { - client = client.Development() + defaultEnvironment = apnsEnvironmentProduction } - return &PushSender{client: client, topic: topic}, nil + return &PushSender{ + developmentClient: apns2.NewTokenClient(tok).Development(), + productionClient: apns2.NewTokenClient(tok).Production(), + topic: topic, + defaultEnvironment: defaultEnvironment, + }, nil } // Push delivery modes accepted by POST /push. @@ -87,6 +100,12 @@ type PushRequest struct { EncryptedAlertB64 string `json:"encrypted_alert,omitempty"` Category string `json:"category,omitempty"` CollapseID string `json:"collapse_id,omitempty"` + // APNSEnvironment selects the APNs endpoint for this device token. Accepted + // values are "sandbox" and "production". Empty falls back to the relay's + // APNS_PRODUCTION default for compatibility with older desktop builds. + APNSEnvironment string `json:"apns_environment,omitempty"` + // Environment is accepted as a compatibility alias for APNSEnvironment. + Environment string `json:"environment,omitempty"` // PushType selects the delivery mode: "" / "alert", "liveactivity", or // "background". Unknown values are rejected. PushType string `json:"push_type,omitempty"` @@ -131,6 +150,11 @@ func pushHandler(sender *PushSender) http.HandlerFunc { if mode == "" { mode = pushModeAlert } + environment, err := sender.environmentForRequest(&req) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } var notif *apns2.Notification switch mode { @@ -151,40 +175,69 @@ func pushHandler(sender *PushSender) http.HandlerFunc { payloadBytes, _ := notif.Payload.([]byte) log.Printf( - "apns push send: mode=%s device=%s category=%q collapse_id=%q payload_bytes=%d", - mode, short(req.DeviceToken), req.Category, req.CollapseID, len(payloadBytes), + "apns push send: mode=%s environment=%s device=%s category=%q collapse_id=%q payload_bytes=%d", + mode, environment, short(req.DeviceToken), req.Category, req.CollapseID, len(payloadBytes), ) - res, err := sender.client.Push(notif) + res, err := sender.clientForEnvironment(environment).Push(notif) if err != nil { log.Printf( - "apns push transport error: %v mode=%s device=%s category=%q", - err, mode, short(req.DeviceToken), req.Category, + "apns push transport error: %v mode=%s environment=%s device=%s category=%q", + err, mode, environment, short(req.DeviceToken), req.Category, ) http.Error(w, "apns push failed", http.StatusBadGateway) return } if res.Sent() { log.Printf( - "apns push sent: mode=%s status=%d apns_id=%s device=%s", - mode, res.StatusCode, res.ApnsID, short(req.DeviceToken), + "apns push sent: mode=%s environment=%s status=%d apns_id=%s device=%s", + mode, environment, res.StatusCode, res.ApnsID, short(req.DeviceToken), ) } else { log.Printf( - "apns push rejected: mode=%s status=%d reason=%q apns_id=%s device=%s", - mode, res.StatusCode, res.Reason, res.ApnsID, short(req.DeviceToken), + "apns push rejected: mode=%s environment=%s status=%d reason=%q apns_id=%s device=%s", + mode, environment, res.StatusCode, res.Reason, res.ApnsID, short(req.DeviceToken), ) } resp := map[string]any{ - "status_code": res.StatusCode, - "reason": res.Reason, - "apns_id": res.ApnsID, + "status_code": res.StatusCode, + "reason": res.Reason, + "apns_id": res.ApnsID, + "apns_environment": string(environment), } w.Header().Set("Content-Type", "application/json") _ = json.NewEncoder(w).Encode(resp) } } +func (s *PushSender) environmentForRequest(req *PushRequest) (APNSEnvironment, error) { + raw := req.APNSEnvironment + if raw == "" { + raw = req.Environment + } + return parseAPNSEnvironment(raw, s.defaultEnvironment) +} + +func (s *PushSender) clientForEnvironment(environment APNSEnvironment) *apns2.Client { + if environment == apnsEnvironmentProduction { + return s.productionClient + } + return s.developmentClient +} + +func parseAPNSEnvironment(raw string, fallback APNSEnvironment) (APNSEnvironment, error) { + switch strings.ToLower(strings.TrimSpace(raw)) { + case "": + return fallback, nil + case "sandbox", "development", "dev": + return apnsEnvironmentSandbox, nil + case "production", "prod", "release": + return apnsEnvironmentProduction, nil + default: + return "", fmt.Errorf("unknown apns_environment") + } +} + // buildAlertNotification wraps the desktop's E2E-encrypted blob in the static // envelope the iOS Notification Service Extension expects. `mutable-content=1` // triggers the extension, which decrypts `enc` and rewrites the visible alert. diff --git a/relay-server/push_test.go b/relay-server/push_test.go new file mode 100644 index 0000000..29d453d --- /dev/null +++ b/relay-server/push_test.go @@ -0,0 +1,68 @@ +package main + +import "testing" + +func TestParseAPNSEnvironment(t *testing.T) { + tests := []struct { + name string + raw string + fallback APNSEnvironment + want APNSEnvironment + wantErr bool + }{ + { + name: "empty uses fallback", + raw: "", + fallback: apnsEnvironmentProduction, + want: apnsEnvironmentProduction, + }, + { + name: "sandbox", + raw: "sandbox", + fallback: apnsEnvironmentProduction, + want: apnsEnvironmentSandbox, + }, + { + name: "development alias", + raw: "development", + fallback: apnsEnvironmentProduction, + want: apnsEnvironmentSandbox, + }, + { + name: "production", + raw: "production", + fallback: apnsEnvironmentSandbox, + want: apnsEnvironmentProduction, + }, + { + name: "release alias", + raw: "release", + fallback: apnsEnvironmentSandbox, + want: apnsEnvironmentProduction, + }, + { + name: "unknown", + raw: "staging", + fallback: apnsEnvironmentSandbox, + wantErr: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := parseAPNSEnvironment(tt.raw, tt.fallback) + if tt.wantErr { + if err == nil { + t.Fatal("expected error") + } + return + } + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if got != tt.want { + t.Fatalf("parseAPNSEnvironment(%q) = %q, want %q", tt.raw, got, tt.want) + } + }) + } +}