From 5c9d11a816df7636bd9d1d22c3b561986428aabc Mon Sep 17 00:00:00 2001
From: sirily11 <32106111+sirily11@users.noreply.github.com>
Date: Sat, 16 May 2026 14:53:46 +0800
Subject: [PATCH 1/3] fix: app signing
---
RxCode.xcodeproj/project.pbxproj | 12 ++---
RxCode/Info.plist | 2 +-
RxCode/Services/ACPService.swift | 34 +++++++++++++-
RxCode/Services/MCPService.swift | 79 ++++++++++++++++++++++----------
scripts/ci/generate-appcast.sh | 48 +++++++++++++++++--
5 files changed, 138 insertions(+), 37 deletions(-)
diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj
index 4a182baa..a10f24ac 100644
--- a/RxCode.xcodeproj/project.pbxproj
+++ b/RxCode.xcodeproj/project.pbxproj
@@ -269,7 +269,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
DEVELOPMENT_TEAM = P9KK452K8P;
GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
PRODUCT_NAME = RxCodeTests;
SDKROOT = macosx;
SWIFT_VERSION = 6.0;
@@ -283,7 +283,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
DEVELOPMENT_TEAM = P9KK452K8P;
GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
PRODUCT_NAME = RxCodeTests;
SDKROOT = macosx;
SWIFT_VERSION = 6.0;
@@ -345,7 +345,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE;
MTL_FAST_MATH = YES;
ONLY_ACTIVE_ARCH = YES;
@@ -403,7 +403,7 @@
GCC_WARN_UNUSED_FUNCTION = YES;
GCC_WARN_UNUSED_VARIABLE = YES;
LOCALIZATION_PREFERS_STRING_CATALOGS = YES;
- MACOSX_DEPLOYMENT_TARGET = 26.2;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
MTL_ENABLE_DEBUG_INFO = NO;
MTL_FAST_MATH = YES;
SDKROOT = macosx;
@@ -431,7 +431,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCode;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -465,7 +465,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 26.0;
+ MACOSX_DEPLOYMENT_TARGET = 15.0;
MARKETING_VERSION = 1.2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCode;
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/RxCode/Info.plist b/RxCode/Info.plist
index 8e6a374f..0ae0acce 100644
--- a/RxCode/Info.plist
+++ b/RxCode/Info.plist
@@ -29,6 +29,6 @@
SUFeedURL
https://update.code.rxlab.app/appcast.xml
SUPublicEDKey
- o1+OjC4eJrHnvmfwRxGPhH8OQFD21WYlxkFndh+4J0M=
+ OOJs50Q9V9lpqBgeRi0ow0YBdiAlmc9uJ3B9hABDsqQ=
diff --git a/RxCode/Services/ACPService.swift b/RxCode/Services/ACPService.swift
index ad6a9224..23b6be92 100644
--- a/RxCode/Services/ACPService.swift
+++ b/RxCode/Services/ACPService.swift
@@ -457,6 +457,7 @@ actor ACPService {
)
heartbeat.cancel()
logger.info("[ACP] initialize ok for \(spec.displayName, privacy: .public): \(initResult.shortDescription, privacy: .public)")
+ let filteredMCPServers = supportedMCPServers(mcpServers, initResult: initResult)
continuation.yield(.system(SystemEvent(
subtype: "init",
@@ -472,7 +473,7 @@ actor ACPService {
// persisted conversation as updates and would duplicate messages.
let newParams: [String: JSONValue] = [
"cwd": .string(cwd),
- "mcpServers": .array(mcpServers)
+ "mcpServers": .array(filteredMCPServers)
]
let newResult = try await sendRequest(key: bootstrapKey, method: "session/new", params: newParams)
guard let agentSessionId = newResult.objectValue?["sessionId"]?.stringValue else {
@@ -1238,6 +1239,37 @@ actor ACPService {
initResult.objectValue?["agentCapabilities"]?.objectValue?["loadSession"]?.boolValue ?? false
}
+ private func agentSupportsMCPTransport(_ transport: String, initResult: JSONValue) -> Bool {
+ guard transport == "http" || transport == "sse" else { return true }
+ return initResult
+ .objectValue?["agentCapabilities"]?
+ .objectValue?["mcpCapabilities"]?
+ .objectValue?[transport]?
+ .boolValue ?? false
+ }
+
+ private func supportedMCPServers(_ servers: [JSONValue], initResult: JSONValue) -> [JSONValue] {
+ var supported: [JSONValue] = []
+ var dropped: [String] = []
+
+ for server in servers {
+ let obj = server.objectValue
+ let transport = obj?["type"]?.stringValue ?? "stdio"
+ if agentSupportsMCPTransport(transport, initResult: initResult) {
+ supported.append(server)
+ } else {
+ let name = obj?["name"]?.stringValue ?? ""
+ dropped.append("\(name):\(transport)")
+ }
+ }
+
+ if !dropped.isEmpty {
+ logger.info("[ACP-MCP] dropping unsupported MCP transports: \(dropped.joined(separator: ", "), privacy: .public)")
+ }
+ logger.info("[ACP-MCP] passing \(supported.count, privacy: .public)/\(servers.count, privacy: .public) mcpServers after initialize capability check")
+ return supported
+ }
+
/// Scans a `session/new` (or `session/load`) response for the first
/// `SessionConfigOption` with `category: "model"` and `type: "select"`,
/// flattening grouped options.
diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift
index f3d7315b..900008ae 100644
--- a/RxCode/Services/MCPService.swift
+++ b/RxCode/Services/MCPService.swift
@@ -145,18 +145,15 @@ actor MCPService {
}
/// Build the `mcpServers` array passed to ACP's `session/new` RPC.
- /// ACP v1 only specifies stdio transport; http/sse servers are silently
- /// dropped. Disabled servers are filtered out. If `bridgeCommand` is
- /// non-nil an additional entry `rxcode-ide` is prepended — that's the
- /// local MCP polyfill server.
+ /// Disabled servers are filtered out. If `bridgeCommand` is non-nil an
+ /// additional entry `rxcode-ide` is prepended — that's the local MCP
+ /// polyfill server. HTTP/SSE entries are emitted using ACP's discriminated
+ /// union shape and are later filtered against the agent's advertised MCP
+ /// capabilities after `initialize`.
func acpMCPServers(
projectPath: String?,
bridgeCommand: (command: String, args: [String])? = nil
) async -> [JSONValue] {
- // IMPORTANT: do NOT add a `"type": "stdio"` field. The ACP spec's
- // anyOf union uses presence of `type` as the stdio-vs-http/sse
- // discriminator, and known agent implementations (OpenCode) bail
- // into the HTTP parsing path if `type` is present at all.
var entries: [JSONValue] = []
if let bridge = bridgeCommand {
entries.append(.object([
@@ -169,23 +166,9 @@ actor MCPService {
do {
let records = try enabledRecords(projectPath: projectPath)
let userEntries: [JSONValue] = records
- .filter { $0.transport == .stdio }
.sorted { $0.name < $1.name }
- .map { record -> JSONValue in
- let envPairs: [JSONValue] = record.env
- .sorted { $0.key < $1.key }
- .map { key, value in
- .object([
- "name": .string(key),
- "value": .string(value),
- ])
- }
- return .object([
- "name": .string(record.name),
- "command": .string(record.command ?? ""),
- "args": .array(record.args.map { .string($0) }),
- "env": .array(envPairs),
- ])
+ .compactMap { record -> JSONValue? in
+ acpMCPServerEntry(for: record)
}
entries.append(contentsOf: userEntries)
} catch {
@@ -196,6 +179,54 @@ actor MCPService {
return entries
}
+ private func acpMCPServerEntry(for record: MCPServerRecord) -> JSONValue? {
+ let headers: [JSONValue] = record.headers
+ .sorted { $0.key < $1.key }
+ .map { key, value in
+ .object([
+ "name": .string(key),
+ "value": .string(value),
+ ])
+ }
+
+ switch record.transport {
+ case .stdio:
+ let envPairs: [JSONValue] = record.env
+ .sorted { $0.key < $1.key }
+ .map { key, value in
+ .object([
+ "name": .string(key),
+ "value": .string(value),
+ ])
+ }
+ // IMPORTANT: do NOT add a `"type": "stdio"` field. Stdio is the
+ // default ACP union member; `type` is only the discriminator for
+ // HTTP and SSE entries.
+ return .object([
+ "name": .string(record.name),
+ "command": .string(record.command ?? ""),
+ "args": .array(record.args.map { .string($0) }),
+ "env": .array(envPairs),
+ ])
+ case .http:
+ guard let url = record.url, !url.isEmpty else { return nil }
+ return .object([
+ "name": .string(record.name),
+ "type": .string("http"),
+ "url": .string(url),
+ "headers": .array(headers),
+ ])
+ case .sse:
+ guard let url = record.url, !url.isEmpty else { return nil }
+ return .object([
+ "name": .string(record.name),
+ "type": .string("sse"),
+ "url": .string(url),
+ "headers": .array(headers),
+ ])
+ }
+ }
+
func codexConfigOverrides(projectPath: String?) async -> [String] {
do {
let config = try loadConfig()
diff --git a/scripts/ci/generate-appcast.sh b/scripts/ci/generate-appcast.sh
index 1e19cb5a..9d0473c4 100755
--- a/scripts/ci/generate-appcast.sh
+++ b/scripts/ci/generate-appcast.sh
@@ -1,5 +1,13 @@
#!/bin/bash
-set -e
+set -euo pipefail
+
+: "${SPARKLE_KEY:?SPARKLE_KEY is required}"
+: "${VERSION:?VERSION is required}"
+: "${BUILD_NUMBER:?BUILD_NUMBER is required}"
+
+RELEASES_URL="https://github.com/rxtech-lab/rxcode/releases"
+DOWNLOAD_URL_PREFIX="${RELEASES_URL}/download/${VERSION}/"
+EXPECTED_MINIMUM_SYSTEM_VERSION="${EXPECTED_MINIMUM_SYSTEM_VERSION:-15.0}"
# Write SPARKLE_KEY to sparkle.key
echo "$SPARKLE_KEY" > sparkle.key
@@ -7,7 +15,7 @@ echo "$SPARKLE_KEY" > sparkle.key
echo "Generating appcast for version: $VERSION"
# Create a temporary release notes file if release notes exist
-if [ -n "$RELEASE_NOTE" ]; then
+if [ -n "${RELEASE_NOTE:-}" ]; then
echo "$RELEASE_NOTE" > release_notes.md
# Convert markdown to HTML
python3 scripts/ci/convert-markdown.py release_notes.md release_notes.html
@@ -18,9 +26,39 @@ fi
# Generate appcast.xml with optional release notes
./bin/generate_appcast ./ \
--ed-key-file sparkle.key \
- --link https://github.com/rxtech-lab/Clarc/releases \
- --download-url-prefix https://github.com/rxtech-lab/Clarc/releases/download/${VERSION}/
+ --link "$RELEASES_URL" \
+ --download-url-prefix "$DOWNLOAD_URL_PREFIX"
+
+if grep -Fq "rxtech-lab/Clarc" appcast.xml; then
+ echo "Error: generated appcast still points at the old Clarc repository"
+ exit 1
+fi
+
+if ! grep -Fq "$DOWNLOAD_URL_PREFIX" appcast.xml; then
+ echo "Error: generated appcast does not contain the expected RxCode download URL prefix"
+ exit 1
+fi
+
+if ! grep -Fq "sparkle:edSignature=" appcast.xml; then
+ echo "Error: generated appcast is missing sparkle:edSignature"
+ exit 1
+fi
+
+if ! grep -Fq "${EXPECTED_MINIMUM_SYSTEM_VERSION}" appcast.xml; then
+ echo "Error: generated appcast does not advertise macOS ${EXPECTED_MINIMUM_SYSTEM_VERSION} as the minimum system version"
+ exit 1
+fi
if [ -f "release_notes.html" ]; then
- python3 scripts/ci/update-xml.py appcast.xml release_notes.html ${BUILD_NUMBER}
+ python3 scripts/ci/update-xml.py appcast.xml release_notes.html "$BUILD_NUMBER"
+fi
+
+if ! grep -Fq "sparkle:edSignature=" appcast.xml; then
+ echo "Error: appcast post-processing removed sparkle:edSignature"
+ exit 1
+fi
+
+if ! grep -Fq "${EXPECTED_MINIMUM_SYSTEM_VERSION}" appcast.xml; then
+ echo "Error: appcast post-processing changed the minimum system version"
+ exit 1
fi
From ef9ee1e3bfdca08cc85127e2a441c19265b43942 Mon Sep 17 00:00:00 2001
From: sirily11 <32106111+sirily11@users.noreply.github.com>
Date: Sat, 16 May 2026 15:08:11 +0800
Subject: [PATCH 2/3] fix: mcp for acp client
---
RxCode.xcodeproj/project.pbxproj | 4 ++--
RxCode/Services/ACPService.swift | 16 ++++++++++++++--
RxCode/Services/MCPService.swift | 6 ++++++
3 files changed, 22 insertions(+), 4 deletions(-)
diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj
index a10f24ac..5faef6c8 100644
--- a/RxCode.xcodeproj/project.pbxproj
+++ b/RxCode.xcodeproj/project.pbxproj
@@ -431,7 +431,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 15.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCode;
PRODUCT_NAME = "$(TARGET_NAME)";
@@ -465,7 +465,7 @@
"$(inherited)",
"@executable_path/../Frameworks",
);
- MACOSX_DEPLOYMENT_TARGET = 15.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
MARKETING_VERSION = 1.2.5;
PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCode;
PRODUCT_NAME = "$(TARGET_NAME)";
diff --git a/RxCode/Services/ACPService.swift b/RxCode/Services/ACPService.swift
index 23b6be92..a81cd2a0 100644
--- a/RxCode/Services/ACPService.swift
+++ b/RxCode/Services/ACPService.swift
@@ -457,7 +457,12 @@ actor ACPService {
)
heartbeat.cancel()
logger.info("[ACP] initialize ok for \(spec.displayName, privacy: .public): \(initResult.shortDescription, privacy: .public)")
+ logger.info("[ACP-MCP] runFreshTurn received \(mcpServers.count, privacy: .public) candidate mcpServers from caller")
let filteredMCPServers = supportedMCPServers(mcpServers, initResult: initResult)
+ if let data = try? JSONEncoder().encode(JSONValue.array(filteredMCPServers)),
+ let json = String(data: data, encoding: .utf8) {
+ logger.info("[ACP-MCP] session/new mcpServers payload=\(json, privacy: .public)")
+ }
continuation.yield(.system(SystemEvent(
subtype: "init",
@@ -547,6 +552,7 @@ actor ACPService {
let modelConfigId = sessions[poolKey]?.modelConfigId
let spec = sessions[poolKey]!.spec
logger.info("[ACP] reusing pooled session key=\(poolKey, privacy: .public) agentSid=\(agentSessionId, privacy: .public) client=\(spec.displayName, privacy: .public)")
+ logger.info("[ACP-MCP] reused turn — NOT re-sending mcpServers; agent already has whatever was registered at initial session/new")
// Reset per-turn state and adopt the new streamId/continuation.
mutateSession(poolKey) { e in
@@ -1048,7 +1054,8 @@ actor ACPService {
let kind = update["kind"]?.stringValue
let rawInput = update["rawInput"]?.objectValue ?? [:]
let normalized = Self.normalizeToolCall(kind: kind, title: title, update: update, rawInput: rawInput)
- logger.info("[ACP] ⟵ tool_call id=\(toolCallId, privacy: .public) name=\(normalized.name, privacy: .public) kind=\(kind ?? "", privacy: .public) inputKeys=[\(normalized.input.keys.sorted().joined(separator: ","), privacy: .public)]")
+ let mcpTag = (normalized.name.contains("context7") || normalized.name.contains("mcp_") || title.contains("MCP")) ? " [MCP]" : ""
+ logger.info("[ACP] ⟵ tool_call\(mcpTag, privacy: .public) id=\(toolCallId, privacy: .public) name=\(normalized.name, privacy: .public) title=\(title, privacy: .public) kind=\(kind ?? "", privacy: .public) inputKeys=[\(normalized.input.keys.sorted().joined(separator: ","), privacy: .public)]")
mutateSession(key) { $0.liveToolCalls.insert(toolCallId) }
@@ -1249,16 +1256,21 @@ actor ACPService {
}
private func supportedMCPServers(_ servers: [JSONValue], initResult: JSONValue) -> [JSONValue] {
+ let mcpCaps = initResult.objectValue?["agentCapabilities"]?.objectValue?["mcpCapabilities"]
+ logger.info("[ACP-MCP] agent advertised mcpCapabilities=\(mcpCaps?.shortDescription ?? "", privacy: .public) (http=\(mcpCaps?.objectValue?["http"]?.boolValue == true) sse=\(mcpCaps?.objectValue?["sse"]?.boolValue == true))")
+
var supported: [JSONValue] = []
var dropped: [String] = []
for server in servers {
let obj = server.objectValue
let transport = obj?["type"]?.stringValue ?? "stdio"
+ let name = obj?["name"]?.stringValue ?? ""
if agentSupportsMCPTransport(transport, initResult: initResult) {
+ logger.info("[ACP-MCP] passing entry name=\(name, privacy: .public) transport=\(transport, privacy: .public)")
supported.append(server)
} else {
- let name = obj?["name"]?.stringValue ?? ""
+ logger.info("[ACP-MCP] dropping entry name=\(name, privacy: .public) transport=\(transport, privacy: .public) — agent doesn't advertise capability")
dropped.append("\(name):\(transport)")
}
}
diff --git a/RxCode/Services/MCPService.swift b/RxCode/Services/MCPService.swift
index 900008ae..9f2e9eb0 100644
--- a/RxCode/Services/MCPService.swift
+++ b/RxCode/Services/MCPService.swift
@@ -176,6 +176,12 @@ actor MCPService {
}
let names = entries.compactMap { $0["name"]?.stringValue }.joined(separator: ", ")
logger.info("[ACP-MCP] built \(entries.count, privacy: .public) mcpServers entries for project=\(projectPath ?? "", privacy: .public) [\(names, privacy: .public)]")
+ // Dump the full JSON payload so a comparison against a known-good
+ // Python repro is one log line away.
+ if let data = try? JSONEncoder().encode(JSONValue.array(entries)),
+ let json = String(data: data, encoding: .utf8) {
+ logger.info("[ACP-MCP] payload=\(json, privacy: .public)")
+ }
return entries
}
From 67af63a3a44e0e32788602abbf61a914cc55db0b Mon Sep 17 00:00:00 2001
From: sirily11 <32106111+sirily11@users.noreply.github.com>
Date: Sat, 16 May 2026 15:26:53 +0800
Subject: [PATCH 3/3] feat: add ui test script
---
.github/workflows/test.yaml | 1 +
.../Sources/RxCodeChatKit/IMETextView.swift | 1 +
.../Sources/RxCodeChatKit/InputBarView.swift | 3 +
.../Sources/RxCodeChatKit/PlanCardView.swift | 1 +
.../Sources/RxCodeChatKit/PlanSheetView.swift | 10 +
.../RxCodeCore/Utilities/AppSupport.swift | 5 +
RxCode.xcodeproj/project.pbxproj | 116 ++++++-
.../xcshareddata/swiftpm/Package.resolved | 2 +-
.../xcshareddata/xcschemes/RxCode.xcscheme | 22 +-
RxCode/Services/ThreadStore.swift | 4 +-
RxCode/Views/MainView.swift | 5 +
.../RunProfile/RunProfileToolbarGroup.swift | 4 +
RxCode/Views/Toolbar/RxCodeToolbar.swift | 2 +
.../Toolbar/TodoProgressToolbarItem.swift | 2 +
.../LocalAIProviderAcceptanceTests.swift | 298 ++++++++++++++++++
UITestplan.xctestplan | 40 +++
UnitTestPlan.xctestplan | 24 ++
scripts/local-ai-ui-tests.sh | 30 ++
scripts/mcp_echo_server.py | 68 ++++
19 files changed, 630 insertions(+), 8 deletions(-)
create mode 100644 RxCodeUITests/LocalAIProviderAcceptanceTests.swift
create mode 100644 UITestplan.xctestplan
create mode 100644 UnitTestPlan.xctestplan
create mode 100755 scripts/local-ai-ui-tests.sh
create mode 100755 scripts/mcp_echo_server.py
diff --git a/.github/workflows/test.yaml b/.github/workflows/test.yaml
index 17a93366..c666e0fc 100644
--- a/.github/workflows/test.yaml
+++ b/.github/workflows/test.yaml
@@ -56,6 +56,7 @@ jobs:
-scheme RxCode \
-configuration Debug \
-destination platform=macOS \
+ -testPlan UnitTestPlan \
CODE_SIGNING_ALLOWED=NO \
test | xcpretty
diff --git a/Packages/Sources/RxCodeChatKit/IMETextView.swift b/Packages/Sources/RxCodeChatKit/IMETextView.swift
index bb360027..5be2a008 100644
--- a/Packages/Sources/RxCodeChatKit/IMETextView.swift
+++ b/Packages/Sources/RxCodeChatKit/IMETextView.swift
@@ -52,6 +52,7 @@ struct IMETextView: NSViewRepresentable {
textView.isAutomaticTextReplacementEnabled = false
textView.isAutomaticSpellingCorrectionEnabled = false
textView.smartInsertDeleteEnabled = false
+ textView.setAccessibilityIdentifier("chat-input-text-view")
textView.textContainerInset = .zero
textView.isHorizontallyResizable = false
textView.isVerticallyResizable = true
diff --git a/Packages/Sources/RxCodeChatKit/InputBarView.swift b/Packages/Sources/RxCodeChatKit/InputBarView.swift
index 636a6aac..161f4d80 100644
--- a/Packages/Sources/RxCodeChatKit/InputBarView.swift
+++ b/Packages/Sources/RxCodeChatKit/InputBarView.swift
@@ -253,6 +253,7 @@ struct InputBarView: View {
.menuIndicator(.hidden)
.fixedSize()
.help(windowState.sessionPlanMode ? "Plan mode is on — Add menu" : "Add — attach file or toggle plan mode")
+ .accessibilityIdentifier("composer-add-menu")
.fileImporter(
isPresented: $showFilePicker,
allowedContentTypes: [.item],
@@ -289,6 +290,7 @@ struct InputBarView: View {
}
.buttonStyle(.plain)
.help(String(localized: "Turn off plan mode", bundle: .module))
+ .accessibilityIdentifier("plan-mode-chip")
}
/// Mirrors `windowState.sessionPlanMode` into a Binding so the Menu's `Toggle`
@@ -321,6 +323,7 @@ struct InputBarView: View {
onImageChipTap: handleImageChipTap
)
.id(textFieldLayoutID)
+ .accessibilityIdentifier("chat-input")
.onChange(of: windowState.inputText) { oldValue, newValue in
handleInputTextChange(oldValue: oldValue, newValue: newValue)
}
diff --git a/Packages/Sources/RxCodeChatKit/PlanCardView.swift b/Packages/Sources/RxCodeChatKit/PlanCardView.swift
index a5668a14..15dc1228 100644
--- a/Packages/Sources/RxCodeChatKit/PlanCardView.swift
+++ b/Packages/Sources/RxCodeChatKit/PlanCardView.swift
@@ -285,6 +285,7 @@ struct PlanCardView: View {
.onHover { isHovered = $0 }
.disabled(isStreaming)
.help(helpText)
+ .accessibilityIdentifier("plan-card-button")
.animation(.easeInOut(duration: 0.12), value: isHovered)
}
diff --git a/Packages/Sources/RxCodeChatKit/PlanSheetView.swift b/Packages/Sources/RxCodeChatKit/PlanSheetView.swift
index 563aa20e..65de53bf 100644
--- a/Packages/Sources/RxCodeChatKit/PlanSheetView.swift
+++ b/Packages/Sources/RxCodeChatKit/PlanSheetView.swift
@@ -353,5 +353,15 @@ public struct PlanSheetView: View {
}
.buttonStyle(.plain)
.disabled(isDisabled)
+ .accessibilityIdentifier("plan-button-\(accessibilityIDComponent(from: title))")
+ }
+
+ private func accessibilityIDComponent(from title: String) -> String {
+ title
+ .lowercased()
+ .map { $0.isLetter || $0.isNumber ? String($0) : "-" }
+ .joined()
+ .split(separator: "-")
+ .joined(separator: "-")
}
}
diff --git a/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift b/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift
index ec66a95e..47872e09 100644
--- a/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift
+++ b/Packages/Sources/RxCodeCore/Utilities/AppSupport.swift
@@ -6,6 +6,11 @@ import Foundation
/// `RxCode.` so alternate builds never share state with production.
public enum AppSupport {
public static let bundleScopedURL: URL = {
+ if let override = ProcessInfo.processInfo.environment["RXCODE_APP_SUPPORT_DIR"],
+ !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ return URL(fileURLWithPath: override, isDirectory: true)
+ }
+
let root = FileManager.default
.urls(for: .applicationSupportDirectory, in: .userDomainMask)
.first!
diff --git a/RxCode.xcodeproj/project.pbxproj b/RxCode.xcodeproj/project.pbxproj
index 5faef6c8..ca95b1bc 100644
--- a/RxCode.xcodeproj/project.pbxproj
+++ b/RxCode.xcodeproj/project.pbxproj
@@ -8,9 +8,11 @@
/* Begin PBXBuildFile section */
33993F0F87CF4DB09F2813A8 /* AppStateProjectSwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */; };
+ 6E17B0012FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */; };
92E180F9B311F3C72D5DE6B7 /* Cocoa.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = A9993BB72A5307039A88B729 /* Cocoa.framework */; };
DCA8CF4A05C959C4A6EB391F /* RxCodeCore in Frameworks */ = {isa = PBXBuildFile; productRef = CCDF9594876099576D4FD46E /* RxCodeCore */; };
DF06CCD12FB4CAB5005991E1 /* ViewInspector in Frameworks */ = {isa = PBXBuildFile; productRef = DF06CCD02FB4CAB5005991E1 /* ViewInspector */; };
+ DF06DCC72FB8552B005991E1 /* UnitTestPlan.xctestplan in Resources */ = {isa = PBXBuildFile; fileRef = DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */; };
DFA0CCD12FB4CC01005991E1 /* PlanDecisionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC02FB4CC01005991E1 /* PlanDecisionTests.swift */; };
DFA0CCD22FB4CC01005991E1 /* PlanCardViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = DFA0CCC12FB4CC01005991E1 /* PlanCardViewTests.swift */; };
DFA0CCD42FB4CC01005991E1 /* RxCodeChatKit in Frameworks */ = {isa = PBXBuildFile; productRef = DFA0CCC32FB4CC01005991E1 /* RxCodeChatKit */; };
@@ -29,12 +31,23 @@
remoteGlobalIDString = E67335372F7356F600FD26C7;
remoteInfo = RxCode;
};
+ 6E17B0022FC8000100A10001 /* PBXContainerItemProxy */ = {
+ isa = PBXContainerItemProxy;
+ containerPortal = E67335302F7356F600FD26C7 /* Project object */;
+ proxyType = 1;
+ remoteGlobalIDString = E67335372F7356F600FD26C7;
+ remoteInfo = RxCode;
+ };
/* End PBXContainerItemProxy section */
/* Begin PBXFileReference section */
4381E755142272EB2DAA9C96 /* AppStateProjectSwitchTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AppStateProjectSwitchTests.swift; sourceTree = ""; };
+ 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = LocalAIProviderAcceptanceTests.swift; sourceTree = ""; };
+ 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+ 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UITestplan.xctestplan; sourceTree = ""; };
7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = RxCodeTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
A9993BB72A5307039A88B729 /* Cocoa.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Cocoa.framework; path = Platforms/MacOSX.platform/Developer/SDKs/MacOSX15.0.sdk/System/Library/Frameworks/Cocoa.framework; sourceTree = DEVELOPER_DIR; };
+ DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = UnitTestPlan.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 = ""; };
@@ -61,6 +74,13 @@
);
runOnlyForDeploymentPostprocessing = 0;
};
+ 6E17B0052FC8000100A10001 /* Frameworks */ = {
+ isa = PBXFrameworksBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
E67335352F7356F600FD26C7 /* Frameworks */ = {
isa = PBXFrameworksBuildPhase;
buildActionMask = 2147483647;
@@ -102,10 +122,21 @@
name = Frameworks;
sourceTree = "";
};
+ 6E17B0042FC8000100A10001 /* RxCodeUITests */ = {
+ isa = PBXGroup;
+ children = (
+ 6E17B0002FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift */,
+ );
+ path = RxCodeUITests;
+ sourceTree = "";
+ };
E673352F2F7356F600FD26C7 = {
isa = PBXGroup;
children = (
+ DF06DCC62FB8552B005991E1 /* UnitTestPlan.xctestplan */,
+ 6E17B00D2FC8000100A10001 /* UITestplan.xctestplan */,
E673353A2F7356F600FD26C7 /* RxCode */,
+ 6E17B0042FC8000100A10001 /* RxCodeUITests */,
E67335392F7356F600FD26C7 /* Products */,
609E8EE085862BD7D5B4012F /* Frameworks */,
1525FE6BFB6F06A3F00B92D3 /* RxCodeTests */,
@@ -117,6 +148,7 @@
children = (
E67335382F7356F600FD26C7 /* RxCode.app */,
7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */,
+ 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */,
);
name = Products;
sourceTree = "";
@@ -147,6 +179,26 @@
productReference = 7321B5E8B81AAB1A2DC0593B /* RxCodeTests.xctest */;
productType = "com.apple.product-type.bundle.unit-test";
};
+ 6E17B0062FC8000100A10001 /* RxCodeUITests */ = {
+ isa = PBXNativeTarget;
+ buildConfigurationList = 6E17B0072FC8000100A10001 /* Build configuration list for PBXNativeTarget "RxCodeUITests" */;
+ buildPhases = (
+ 6E17B0082FC8000100A10001 /* Sources */,
+ 6E17B0052FC8000100A10001 /* Frameworks */,
+ 6E17B0092FC8000100A10001 /* Resources */,
+ );
+ buildRules = (
+ );
+ dependencies = (
+ 6E17B00A2FC8000100A10001 /* PBXTargetDependency */,
+ );
+ name = RxCodeUITests;
+ packageProductDependencies = (
+ );
+ productName = RxCodeUITests;
+ productReference = 6E17B0032FC8000100A10001 /* RxCodeUITests.xctest */;
+ productType = "com.apple.product-type.bundle.ui-testing";
+ };
E67335372F7356F600FD26C7 /* RxCode */ = {
isa = PBXNativeTarget;
buildConfigurationList = E67335432F7356F700FD26C7 /* Build configuration list for PBXNativeTarget "RxCode" */;
@@ -211,11 +263,19 @@
targets = (
E67335372F7356F600FD26C7 /* RxCode */,
5D74FE7B782850D23F8D9BF7 /* RxCodeTests */,
+ 6E17B0062FC8000100A10001 /* RxCodeUITests */,
);
};
/* End PBXProject section */
/* Begin PBXResourcesBuildPhase section */
+ 6E17B0092FC8000100A10001 /* Resources */ = {
+ isa = PBXResourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
AB14AE27D09CB3D7C28C9888 /* Resources */ = {
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
@@ -227,12 +287,21 @@
isa = PBXResourcesBuildPhase;
buildActionMask = 2147483647;
files = (
+ DF06DCC72FB8552B005991E1 /* UnitTestPlan.xctestplan in Resources */,
);
runOnlyForDeploymentPostprocessing = 0;
};
/* End PBXResourcesBuildPhase section */
/* Begin PBXSourcesBuildPhase section */
+ 6E17B0082FC8000100A10001 /* Sources */ = {
+ isa = PBXSourcesBuildPhase;
+ buildActionMask = 2147483647;
+ files = (
+ 6E17B0012FC8000100A10001 /* LocalAIProviderAcceptanceTests.swift in Sources */,
+ );
+ runOnlyForDeploymentPostprocessing = 0;
+ };
AEA1371373E1E06A1A8F340A /* Sources */ = {
isa = PBXSourcesBuildPhase;
buildActionMask = 2147483647;
@@ -254,6 +323,12 @@
/* End PBXSourcesBuildPhase section */
/* Begin PBXTargetDependency section */
+ 6E17B00A2FC8000100A10001 /* PBXTargetDependency */ = {
+ isa = PBXTargetDependency;
+ name = RxCode;
+ target = E67335372F7356F600FD26C7 /* RxCode */;
+ targetProxy = 6E17B0022FC8000100A10001 /* PBXContainerItemProxy */;
+ };
BC9DD9CE1DAA92D5D395C09C /* PBXTargetDependency */ = {
isa = PBXTargetDependency;
name = RxCode;
@@ -269,7 +344,7 @@
BUNDLE_LOADER = "$(TEST_HOST)";
DEVELOPMENT_TEAM = P9KK452K8P;
GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 15.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_NAME = RxCodeTests;
SDKROOT = macosx;
SWIFT_VERSION = 6.0;
@@ -277,13 +352,41 @@
};
name = Debug;
};
+ 6E17B00B2FC8000100A10001 /* Debug */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ DEVELOPMENT_TEAM = P9KK452K8P;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCodeUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_VERSION = 6.0;
+ TEST_TARGET_NAME = RxCode;
+ };
+ name = Debug;
+ };
+ 6E17B00C2FC8000100A10001 /* Release */ = {
+ isa = XCBuildConfiguration;
+ buildSettings = {
+ DEVELOPMENT_TEAM = P9KK452K8P;
+ GENERATE_INFOPLIST_FILE = YES;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
+ PRODUCT_BUNDLE_IDENTIFIER = com.idealapp.RxCodeUITests;
+ PRODUCT_NAME = "$(TARGET_NAME)";
+ SDKROOT = macosx;
+ SWIFT_VERSION = 6.0;
+ TEST_TARGET_NAME = RxCode;
+ };
+ name = Release;
+ };
AD9E27433C08ABBEF721A8A6 /* Release */ = {
isa = XCBuildConfiguration;
buildSettings = {
BUNDLE_LOADER = "$(TEST_HOST)";
DEVELOPMENT_TEAM = P9KK452K8P;
GENERATE_INFOPLIST_FILE = YES;
- MACOSX_DEPLOYMENT_TARGET = 15.0;
+ MACOSX_DEPLOYMENT_TARGET = 26.0;
PRODUCT_NAME = RxCodeTests;
SDKROOT = macosx;
SWIFT_VERSION = 6.0;
@@ -491,6 +594,15 @@
defaultConfigurationIsVisible = 0;
defaultConfigurationName = Release;
};
+ 6E17B0072FC8000100A10001 /* Build configuration list for PBXNativeTarget "RxCodeUITests" */ = {
+ isa = XCConfigurationList;
+ buildConfigurations = (
+ 6E17B00C2FC8000100A10001 /* Release */,
+ 6E17B00B2FC8000100A10001 /* Debug */,
+ );
+ defaultConfigurationIsVisible = 0;
+ defaultConfigurationName = Release;
+ };
E67335332F7356F600FD26C7 /* Build configuration list for PBXProject "RxCode" */ = {
isa = XCConfigurationList;
buildConfigurations = (
diff --git a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
index 10af1b8b..067ac86d 100644
--- a/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
+++ b/RxCode.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved
@@ -1,5 +1,5 @@
{
- "originHash" : "3439cb7e9e273685728739526616297ea031c0ca38a7f332ac8b1e5c0ddc3397",
+ "originHash" : "8632ffca1a16af6f1b83e63eb95cf449d1902e96b275c0df9fe37cce485ca3d8",
"pins" : [
{
"identity" : "sparkle",
diff --git a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme
index 8a8f85ad..aebc87da 100644
--- a/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme
+++ b/RxCode.xcodeproj/xcshareddata/xcschemes/RxCode.xcscheme
@@ -27,8 +27,16 @@
buildConfiguration = "Debug"
selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
- shouldUseLaunchSchemeArgsEnv = "YES"
- shouldAutocreateTestPlan = "YES">
+ shouldUseLaunchSchemeArgsEnv = "YES">
+
+
+
+
+
+
@@ -40,6 +48,16 @@
ReferencedContainer = "container:RxCode.xcodeproj">
+
+
+
+
URL {
let fm = FileManager.default
- let appSupport = (try? fm.url(for: .applicationSupportDirectory, in: .userDomainMask, appropriateFor: nil, create: true))
- ?? URL(fileURLWithPath: NSHomeDirectory()).appendingPathComponent("Library/Application Support")
- let dir = appSupport.appendingPathComponent("RxCode", isDirectory: true)
+ let dir = AppSupport.bundleScopedURL
try? fm.createDirectory(at: dir, withIntermediateDirectories: true)
return dir.appendingPathComponent("threads.store")
}
diff --git a/RxCode/Views/MainView.swift b/RxCode/Views/MainView.swift
index 1d494388..b7ebe43f 100644
--- a/RxCode/Views/MainView.swift
+++ b/RxCode/Views/MainView.swift
@@ -91,6 +91,8 @@ struct MainView: View {
.toolbar(removing: .title)
.toolbarBackground(.hidden, for: .windowToolbar)
.background(UnifiedTitleBarAccessor())
+ .accessibilityElement(children: .contain)
+ .accessibilityIdentifier("rxcode-main-view")
.toolbar {
if columnVisibility != .detailOnly {
ToolbarItem(placement: .navigation) {
@@ -480,6 +482,7 @@ struct ChatToolbarControls: View {
.menuStyle(.borderlessButton)
.fixedSize()
.help("Permission mode: \(effectiveMode.displayName)")
+ .accessibilityIdentifier("permission-mode-menu")
Menu {
Section("Model Picker") {
@@ -517,6 +520,7 @@ struct ChatToolbarControls: View {
.menuStyle(.borderlessButton)
.fixedSize()
.help("Model: \(effectiveProvider.displayName) · \(appState.modelDisplayLabel(effectiveModel, provider: effectiveProvider))")
+ .accessibilityIdentifier("provider-model-menu")
Menu {
Section("Effort Picker") {
@@ -547,6 +551,7 @@ struct ChatToolbarControls: View {
.menuStyle(.borderlessButton)
.fixedSize()
.help("Effort level: \(windowState.sessionEffort.map { effortDisplayName($0) } ?? "Auto Effort")")
+ .accessibilityIdentifier("effort-menu")
}
.frame(maxWidth: placement == .composer ? .infinity : nil, alignment: .leading)
}
diff --git a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift
index 8b6f5d3d..2886bb22 100644
--- a/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift
+++ b/RxCode/Views/RunProfile/RunProfileToolbarGroup.swift
@@ -96,6 +96,7 @@ struct RunProfileToolbarGroup: View {
.menuIndicator(.hidden)
.fixedSize()
.help("Select Run Profile")
+ .accessibilityIdentifier("run-profile-menu")
}
// MARK: - Run button
@@ -115,6 +116,7 @@ struct RunProfileToolbarGroup: View {
? "\(selectedProfile?.name ?? "") is already running"
: "Run \(selectedProfile?.name ?? "")")
.disabled(selectedProfile == nil || project == nil || isSelectedProfileRunning)
+ .accessibilityIdentifier("run-profile-run-button")
}
// MARK: - Stop button
@@ -128,6 +130,7 @@ struct RunProfileToolbarGroup: View {
stopIcon
}
.help("Stop \(only.profile.name)")
+ .accessibilityIdentifier("run-profile-stop-button")
} else {
Menu {
ForEach(activeTasks) { task in
@@ -149,6 +152,7 @@ struct RunProfileToolbarGroup: View {
.fixedSize()
.help("Stop running tasks")
.padding(.trailing, 4)
+ .accessibilityIdentifier("run-profile-stop-menu")
}
}
diff --git a/RxCode/Views/Toolbar/RxCodeToolbar.swift b/RxCode/Views/Toolbar/RxCodeToolbar.swift
index 2dbac5c1..e7c51f92 100644
--- a/RxCode/Views/Toolbar/RxCodeToolbar.swift
+++ b/RxCode/Views/Toolbar/RxCodeToolbar.swift
@@ -82,6 +82,7 @@ struct RxCodeToolbarContent: ToolbarContent {
}
.help("Toggle Inspector")
.keyboardShortcut("4", modifiers: .command)
+ .accessibilityIdentifier("toggle-inspector-button")
Button {
openSettings()
@@ -89,6 +90,7 @@ struct RxCodeToolbarContent: ToolbarContent {
Image(systemName: "gearshape")
}
.help("Settings")
+ .accessibilityIdentifier("settings-button")
}
}
}
diff --git a/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift b/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift
index f849f99f..3cf33a50 100644
--- a/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift
+++ b/RxCode/Views/Toolbar/TodoProgressToolbarItem.swift
@@ -36,8 +36,10 @@ struct TodoProgressToolbarItem: View {
}
.buttonStyle(.plain)
.help("Todos (\(done)/\(todos.count))")
+ .accessibilityIdentifier("todo-progress-button")
.popover(isPresented: $showPopover, arrowEdge: .bottom) {
TodoListPopoverView(todos: todos)
+ .accessibilityIdentifier("todo-progress-popover")
}
}
}
diff --git a/RxCodeUITests/LocalAIProviderAcceptanceTests.swift b/RxCodeUITests/LocalAIProviderAcceptanceTests.swift
new file mode 100644
index 00000000..41b83431
--- /dev/null
+++ b/RxCodeUITests/LocalAIProviderAcceptanceTests.swift
@@ -0,0 +1,298 @@
+import XCTest
+
+final class LocalAIProviderAcceptanceTests: XCTestCase {
+ enum Provider: String, CaseIterable {
+ case codex
+ case claudeCode
+ case acp
+
+ var modelSeed: String {
+ switch self {
+ case .codex: return "gpt-5.1-codex"
+ case .claudeCode: return "sonnet"
+ case .acp: return "default"
+ }
+ }
+ }
+
+ private var app: XCUIApplication!
+ private var fixture: Fixture!
+
+ override func setUpWithError() throws {
+ continueAfterFailure = false
+ try XCTSkipUnless(
+ ProcessInfo.processInfo.environment["CI"] != "true"
+ && ProcessInfo.processInfo.environment["RXCODE_SKIP_LOCAL_AI_UI_TESTS"] != "1",
+ "Local AI UI acceptance tests are skipped in CI or when RXCODE_SKIP_LOCAL_AI_UI_TESTS=1."
+ )
+ }
+
+ override func tearDownWithError() throws {
+ app?.terminate()
+ if let fixture {
+ try? FileManager.default.removeItem(at: fixture.rootURL)
+ }
+ app = nil
+ fixture = nil
+ }
+
+ func testCodexAcceptanceWorkflow() throws {
+ try runAcceptanceWorkflow(provider: .codex)
+ }
+
+ func testClaudeCodeAcceptanceWorkflow() throws {
+ try runAcceptanceWorkflow(provider: .claudeCode)
+ }
+
+ func testACPAcceptanceWorkflow() throws {
+ try XCTSkipUnless(
+ Fixture.firstInstalledACPClient() != nil,
+ "No installed ACP client found in the local RxCode ACP client store."
+ )
+ try runAcceptanceWorkflow(provider: .acp)
+ }
+
+ private func runAcceptanceWorkflow(provider: Provider) throws {
+ fixture = try Fixture.make(provider: provider)
+ app = launchApp(provider: provider, fixture: fixture)
+
+ XCTAssertTrue(app.otherElements["rxcode-main-view"].waitForExistence(timeout: 30))
+ XCTAssertTrue(app.buttons["run-profile-run-button"].waitForExistence(timeout: 10))
+
+ runProfileSmoke()
+ sendPlanModeTurn(provider: provider)
+ sendIntegratedAITurn(provider: provider)
+ }
+
+ private func launchApp(provider: Provider, fixture: Fixture) -> XCUIApplication {
+ let app = XCUIApplication()
+ app.launchArguments = [
+ "-onboardingCompleted", "YES",
+ "-showMenuBarExtra", "NO",
+ "-selectedAgentProvider", provider.rawValue,
+ "-selectedModel", fixture.selectedModel(for: provider),
+ "-selectedACPClientId", fixture.selectedACPClientId ?? "",
+ ]
+ app.launchEnvironment = [
+ "RXCODE_APP_SUPPORT_DIR": fixture.appSupportURL.path,
+ "RXCODE_UI_TESTING": "1",
+ "RXCODE_LOCAL_MCP_SERVER": fixture.mcpServerPath,
+ ]
+ app.launch()
+ return app
+ }
+
+ private func runProfileSmoke() {
+ app.buttons["run-profile-run-button"].click()
+ let inspector = app.buttons["toggle-inspector-button"]
+ XCTAssertTrue(inspector.waitForExistence(timeout: 5))
+ XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "RXCODE_UI_TEST_RUN_PROFILE")).element.waitForExistence(timeout: 15))
+ }
+
+ private func sendPlanModeTurn(provider: Provider) {
+ let input = focusInput()
+ app.typeKey(.tab, modifierFlags: [.shift])
+ XCTAssertTrue(app.buttons["plan-mode-chip"].waitForExistence(timeout: 5))
+ input.typeText("Create a concise implementation plan for adding a local test sentinel. Do not edit files yet.")
+ app.buttons["chat-send-button"].click()
+
+ let planCard = app.buttons["plan-card-button"]
+ XCTAssertTrue(planCard.waitForExistence(timeout: 120), "Expected a plan card for \(provider.rawValue)")
+ planCard.click()
+
+ let acceptAsk = app.buttons["plan-button-accept-ask"]
+ XCTAssertTrue(acceptAsk.waitForExistence(timeout: 10))
+ acceptAsk.click()
+ }
+
+ private func sendIntegratedAITurn(provider: Provider) {
+ let input = focusInput()
+ input.typeText("""
+ Local RxCode UI acceptance test for \(provider.rawValue):
+ 1. Track exactly three todos: inspect fixture, edit sentinel, verify result.
+ 2. Edit Sources/Sentinel.txt so it contains RXCODE_UI_TEST_EDITED.
+ 3. Call the MCP tool rxcode_test_echo with text ui-test.
+ 4. Use any available IDE job/thread inspection tool if it is available.
+ Keep the final response short and include RXCODE_UI_TEST_DONE.
+ """)
+ app.buttons["chat-send-button"].click()
+
+ XCTAssertTrue(app.buttons["todo-progress-button"].waitForExistence(timeout: 120))
+ XCTAssertTrue(waitForFileToContain(fixture.projectURL.appendingPathComponent("Sources/Sentinel.txt"), "RXCODE_UI_TEST_EDITED", timeout: 180))
+ XCTAssertTrue(app.staticTexts.containing(NSPredicate(format: "label CONTAINS %@", "RXCODE_UI_TEST_DONE")).element.waitForExistence(timeout: 180))
+ }
+
+ private func focusInput() -> XCUIElement {
+ let input = app.textViews["chat-input-text-view"].exists
+ ? app.textViews["chat-input-text-view"]
+ : app.textViews.element(boundBy: 0)
+ XCTAssertTrue(input.waitForExistence(timeout: 10))
+ input.click()
+ return input
+ }
+
+ private func waitForFileToContain(_ url: URL, _ needle: String, timeout: TimeInterval) -> Bool {
+ let deadline = Date().addingTimeInterval(timeout)
+ while Date() < deadline {
+ if let contents = try? String(contentsOf: url), contents.contains(needle) {
+ return true
+ }
+ RunLoop.current.run(until: Date().addingTimeInterval(1))
+ }
+ return false
+ }
+}
+
+private struct Fixture {
+ let rootURL: URL
+ let appSupportURL: URL
+ let projectURL: URL
+ let mcpServerPath: String
+ let selectedACPClientId: String?
+ let selectedACPModel: String?
+
+ func selectedModel(for provider: LocalAIProviderAcceptanceTests.Provider) -> String {
+ if provider == .acp, let selectedACPModel {
+ return selectedACPModel
+ }
+ return provider.modelSeed
+ }
+
+ static func make(provider: LocalAIProviderAcceptanceTests.Provider) throws -> Fixture {
+ let fm = FileManager.default
+ let root = fm.temporaryDirectory.appendingPathComponent("RxCodeUITests-\(UUID().uuidString)", isDirectory: true)
+ let appSupport = root.appendingPathComponent("AppSupport", isDirectory: true)
+ let project = root.appendingPathComponent("FixtureProject", isDirectory: true)
+ let sources = project.appendingPathComponent("Sources", isDirectory: true)
+ try fm.createDirectory(at: appSupport, withIntermediateDirectories: true)
+ try fm.createDirectory(at: sources, withIntermediateDirectories: true)
+ try "RXCODE_UI_TEST_ORIGINAL\n".write(to: sources.appendingPathComponent("Sentinel.txt"), atomically: true, encoding: .utf8)
+
+ let projectId = "11111111-1111-1111-1111-111111111111"
+ let installedACPClient = provider == .acp ? firstInstalledACPClient() : nil
+ let selectedACPClientId = installedACPClient.flatMap { $0["id"] as? String }
+ let selectedACPModel = installedACPClient.flatMap(Self.firstModelID(in:))
+ try writeJSON([
+ [
+ "id": projectId,
+ "name": "RxCode UI Fixture",
+ "path": project.path,
+ "lastAgentProvider": provider.rawValue,
+ "lastModel": selectedACPModel ?? provider.modelSeed,
+ ],
+ ], to: appSupport.appendingPathComponent("projects.json"))
+
+ try writeJSON([
+ [
+ "id": "22222222-2222-2222-2222-222222222222",
+ "projectId": projectId,
+ "name": "UI Test Echo",
+ "type": "bash",
+ "bash": [
+ "command": "printf 'RXCODE_UI_TEST_RUN_PROFILE\\n'",
+ "workingDirectory": "",
+ "environments": [],
+ ],
+ "beforeSteps": [],
+ "afterSteps": [],
+ "createdAt": "2026-05-16T00:00:00Z",
+ "updatedAt": "2026-05-16T00:00:00Z",
+ ],
+ ], to: appSupport.appendingPathComponent("run_profiles/\(projectId).json"))
+
+ let mcpServer = ProcessInfo.processInfo.environment["RXCODE_LOCAL_MCP_SERVER"]
+ ?? "\(URL(fileURLWithPath: #filePath).deletingLastPathComponent().deletingLastPathComponent().path)/scripts/mcp_echo_server.py"
+ try writeJSON([
+ "version": 1,
+ "servers": [[
+ "name": "rxcode-ui-test",
+ "transport": "stdio",
+ "command": mcpServer,
+ "args": [],
+ "env": [:],
+ "headers": [:],
+ "isGloballyEnabled": true,
+ "projectOverrides": [project.path: "enabled"],
+ ]],
+ ], to: appSupport.appendingPathComponent("mcp.json"))
+
+ if let installedACPClient {
+ var copied = installedACPClient
+ copied["enabled"] = true
+ try writeJSON([copied], to: appSupport.appendingPathComponent("acp_clients.json"))
+ }
+
+ return Fixture(
+ rootURL: root,
+ appSupportURL: appSupport,
+ projectURL: project,
+ mcpServerPath: mcpServer,
+ selectedACPClientId: selectedACPClientId,
+ selectedACPModel: selectedACPModel
+ )
+ }
+
+ static func firstInstalledACPClient() -> [String: Any]? {
+ for url in acpClientStoreCandidates() {
+ guard let data = try? Data(contentsOf: url),
+ let array = try? JSONSerialization.jsonObject(with: data) as? [[String: Any]],
+ !array.isEmpty else {
+ continue
+ }
+ return array.first { ($0["enabled"] as? Bool) != false } ?? array.first
+ }
+ return nil
+ }
+
+ private static func acpClientStoreCandidates() -> [URL] {
+ let fm = FileManager.default
+ var candidates: [URL] = []
+
+ if let override = ProcessInfo.processInfo.environment["RXCODE_ACP_CLIENTS_FILE"],
+ !override.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty {
+ candidates.append(URL(fileURLWithPath: override))
+ }
+
+ let home = URL(fileURLWithPath: NSHomeDirectory(), isDirectory: true)
+ let applicationSupport = home.appendingPathComponent("Library/Application Support", isDirectory: true)
+ candidates.append(applicationSupport.appendingPathComponent("RxCode/acp_clients.json"))
+ candidates.append(applicationSupport.appendingPathComponent("RxCode.dev/acp_clients.json"))
+
+ if let enumerator = fm.enumerator(
+ at: applicationSupport,
+ includingPropertiesForKeys: [.isRegularFileKey],
+ options: [.skipsHiddenFiles, .skipsPackageDescendants]
+ ) {
+ for case let url as URL in enumerator {
+ guard url.lastPathComponent == "acp_clients.json",
+ url.path.contains("/RxCode") else {
+ continue
+ }
+ candidates.append(url)
+ }
+ }
+
+ var seen = Set()
+ return candidates.filter { seen.insert($0.standardizedFileURL.path).inserted }
+ }
+
+ private static func firstModelID(in client: [String: Any]) -> String? {
+ if let models = client["models"] as? [String],
+ let first = models.first,
+ !first.isEmpty {
+ return first
+ }
+ if let options = client["modelOptions"] as? [[String: Any]],
+ let value = options.compactMap({ $0["value"] as? String }).first,
+ !value.isEmpty {
+ return value
+ }
+ return nil
+ }
+
+ private static func writeJSON(_ value: Any, to url: URL) throws {
+ try FileManager.default.createDirectory(at: url.deletingLastPathComponent(), withIntermediateDirectories: true)
+ let data = try JSONSerialization.data(withJSONObject: value, options: [.prettyPrinted, .sortedKeys])
+ try data.write(to: url, options: .atomic)
+ }
+}
diff --git a/UITestplan.xctestplan b/UITestplan.xctestplan
new file mode 100644
index 00000000..35c779e0
--- /dev/null
+++ b/UITestplan.xctestplan
@@ -0,0 +1,40 @@
+{
+ "configurations" : [
+ {
+ "id" : "5C12EB1C-119F-45F2-8C22-0B1274C2A4B3",
+ "name" : "Local UI Acceptance",
+ "options" : {
+ "environmentVariableEntries" : [
+ {
+ "isEnabled" : true,
+ "key" : "RXCODE_LOCAL_MCP_SERVER",
+ "value" : "$(SRCROOT)/scripts/mcp_echo_server.py"
+ },
+ {
+ "isEnabled" : true,
+ "key" : "RXCODE_SKIP_LOCAL_AI_UI_TESTS",
+ "value" : "0"
+ }
+ ]
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "targetForVariableExpansion" : {
+ "containerPath" : "container:RxCode.xcodeproj",
+ "identifier" : "E67335372F7356F600FD26C7",
+ "name" : "RxCode"
+ },
+ "testTimeoutsEnabled" : true
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:RxCode.xcodeproj",
+ "identifier" : "6E17B0062FC8000100A10001",
+ "name" : "RxCodeUITests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/UnitTestPlan.xctestplan b/UnitTestPlan.xctestplan
new file mode 100644
index 00000000..1353de8d
--- /dev/null
+++ b/UnitTestPlan.xctestplan
@@ -0,0 +1,24 @@
+{
+ "configurations" : [
+ {
+ "id" : "BC843CAB-645B-4D89-BAE0-34BE9DFE9D04",
+ "name" : "Configuration 1",
+ "options" : {
+
+ }
+ }
+ ],
+ "defaultOptions" : {
+ "testTimeoutsEnabled" : true
+ },
+ "testTargets" : [
+ {
+ "target" : {
+ "containerPath" : "container:RxCode.xcodeproj",
+ "identifier" : "5D74FE7B782850D23F8D9BF7",
+ "name" : "RxCodeTests"
+ }
+ }
+ ],
+ "version" : 1
+}
diff --git a/scripts/local-ai-ui-tests.sh b/scripts/local-ai-ui-tests.sh
new file mode 100755
index 00000000..6338178e
--- /dev/null
+++ b/scripts/local-ai-ui-tests.sh
@@ -0,0 +1,30 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
+
+export RXCODE_RUN_LOCAL_AI_UI_TESTS="${RXCODE_RUN_LOCAL_AI_UI_TESTS:-1}"
+export RXCODE_LOCAL_MCP_SERVER="${RXCODE_LOCAL_MCP_SERVER:-$ROOT/scripts/mcp_echo_server.py}"
+
+if [[ ! -x "$RXCODE_LOCAL_MCP_SERVER" ]]; then
+ chmod +x "$RXCODE_LOCAL_MCP_SERVER"
+fi
+
+if ! command -v codex >/dev/null 2>&1; then
+ echo "codex is required for the local AI UI suite." >&2
+ exit 1
+fi
+
+if ! command -v claude >/dev/null 2>&1; then
+ echo "claude is required for the local AI UI suite." >&2
+ exit 1
+fi
+
+cd "$ROOT"
+
+xcodebuild test \
+ -project RxCode.xcodeproj \
+ -scheme RxCode \
+ -testPlan UITestplan \
+ -configuration Debug \
+ -only-testing:RxCodeUITests
diff --git a/scripts/mcp_echo_server.py b/scripts/mcp_echo_server.py
new file mode 100755
index 00000000..016d685c
--- /dev/null
+++ b/scripts/mcp_echo_server.py
@@ -0,0 +1,68 @@
+#!/usr/bin/env python3
+import json
+import sys
+
+
+def send(payload):
+ sys.stdout.write(json.dumps(payload, separators=(",", ":")) + "\n")
+ sys.stdout.flush()
+
+
+def result(request_id, value):
+ send({"jsonrpc": "2.0", "id": request_id, "result": value})
+
+
+def error(request_id, code, message):
+ send({
+ "jsonrpc": "2.0",
+ "id": request_id,
+ "error": {"code": code, "message": message},
+ })
+
+
+for line in sys.stdin:
+ try:
+ frame = json.loads(line)
+ except json.JSONDecodeError:
+ continue
+
+ method = frame.get("method")
+ request_id = frame.get("id")
+ params = frame.get("params") or {}
+
+ if request_id is None:
+ continue
+
+ if method == "initialize":
+ result(request_id, {
+ "protocolVersion": "2024-11-05",
+ "capabilities": {"tools": {}},
+ "serverInfo": {"name": "rxcode-ui-test-mcp", "version": "1.0.0"},
+ })
+ elif method == "tools/list":
+ result(request_id, {
+ "tools": [{
+ "name": "rxcode_test_echo",
+ "description": "Return the input text for RxCode UI acceptance tests.",
+ "inputSchema": {
+ "type": "object",
+ "properties": {
+ "text": {"type": "string"},
+ },
+ "required": ["text"],
+ },
+ }],
+ })
+ elif method == "tools/call":
+ name = params.get("name")
+ arguments = params.get("arguments") or {}
+ if name != "rxcode_test_echo":
+ error(request_id, -32601, f"unknown tool: {name}")
+ continue
+ text = arguments.get("text", "")
+ result(request_id, {
+ "content": [{"type": "text", "text": f"rxcode-mcp-echo:{text}"}],
+ "isError": False,
+ })
+ else:
+ result(request_id, {})