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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 82 additions & 2 deletions .github/workflows/test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ jobs:

test-packages:
name: swift test (Packages)
runs-on: self-hosted
runs-on: [self-hosted, regular]

steps:
- name: Checkout code
Expand All @@ -49,7 +49,7 @@ jobs:

test-xcode:
name: xcodebuild test (RxCode)
runs-on: self-hosted
runs-on: [self-hosted, regular]

steps:
- name: Checkout code
Expand Down Expand Up @@ -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
30 changes: 30 additions & 0 deletions MobileUITestPlan-iPad.xctestplan
Original file line number Diff line number Diff line change
@@ -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
}
30 changes: 30 additions & 0 deletions MobileUITestPlan-iPhone.xctestplan
Original file line number Diff line number Diff line change
@@ -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
}
8 changes: 7 additions & 1 deletion Packages/Sources/RxCodeChatKit/ChatMessageListView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
26 changes: 21 additions & 5 deletions Packages/Sources/RxCodeChatKit/IMETextView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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 }
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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
4 changes: 0 additions & 4 deletions Packages/Sources/RxCodeChatKit/InputBarView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -315,7 +315,6 @@ struct InputBarView<Accessory: View, TopAccessory: View>: 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 },
Expand Down Expand Up @@ -772,9 +771,6 @@ struct InputBarView<Accessory: View, TopAccessory: View>: View {
sendMessage()
}

private func handleShiftReturnKey() {
windowState.inputText.append("\n")
}
}

// IMETextView's NSScrollView doesn't surface intrinsic height, so a hidden Text at the same
Expand Down
10 changes: 9 additions & 1 deletion Packages/Sources/RxCodeSync/Protocol/Payload.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
}

Expand Down
22 changes: 22 additions & 0 deletions Packages/Tests/RxCodeSyncTests/PayloadTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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")!
Expand Down
4 changes: 4 additions & 0 deletions RxCode.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -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 = "<group>"; };
DF5B0DDA2FC023BE000CE36F /* MobileUITestPlan-iPhone.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPhone.xctestplan"; sourceTree = "<group>"; };
DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPad.xctestplan"; sourceTree = "<group>"; };
DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = "<group>"; };
DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = "<group>"; };
DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = "<group>"; };
Expand Down Expand Up @@ -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 */,
Expand Down
3 changes: 3 additions & 0 deletions RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@
<TestPlanReference
reference = "container:UnitTestPlan.xctestplan">
</TestPlanReference>
<TestPlanReference
reference = "container:MobileUITestPlan.xctestplan">
</TestPlanReference>
Comment on lines +40 to +41
</TestPlans>
<Testables>
<TestableReference
Expand Down
33 changes: 9 additions & 24 deletions RxCode.xcodeproj/xcshareddata/xcschemes/RxCodeMobile.xcscheme
Original file line number Diff line number Diff line change
Expand Up @@ -28,30 +28,15 @@
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
shouldUseLaunchSchemeArgsEnv = "YES">
<Testables>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DF230B5E2FBC7368008929A6"
BuildableName = "RxCodeMobileTests.xctest"
BlueprintName = "RxCodeMobileTests"
ReferencedContainer = "container:RxCode.xcodeproj">
</BuildableReference>
</TestableReference>
<TestableReference
skipped = "NO"
parallelizable = "YES">
<BuildableReference
BuildableIdentifier = "primary"
BlueprintIdentifier = "DF230B682FBC7368008929A6"
BuildableName = "RxCodeMobileUITests.xctest"
BlueprintName = "RxCodeMobileUITests"
ReferencedContainer = "container:RxCode.xcodeproj">
</BuildableReference>
</TestableReference>
</Testables>
<TestPlans>
<TestPlanReference
reference = "container:MobileUITestPlan-iPhone.xctestplan"
default = "YES">
</TestPlanReference>
<TestPlanReference
reference = "container:MobileUITestPlan-iPad.xctestplan">
</TestPlanReference>
</TestPlans>
</TestAction>
<LaunchAction
buildConfiguration = "Debug"
Expand Down
2 changes: 1 addition & 1 deletion RxCode/Services/MobileSyncService+EventDispatch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -85,7 +85,7 @@ extension MobileSyncService {
if let idx = pairedDevices.firstIndex(where: { $0.pubkeyHex == inbound.fromHex }) {
logger.info("[APNs] token received mobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public) environment=\(t.environment, privacy: .public)")
pairedDevices[idx].apnsToken = t.tokenHex
pairedDevices[idx].apnsEnvironment = t.environment
pairedDevices[idx].apnsEnvironment = Self.normalizedAPNSEnvironment(t.environment)
pairedDevices[idx].lastSeen = .now
for staleIdx in pairedDevices.indices where staleIdx != idx && pairedDevices[staleIdx].apnsToken == t.tokenHex {
logger.warning("[APNs] clearing duplicate token from stale mobileKey=\(String(self.pairedDevices[staleIdx].pubkeyHex.prefix(12)), privacy: .public) currentMobileKey=\(String(inbound.fromHex.prefix(12)), privacy: .public) tokenPrefix=\(String(t.tokenHex.prefix(12)), privacy: .public)")
Expand Down
Loading
Loading