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 4a182baa..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 = 26.2; + 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 = 26.2; + MACOSX_DEPLOYMENT_TARGET = 26.0; PRODUCT_NAME = RxCodeTests; SDKROOT = macosx; SWIFT_VERSION = 6.0; @@ -345,7 +448,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 +506,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; @@ -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"> + + + + 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..a81cd2a0 100644 --- a/RxCode/Services/ACPService.swift +++ b/RxCode/Services/ACPService.swift @@ -457,6 +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", @@ -472,7 +478,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 { @@ -546,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 @@ -1047,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) } @@ -1238,6 +1246,42 @@ 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] { + 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 { + logger.info("[ACP-MCP] dropping entry name=\(name, privacy: .public) transport=\(transport, privacy: .public) — agent doesn't advertise capability") + 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..9f2e9eb0 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 { @@ -193,9 +176,63 @@ 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 } + 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/RxCode/Services/ThreadStore.swift b/RxCode/Services/ThreadStore.swift index aa7bf7b6..6eb71b57 100644 --- a/RxCode/Services/ThreadStore.swift +++ b/RxCode/Services/ThreadStore.swift @@ -35,9 +35,7 @@ final class ThreadStore { private static func storeURL() -> 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/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 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, {})