From 8db279aa9009d9689c6ed6f895171435b4b4a241 Mon Sep 17 00:00:00 2001 From: Andres Cecilia Luque Date: Fri, 16 Jan 2026 19:26:36 +0100 Subject: [PATCH 1/2] Add comprehensive tests for architecture filtering - Add 4 integration tests for XcodeList.update() that verify architecture filtering - Tests cover ARM64 selecting Apple_silicon, ARM64 fallback to Universal, Intel selecting Universal, and Intel edge case --- Sources/XcodesKit/Environment.swift | 11 ++ Sources/XcodesKit/XcodeList.swift | 66 +++++++-- Tests/XcodesKitTests/Environment+Mock.swift | 1 + Tests/XcodesKitTests/XcodesKitTests.swift | 153 ++++++++++++++++++++ 4 files changed, 219 insertions(+), 12 deletions(-) diff --git a/Sources/XcodesKit/Environment.swift b/Sources/XcodesKit/Environment.swift index 393e19c9..1091ee26 100644 --- a/Sources/XcodesKit/Environment.swift +++ b/Sources/XcodesKit/Environment.swift @@ -63,6 +63,17 @@ public struct Shell { } public var isRoot: () -> Bool = { NSUserName() == "root" } + /// Returns the machine architecture (e.g., "arm64", "x86_64") + public var machineArchitecture: () -> String = { + var systemInfo = utsname() + uname(&systemInfo) + return withUnsafePointer(to: &systemInfo.machine) { + $0.withMemoryRebound(to: CChar.self, capacity: 1) { + String(cString: $0) + } + } + } + /// Returns the path of an executable within the directories in the PATH environment variable. public var findExecutable: (_ executableName: String) -> Path? = { executableName in guard let path = ProcessInfo.processInfo.environment["PATH"] else { return nil } diff --git a/Sources/XcodesKit/XcodeList.swift b/Sources/XcodesKit/XcodeList.swift index b46c970d..3e443718 100644 --- a/Sources/XcodesKit/XcodeList.swift +++ b/Sources/XcodesKit/XcodeList.swift @@ -23,9 +23,11 @@ public final class XcodeList { } public func update(dataSource: DataSource) -> Promise<[Xcode]> { + let xcodesPromise: Promise<[Xcode]> + switch dataSource { case .apple: - return when(fulfilled: releasedXcodes(), prereleaseXcodes()) + xcodesPromise = when(fulfilled: releasedXcodes(), prereleaseXcodes()) .map { releasedXcodes, prereleaseXcodes in // Starting with Xcode 11 beta 6, developer.apple.com/download and developer.apple.com/download/more both list some pre-release versions of Xcode. // Previously pre-release versions only appeared on developer.apple.com/download. @@ -34,19 +36,19 @@ public final class XcodeList { let xcodes = releasedXcodes.filter { releasedXcode in prereleaseXcodes.contains { $0.version.isEquivalent(to: releasedXcode.version) } == false } + prereleaseXcodes - self.availableXcodes = xcodes - self.lastUpdated = Date() - try? self.cacheAvailableXcodes(xcodes) return xcodes } case .xcodeReleases: - return xcodeReleases() - .map { xcodes in - self.availableXcodes = xcodes - self.lastUpdated = Date() - try? self.cacheAvailableXcodes(xcodes) - return xcodes - } + xcodesPromise = xcodeReleases() + } + + return xcodesPromise.map { xcodes in + // Apply architecture filtering centrally for all data sources + let filtered = self.filterArchitectureVariants(xcodes) + self.availableXcodes = filtered + self.lastUpdated = Date() + try? self.cacheAvailableXcodes(filtered) + return filtered } } } @@ -190,5 +192,45 @@ extension XcodeList { } } return filteredXcodes - } + } + + /// Filters architecture variants to select the appropriate one for the current machine. + /// When multiple architecture variants exist for the same version (e.g., Apple_silicon and Universal), + /// selects the one that matches the current machine's architecture. + private func filterArchitectureVariants(_ xcodes: [Xcode]) -> [Xcode] { + let machine = Current.shell.machineArchitecture() + let isAppleSilicon = machine == "arm64" + + // Group by version to find duplicates + var versionGroups: [Version: [Xcode]] = [:] + for xcode in xcodes { + versionGroups[xcode.version, default: []].append(xcode) + } + + var filtered: [Xcode] = [] + for (_, variants) in versionGroups { + if variants.count == 1 { + // No architecture variants, keep as-is + filtered.append(variants[0]) + } else { + // Multiple variants - select based on architecture + let selected: Xcode + if isAppleSilicon { + // Prefer Apple_silicon, fallback to Universal + selected = variants.first { $0.url.absoluteString.contains("Apple_silicon") } + ?? variants.first { $0.url.absoluteString.contains("Universal") } + ?? variants[0] + } else { + // On Intel, prefer Universal, avoid Apple_silicon + selected = variants.first { $0.url.absoluteString.contains("Universal") } + ?? variants.first { !$0.url.absoluteString.contains("Apple_silicon") } + ?? variants[0] + } + + filtered.append(selected) + } + } + + return filtered + } } diff --git a/Tests/XcodesKitTests/Environment+Mock.swift b/Tests/XcodesKitTests/Environment+Mock.swift index 067f9cec..6677e3a5 100644 --- a/Tests/XcodesKitTests/Environment+Mock.swift +++ b/Tests/XcodesKitTests/Environment+Mock.swift @@ -39,6 +39,7 @@ extension Shell { xcodeSelectPrintPath: { return Promise.value(Shell.processOutputMock) }, xcodeSelectSwitch: { _, _ in return Promise.value(Shell.processOutputMock) }, isRoot: { true }, + machineArchitecture: { "arm64" }, readLine: { _ in return nil }, readSecureLine: { _, _ in return nil }, env: { _ in nil }, diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 1282d926..6112c750 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -1479,4 +1479,157 @@ final class XcodesKitTests: XCTestCase { XCTAssertEqual(cookies[2].path, "/") XCTAssertEqual(cookies[2].isSecure, true) } + + func test_XcodeList_Update_FiltersArchitectureVariants_ARM64SelectsAppleSilicon() { + XcodesKit.Current.shell.machineArchitecture = { "arm64" } + + let downloads = Downloads(downloads: [ + Download(name: "Xcode 16.2", files: [ + Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") + ], dateModified: Date()), + Download(name: "Xcode 16.2", files: [ + Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip") + ], dateModified: Date()) + ]) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) + let downloadsData = try! encoder.encode(downloads) + + XcodesKit.Current.network.dataTask = { url in + if url.pmkRequest.url! == URLRequest.downloads.url! { + return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + // Return empty data for prerelease endpoint + return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + + let expectation = self.expectation(description: "update completes") + let xcodesList = XcodeList() + + xcodesList.update(dataSource: .apple) + .done { xcodes in + XCTAssertEqual(xcodes.count, 1) + XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") + expectation.fulfill() + } + .catch { error in + XCTFail("Update failed: \(error)") + } + + waitForExpectations(timeout: 1.0) + } + + func test_XcodeList_Update_FiltersArchitectureVariants_ARM64FallsBackToUniversal() { + XcodesKit.Current.shell.machineArchitecture = { "arm64" } + + let downloads = Downloads(downloads: [ + Download(name: "Xcode 15.0", files: [ + Download.File(remotePath: "Developer_Tools/Xcode_15.0/Xcode_15.0_Universal.xip") + ], dateModified: Date()) + ]) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) + let downloadsData = try! encoder.encode(downloads) + + XcodesKit.Current.network.dataTask = { url in + if url.pmkRequest.url! == URLRequest.downloads.url! { + return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + + let expectation = self.expectation(description: "update completes") + let xcodesList = XcodeList() + + xcodesList.update(dataSource: .apple) + .done { xcodes in + XCTAssertEqual(xcodes.count, 1) + XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_15.0/Xcode_15.0_Universal.xip") + expectation.fulfill() + } + .catch { error in + XCTFail("Update failed: \(error)") + } + + waitForExpectations(timeout: 1.0) + } + + func test_XcodeList_Update_FiltersArchitectureVariants_IntelSelectsUniversal() { + XcodesKit.Current.shell.machineArchitecture = { "x86_64" } + + let downloads = Downloads(downloads: [ + Download(name: "Xcode 16.2", files: [ + Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") + ], dateModified: Date()), + Download(name: "Xcode 16.2", files: [ + Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip") + ], dateModified: Date()) + ]) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) + let downloadsData = try! encoder.encode(downloads) + + XcodesKit.Current.network.dataTask = { url in + if url.pmkRequest.url! == URLRequest.downloads.url! { + return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + + let expectation = self.expectation(description: "update completes") + let xcodesList = XcodeList() + + xcodesList.update(dataSource: .apple) + .done { xcodes in + XCTAssertEqual(xcodes.count, 1) + XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip") + expectation.fulfill() + } + .catch { error in + XCTFail("Update failed: \(error)") + } + + waitForExpectations(timeout: 1.0) + } + + func test_XcodeList_Update_FiltersArchitectureVariants_IntelAvoidsAppleSilicon() { + XcodesKit.Current.shell.machineArchitecture = { "x86_64" } + + let downloads = Downloads(downloads: [ + Download(name: "Xcode 16.2", files: [ + Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") + ], dateModified: Date()) + ]) + + let encoder = JSONEncoder() + encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) + let downloadsData = try! encoder.encode(downloads) + + XcodesKit.Current.network.dataTask = { url in + if url.pmkRequest.url! == URLRequest.downloads.url! { + return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + } + + let expectation = self.expectation(description: "update completes") + let xcodesList = XcodeList() + + xcodesList.update(dataSource: .apple) + .done { xcodes in + // Intel should not get Apple_silicon only builds + // In this case it falls back to the Apple_silicon as last resort + XCTAssertEqual(xcodes.count, 1) + XCTAssertEqual(xcodes[0].url.absoluteString, "https://download.developer.apple.com/Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") + expectation.fulfill() + } + .catch { error in + XCTFail("Update failed: \(error)") + } + + waitForExpectations(timeout: 1.0) + } } From 3813f735e8c6ddda9846079e947e6ab6ee53ea73 Mon Sep 17 00:00:00 2001 From: Andres Cecilia Luque Date: Fri, 16 Jan 2026 19:43:32 +0100 Subject: [PATCH 2/2] Improve test quality: use deterministic dates and XCTUnwrap - Replace Date() with Date(timeIntervalSince1970: 0) for consistent test behavior - Replace force unwraps with XCTUnwrap in test mocks - Replace try! with try and proper error handling - Wrap mock setup in do-catch blocks that return Promise errors --- Tests/XcodesKitTests/XcodesKitTests.swift | 90 ++++++++++++++++------- 1 file changed, 63 insertions(+), 27 deletions(-) diff --git a/Tests/XcodesKitTests/XcodesKitTests.swift b/Tests/XcodesKitTests/XcodesKitTests.swift index 6112c750..83c6d1e2 100644 --- a/Tests/XcodesKitTests/XcodesKitTests.swift +++ b/Tests/XcodesKitTests/XcodesKitTests.swift @@ -1480,28 +1480,37 @@ final class XcodesKitTests: XCTestCase { XCTAssertEqual(cookies[2].isSecure, true) } - func test_XcodeList_Update_FiltersArchitectureVariants_ARM64SelectsAppleSilicon() { + func test_XcodeList_Update_FiltersArchitectureVariants_ARM64SelectsAppleSilicon() throws { XcodesKit.Current.shell.machineArchitecture = { "arm64" } + let testDate = Date(timeIntervalSince1970: 0) let downloads = Downloads(downloads: [ Download(name: "Xcode 16.2", files: [ Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") - ], dateModified: Date()), + ], dateModified: testDate), Download(name: "Xcode 16.2", files: [ Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip") - ], dateModified: Date()) + ], dateModified: testDate) ]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) - let downloadsData = try! encoder.encode(downloads) + let downloadsData = try encoder.encode(downloads) XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + do { + let requestURL = try XCTUnwrap(url.pmkRequest.url) + let downloadsURL = try XCTUnwrap(URLRequest.downloads.url) + let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil)) + + if requestURL == downloadsURL { + return Promise.value((data: downloadsData, response: response)) + } + // Return empty data for prerelease endpoint + return Promise.value((data: Data(), response: response)) + } catch { + return Promise(error: error) } - // Return empty data for prerelease endpoint - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } let expectation = self.expectation(description: "update completes") @@ -1520,24 +1529,33 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - func test_XcodeList_Update_FiltersArchitectureVariants_ARM64FallsBackToUniversal() { + func test_XcodeList_Update_FiltersArchitectureVariants_ARM64FallsBackToUniversal() throws { XcodesKit.Current.shell.machineArchitecture = { "arm64" } + let testDate = Date(timeIntervalSince1970: 0) let downloads = Downloads(downloads: [ Download(name: "Xcode 15.0", files: [ Download.File(remotePath: "Developer_Tools/Xcode_15.0/Xcode_15.0_Universal.xip") - ], dateModified: Date()) + ], dateModified: testDate) ]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) - let downloadsData = try! encoder.encode(downloads) + let downloadsData = try encoder.encode(downloads) XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + do { + let requestURL = try XCTUnwrap(url.pmkRequest.url) + let downloadsURL = try XCTUnwrap(URLRequest.downloads.url) + let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil)) + + if requestURL == downloadsURL { + return Promise.value((data: downloadsData, response: response)) + } + return Promise.value((data: Data(), response: response)) + } catch { + return Promise(error: error) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } let expectation = self.expectation(description: "update completes") @@ -1556,27 +1574,36 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - func test_XcodeList_Update_FiltersArchitectureVariants_IntelSelectsUniversal() { + func test_XcodeList_Update_FiltersArchitectureVariants_IntelSelectsUniversal() throws { XcodesKit.Current.shell.machineArchitecture = { "x86_64" } + let testDate = Date(timeIntervalSince1970: 0) let downloads = Downloads(downloads: [ Download(name: "Xcode 16.2", files: [ Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") - ], dateModified: Date()), + ], dateModified: testDate), Download(name: "Xcode 16.2", files: [ Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Universal.xip") - ], dateModified: Date()) + ], dateModified: testDate) ]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) - let downloadsData = try! encoder.encode(downloads) + let downloadsData = try encoder.encode(downloads) XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + do { + let requestURL = try XCTUnwrap(url.pmkRequest.url) + let downloadsURL = try XCTUnwrap(URLRequest.downloads.url) + let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil)) + + if requestURL == downloadsURL { + return Promise.value((data: downloadsData, response: response)) + } + return Promise.value((data: Data(), response: response)) + } catch { + return Promise(error: error) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } let expectation = self.expectation(description: "update completes") @@ -1595,24 +1622,33 @@ final class XcodesKitTests: XCTestCase { waitForExpectations(timeout: 1.0) } - func test_XcodeList_Update_FiltersArchitectureVariants_IntelAvoidsAppleSilicon() { + func test_XcodeList_Update_FiltersArchitectureVariants_IntelAvoidsAppleSilicon() throws { XcodesKit.Current.shell.machineArchitecture = { "x86_64" } + let testDate = Date(timeIntervalSince1970: 0) let downloads = Downloads(downloads: [ Download(name: "Xcode 16.2", files: [ Download.File(remotePath: "Developer_Tools/Xcode_16.2/Xcode_16.2_Apple_silicon.xip") - ], dateModified: Date()) + ], dateModified: testDate) ]) let encoder = JSONEncoder() encoder.dateEncodingStrategy = .formatted(.downloadsDateModified) - let downloadsData = try! encoder.encode(downloads) + let downloadsData = try encoder.encode(downloads) XcodesKit.Current.network.dataTask = { url in - if url.pmkRequest.url! == URLRequest.downloads.url! { - return Promise.value((data: downloadsData, response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) + do { + let requestURL = try XCTUnwrap(url.pmkRequest.url) + let downloadsURL = try XCTUnwrap(URLRequest.downloads.url) + let response = try XCTUnwrap(HTTPURLResponse(url: requestURL, statusCode: 200, httpVersion: nil, headerFields: nil)) + + if requestURL == downloadsURL { + return Promise.value((data: downloadsData, response: response)) + } + return Promise.value((data: Data(), response: response)) + } catch { + return Promise(error: error) } - return Promise.value((data: Data(), response: HTTPURLResponse(url: url.pmkRequest.url!, statusCode: 200, httpVersion: nil, headerFields: nil)!)) } let expectation = self.expectation(description: "update completes")