Skip to content

Commit d7aea02

Browse files
authored
fix: mobile list scrolling, relay device env label and add ui tests to mobile (#46)
1 parent 609747f commit d7aea02

40 files changed

Lines changed: 1733 additions & 196 deletions

.github/workflows/test.yaml

Lines changed: 82 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ jobs:
2424

2525
test-packages:
2626
name: swift test (Packages)
27-
runs-on: self-hosted
27+
runs-on: [self-hosted, regular]
2828

2929
steps:
3030
- name: Checkout code
@@ -49,7 +49,7 @@ jobs:
4949
5050
test-xcode:
5151
name: xcodebuild test (RxCode)
52-
runs-on: self-hosted
52+
runs-on: [self-hosted, regular]
5353

5454
steps:
5555
- name: Checkout code
@@ -136,3 +136,83 @@ jobs:
136136
CODE_SIGNING_ALLOWED=YES \
137137
AD_HOC_CODE_SIGNING_ALLOWED=YES \
138138
build | xcpretty
139+
140+
test-mobile:
141+
name: xcodebuild test (RxCodeMobile UI - ${{ matrix.label }})
142+
runs-on: [self-hosted, ui-tests]
143+
144+
strategy:
145+
fail-fast: false
146+
matrix:
147+
include:
148+
- label: iPhone
149+
device-prefix: iPhone
150+
test-plan: MobileUITestPlan-iPhone
151+
- label: iPad
152+
device-prefix: iPad
153+
test-plan: MobileUITestPlan-iPad
154+
155+
steps:
156+
- name: Checkout code
157+
uses: actions/checkout@v6
158+
159+
- name: Setup Xcode
160+
uses: maxim-lobanov/setup-xcode@v1
161+
with:
162+
xcode-version: latest-stable
163+
164+
- name: Install xcpretty
165+
run: gem install xcpretty
166+
167+
- name: Resolve simulator
168+
id: sim
169+
run: |
170+
# Pick the first available simulator matching the device family, so
171+
# the job survives Xcode bumping its bundled simulator models.
172+
UDID=$(xcrun simctl list devices available --json | python3 -c "
173+
import json, sys
174+
devices = json.load(sys.stdin)['devices']
175+
prefix = '${{ matrix.device-prefix }}'
176+
for runtime in devices:
177+
for d in devices[runtime]:
178+
if d['name'].startswith(prefix):
179+
print(d['udid']); sys.exit(0)
180+
")
181+
if [ -z "$UDID" ]; then
182+
echo "No '${{ matrix.device-prefix }}' simulator found. Available devices:"
183+
xcrun simctl list devices available
184+
exit 1
185+
fi
186+
echo "Using ${{ matrix.device-prefix }} simulator $UDID"
187+
echo "udid=$UDID" >> "$GITHUB_OUTPUT"
188+
189+
- name: Prepare simulator
190+
run: |
191+
# Erase first: the app persists pairing in UserDefaults/Keychain, and
192+
# a clean device guarantees the UI-test mock pairing path is taken.
193+
xcrun simctl shutdown "${{ steps.sim.outputs.udid }}" || true
194+
xcrun simctl erase "${{ steps.sim.outputs.udid }}"
195+
xcrun simctl boot "${{ steps.sim.outputs.udid }}"
196+
xcrun simctl bootstatus "${{ steps.sim.outputs.udid }}"
197+
198+
- name: Run UI tests
199+
run: |
200+
set -o pipefail && xcodebuild \
201+
-project RxCode.xcodeproj \
202+
-scheme RxCodeMobile \
203+
-configuration Debug \
204+
-testPlan ${{ matrix.test-plan }} \
205+
-destination "platform=iOS Simulator,id=${{ steps.sim.outputs.udid }}" \
206+
-resultBundlePath "TestResults-${{ matrix.label }}.xcresult" \
207+
-enableCodeCoverage NO \
208+
CODE_SIGNING_ALLOWED=NO \
209+
CODE_SIGNING_REQUIRED=NO \
210+
test | xcpretty
211+
212+
- name: Upload test results
213+
if: always()
214+
uses: actions/upload-artifact@v4
215+
with:
216+
name: mobile-ui-test-results-${{ matrix.label }}
217+
path: TestResults-${{ matrix.label }}.xcresult
218+
retention-days: 14

MobileUITestPlan-iPad.xctestplan

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"configurations" : [
3+
{
4+
"id" : "B1A2C3D4-0002-0002-0002-000000000002",
5+
"name" : "iPad UI",
6+
"options" : {
7+
8+
}
9+
}
10+
],
11+
"defaultOptions" : {
12+
"codeCoverage" : false,
13+
"testTimeoutsEnabled" : true
14+
},
15+
"testTargets" : [
16+
{
17+
"selectedTests" : [
18+
"RxCodeMobileUITests\/testLaunchesPastPairingIntoMainUI()",
19+
"iPadNavigationUITests\/testBriefingSplitKeepsListVisible()",
20+
"iPadNavigationUITests\/testProjectsSplitKeepsThreadListVisible()"
21+
],
22+
"target" : {
23+
"containerPath" : "container:RxCode.xcodeproj",
24+
"identifier" : "DF230B682FBC7368008929A6",
25+
"name" : "RxCodeMobileUITests"
26+
}
27+
}
28+
],
29+
"version" : 1
30+
}

MobileUITestPlan-iPhone.xctestplan

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
{
2+
"configurations" : [
3+
{
4+
"id" : "B1A2C3D4-0001-0001-0001-000000000001",
5+
"name" : "iPhone UI",
6+
"options" : {
7+
8+
}
9+
}
10+
],
11+
"defaultOptions" : {
12+
"codeCoverage" : false,
13+
"testTimeoutsEnabled" : true
14+
},
15+
"testTargets" : [
16+
{
17+
"selectedTests" : [
18+
"RxCodeMobileUITests\/testLaunchesPastPairingIntoMainUI()",
19+
"iPhoneNavigationUITests\/testBriefingFlowReturnsToDetailAfterBack()",
20+
"iPhoneNavigationUITests\/testProjectsFlowReturnsToThreadListAfterBack()"
21+
],
22+
"target" : {
23+
"containerPath" : "container:RxCode.xcodeproj",
24+
"identifier" : "DF230B682FBC7368008929A6",
25+
"name" : "RxCodeMobileUITests"
26+
}
27+
}
28+
],
29+
"version" : 1
30+
}

Packages/Sources/RxCodeChatKit/ChatMessageListView.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,13 @@ public struct ChatMessageListView: View {
3333
ForEach(chatMessageGroups(messages, minGroupSize: transientGroupMinSize)) { group in
3434
if group.isTransientGroup {
3535
ChatTransientGroupSummaryView(messages: group.messages)
36-
.id(group.id)
36+
.background(alignment: .top) {
37+
ForEach(group.messages.map(\.id), id: \.self) { messageID in
38+
Color.clear
39+
.frame(height: 1)
40+
.id(messageID)
41+
}
42+
}
3743
.transition(messageFadeTransition(role: .assistant))
3844
.chatMessageListRowStyle()
3945
} else if let message = group.messages.first {

Packages/Sources/RxCodeChatKit/IMETextView.swift

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,6 @@ struct IMETextView: NSViewRepresentable {
1717
var textColor: NSColor
1818
var placeholder: String = ""
1919
var onReturn: () -> Void
20-
var onShiftReturn: () -> Void
2120
var onUpArrow: () -> Bool
2221
var onDownArrow: () -> Bool
2322
var onTab: () -> Bool
@@ -104,7 +103,6 @@ struct IMETextView: NSViewRepresentable {
104103

105104
private func applyCallbacks(to textView: _IMETextView) {
106105
textView.onReturn = onReturn
107-
textView.onShiftReturn = onShiftReturn
108106
textView.onUpArrow = onUpArrow
109107
textView.onDownArrow = onDownArrow
110108
textView.onTab = onTab
@@ -217,7 +215,6 @@ fileprivate final class ChipLayoutManager: NSLayoutManager, @unchecked Sendable
217215

218216
fileprivate final class _IMETextView: NSTextView {
219217
var onReturn: () -> Void = {}
220-
var onShiftReturn: () -> Void = {}
221218
var onUpArrow: () -> Bool = { false }
222219
var onDownArrow: () -> Bool = { false }
223220
var onTab: () -> Bool = { false }
@@ -274,10 +271,11 @@ fileprivate final class _IMETextView: NSTextView {
274271

275272
override func keyDown(with event: NSEvent) {
276273
// Shift+Enter: NSTextView doesn't bind this to a doCommand by default. Force-commit any
277-
// composing IME text, then fire the newline callback (which appends "\n" via the binding).
274+
// composing IME text, then insert the newline through NSTextView so AppKit updates the
275+
// insertion point and scroll position together.
278276
if event.keyCode == 36, event.modifierFlags.contains(.shift) {
279277
commitMarkedTextIfNeeded()
280-
onShiftReturn()
278+
insertShiftReturnNewline()
281279
return
282280
}
283281
// Shift+Tab: NSTextView routes this to insertBacktab: which we intercept here so it
@@ -407,5 +405,23 @@ fileprivate final class _IMETextView: NSTextView {
407405
let composing = (storage.string as NSString).substring(with: range)
408406
insertText(composing, replacementRange: range)
409407
}
408+
409+
private func insertShiftReturnNewline() {
410+
insertText("\n", replacementRange: selectedRange())
411+
revealInsertionPoint()
412+
}
413+
414+
private func revealInsertionPoint() {
415+
let textLength = (string as NSString).length
416+
let location = min(max(selectedRange().location, 0), textLength)
417+
let caretRange = NSRange(location: location, length: 0)
418+
scrollRangeToVisible(caretRange)
419+
DispatchQueue.main.async { [weak self] in
420+
guard let self else { return }
421+
let textLength = (self.string as NSString).length
422+
let location = min(max(self.selectedRange().location, 0), textLength)
423+
self.scrollRangeToVisible(NSRange(location: location, length: 0))
424+
}
425+
}
410426
}
411427
#endif

Packages/Sources/RxCodeChatKit/InputBarView.swift

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -315,7 +315,6 @@ struct InputBarView<Accessory: View, TopAccessory: View>: View {
315315
textColor: NSColor(ClaudeTheme.textPrimary),
316316
placeholder: String(localized: "Type a message...", bundle: .module),
317317
onReturn: handleReturnKey,
318-
onShiftReturn: handleShiftReturnKey,
319318
onUpArrow: { handleUpArrow() == .handled },
320319
onDownArrow: { handleDownArrow() == .handled },
321320
onTab: { handleTab() == .handled },
@@ -772,9 +771,6 @@ struct InputBarView<Accessory: View, TopAccessory: View>: View {
772771
sendMessage()
773772
}
774773

775-
private func handleShiftReturnKey() {
776-
windowState.inputText.append("\n")
777-
}
778774
}
779775

780776
// IMETextView's NSScrollView doesn't surface intrinsic height, so a hidden Text at the same

Packages/Sources/RxCodeSync/Protocol/Payload.swift

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -137,11 +137,19 @@ public struct PairRequestPayload: Codable, Sendable {
137137
public let displayName: String
138138
public let platform: String
139139
public let appVersion: String
140-
public init(mobilePubkeyHex: String, displayName: String, platform: String, appVersion: String) {
140+
public let apnsEnvironment: String?
141+
public init(
142+
mobilePubkeyHex: String,
143+
displayName: String,
144+
platform: String,
145+
appVersion: String,
146+
apnsEnvironment: String? = nil
147+
) {
141148
self.mobilePubkeyHex = mobilePubkeyHex
142149
self.displayName = displayName
143150
self.platform = platform
144151
self.appVersion = appVersion
152+
self.apnsEnvironment = apnsEnvironment
145153
}
146154
}
147155

Packages/Tests/RxCodeSyncTests/PayloadTests.swift

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,28 @@ import RxCodeCore
55

66
@Suite("Mobile sync payloads")
77
struct PayloadTests {
8+
@Test("pair request carries APNs environment")
9+
func pairRequestCarriesAPNsEnvironment() throws {
10+
let payload = Payload.pairRequest(
11+
PairRequestPayload(
12+
mobilePubkeyHex: String(repeating: "a", count: 64),
13+
displayName: "iPhone",
14+
platform: "iOS",
15+
appVersion: "1.2.3",
16+
apnsEnvironment: "sandbox"
17+
)
18+
)
19+
20+
let data = try JSONEncoder().encode(payload)
21+
let decoded = try JSONDecoder().decode(Payload.self, from: data)
22+
guard case .pairRequest(let request) = decoded else {
23+
Issue.record("Expected pair request payload")
24+
return
25+
}
26+
27+
#expect(request.apnsEnvironment == "sandbox")
28+
}
29+
830
@Test("snapshot carries briefing and settings data")
931
func snapshotCarriesBriefingAndSettingsData() throws {
1032
let projectId = UUID(uuidString: "11111111-2222-3333-4444-555555555555")!

RxCode.xcodeproj/project.pbxproj

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,8 @@
117117
DF230B692FBC7368008929A6 /* RxCodeMobileUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeMobileUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
118118
DF230BAB2FBC9001008929A6 /* RxCodeMobileNotificationService.appex */ = {isa = PBXFileReference; explicitFileType = "wrapper.app-extension"; includeInIndex = 0; path = RxCodeMobileNotificationService.appex; sourceTree = BUILT_PRODUCTS_DIR; };
119119
DF23F7352FB8C3EC008929A6 /* icon.icon */ = {isa = PBXFileReference; lastKnownFileType = folder.iconcomposer.icon; path = icon.icon; sourceTree = "<group>"; };
120+
DF5B0DDA2FC023BE000CE36F /* MobileUITestPlan-iPhone.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPhone.xctestplan"; sourceTree = "<group>"; };
121+
DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "MobileUITestPlan-iPad.xctestplan"; sourceTree = "<group>"; };
120122
DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanDecisionTests.swift; sourceTree = "<group>"; };
121123
DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = PlanCardViewTests.swift; sourceTree = "<group>"; };
122124
DFA0CCD52FB4CC02005991E1 /* HistoryListArchiveFilterTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = HistoryListArchiveFilterTests.swift; sourceTree = "<group>"; };
@@ -321,6 +323,8 @@
321323
isa = PBXGroup;
322324
children = (
323325
DF23F7352FB8C3EC008929A6 /* icon.icon */,
326+
DF5B0DDC2FC023C8000CE36F /* MobileUITestPlan-iPad.xctestplan */,
327+
DF5B0DDA2FC023BE000CE36F /* MobileUITestPlan-iPhone.xctestplan */,
324328
DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */,
325329
6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */,
326330
E673353A2F7356F600FD26C7 /* RxCode */,

RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,9 @@
3636
<TestPlanReference
3737
reference = "container:UnitTestPlan.xctestplan">
3838
</TestPlanReference>
39+
<TestPlanReference
40+
reference = "container:MobileUITestPlan.xctestplan">
41+
</TestPlanReference>
3942
</TestPlans>
4043
<Testables>
4144
<TestableReference

0 commit comments

Comments
 (0)