diff --git a/Sources/LinuxPlatform/Linux.swift b/Sources/LinuxPlatform/Linux.swift index e2c39f36..a074d32b 100644 --- a/Sources/LinuxPlatform/Linux.swift +++ b/Sources/LinuxPlatform/Linux.swift @@ -131,7 +131,9 @@ public struct Linux: Platform { [] } - public func selfUpdate() async throws {} + public func getExecutableName(forArch: String) -> String { + "swiftly-\(forArch)-unknown-linux-gnu" + } public func currentToolchain() throws -> ToolchainVersion? { nil } diff --git a/Sources/Swiftly/Config.swift b/Sources/Swiftly/Config.swift index 29ad53f8..75516980 100644 --- a/Sources/Swiftly/Config.swift +++ b/Sources/Swiftly/Config.swift @@ -23,6 +23,10 @@ public struct Config: Codable, Equatable { /// The CPU architecture of the platform. If omitted, assumed to be x86_64. public let architecture: String? + + public func getArchitecture() -> String { + self.architecture ?? "x86_64" + } } public var inUse: ToolchainVersion? diff --git a/Sources/Swiftly/Install.swift b/Sources/Swiftly/Install.swift index bd5e1f68..ae301ad1 100644 --- a/Sources/Swiftly/Install.swift +++ b/Sources/Swiftly/Install.swift @@ -126,6 +126,10 @@ struct Install: SwiftlyCommand { url += "\(snapshotString)-\(release.date)-a-\(platformFullString).\(Swiftly.currentPlatform.toolchainFileExtension)" } + guard let url = URL(string: url) else { + throw Error(message: "Invalid toolchain URL: \(url)") + } + let animation = PercentProgressAnimation( stream: stdoutStream, header: "Downloading \(version)" @@ -134,10 +138,9 @@ struct Install: SwiftlyCommand { var lastUpdate = Date() do { - try await httpClient.downloadToolchain( - version, + try await httpClient.downloadFile( url: url, - to: tmpFile.path, + to: tmpFile, reportProgress: { progress in let now = Date() diff --git a/Sources/Swiftly/SelfUpdate.swift b/Sources/Swiftly/SelfUpdate.swift index cdacdf31..2ccaa172 100644 --- a/Sources/Swiftly/SelfUpdate.swift +++ b/Sources/Swiftly/SelfUpdate.swift @@ -1,12 +1,78 @@ import ArgumentParser +import Foundation +import TSCBasic +import TSCUtility + +import SwiftlyCore internal struct SelfUpdate: SwiftlyCommand { public static var configuration = CommandConfiguration( abstract: "Update the version of swiftly itself." ) + internal var httpClient = SwiftlyHTTPClient() + + private enum CodingKeys: CodingKey {} + internal mutating func run() async throws { - print("updating swiftly") - try await Swiftly.currentPlatform.selfUpdate() + SwiftlyCore.print("Checking for swiftly updates...") + + let release: SwiftlyGitHubRelease = try await self.httpClient.getFromGitHub( + url: "https://api.github.com/repos/swift-server/swiftly/releases/latest" + ) + + let version = try SwiftlyVersion(parsing: release.tag) + + guard version > SwiftlyCore.version else { + SwiftlyCore.print("Already up to date.") + return + } + + SwiftlyCore.print("A new version is available: \(version)") + + let config = try Config.load() + let executableName = Swiftly.currentPlatform.getExecutableName(forArch: config.platform.getArchitecture()) + let urlString = "https://github.com/swift-server/swiftly/versions/latest/download/\(executableName)" + guard let downloadURL = URL(string: urlString) else { + throw Error(message: "Invalid download url: \(urlString)") + } + + let tmpFile = Swiftly.currentPlatform.getTempFilePath() + FileManager.default.createFile(atPath: tmpFile.path, contents: nil) + defer { + try? FileManager.default.removeItem(at: tmpFile) + } + + let animation = PercentProgressAnimation( + stream: stdoutStream, + header: "Downloading swiftly \(version)" + ) + do { + try await self.httpClient.downloadFile( + url: downloadURL, + to: tmpFile, + reportProgress: { progress in + let downloadedMiB = Double(progress.receivedBytes) / (1024.0 * 1024.0) + let totalMiB = Double(progress.totalBytes!) / (1024.0 * 1024.0) + + animation.update( + step: progress.receivedBytes, + total: progress.totalBytes!, + text: "Downloaded \(String(format: "%.1f", downloadedMiB)) MiB of \(String(format: "%.1f", totalMiB)) MiB" + ) + } + ) + } catch { + animation.complete(success: false) + throw error + } + animation.complete(success: true) + + let swiftlyExecutable = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swiftly", isDirectory: false) + try FileManager.default.removeItem(at: swiftlyExecutable) + try FileManager.default.moveItem(at: tmpFile, to: swiftlyExecutable) + try FileManager.default.setAttributes([.posixPermissions: 0o755], ofItemAtPath: swiftlyExecutable.path) + + SwiftlyCore.print("Successfully updated swiftly to \(version) (was \(SwiftlyCore.version))") } } diff --git a/Sources/Swiftly/Swiftly.swift b/Sources/Swiftly/Swiftly.swift index 902d3b76..21731211 100644 --- a/Sources/Swiftly/Swiftly.swift +++ b/Sources/Swiftly/Swiftly.swift @@ -11,7 +11,7 @@ public struct Swiftly: SwiftlyCommand { public static var configuration = CommandConfiguration( abstract: "A utility for installing and managing Swift toolchains.", - version: "0.1.0", + version: String(describing: SwiftlyCore.version), subcommands: [ Install.self, @@ -19,6 +19,7 @@ public struct Swiftly: SwiftlyCommand { Uninstall.self, List.self, Update.self, + SelfUpdate.self, ] ) diff --git a/Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift b/Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift index 3c82d9da..ab2a84de 100644 --- a/Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift +++ b/Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift @@ -2,10 +2,14 @@ import _StringProcessing import AsyncHTTPClient import Foundation +public struct SwiftlyGitHubRelease: Codable { + public let tag: String +} + extension SwiftlyHTTPClient { /// Get a JSON response from the GitHub REST API. /// This will use the authorization token set, if any. - private func getFromGitHub(url: String) async throws -> T { + public func getFromGitHub(url: String) async throws -> T { var headers: [String: String] = [:] if let token = self.githubToken ?? ProcessInfo.processInfo.environment["SWIFTLY_GITHUB_TOKEN"] { headers["Authorization"] = "Bearer \(token)" diff --git a/Sources/SwiftlyCore/HTTPClient.swift b/Sources/SwiftlyCore/HTTPClient.swift index cdf79822..066f4b49 100644 --- a/Sources/SwiftlyCore/HTTPClient.swift +++ b/Sources/SwiftlyCore/HTTPClient.swift @@ -5,91 +5,49 @@ import NIO import NIOFoundationCompat import NIOHTTP1 -/// Protocol describing the behavior for downloading a tooclhain. -/// This is used to abstract over the underlying HTTP client to allow for mocking downloads in tests. -public protocol ToolchainDownloader { - func downloadToolchain( - _ toolchain: ToolchainVersion, - url: String, - to destination: String, - reportProgress: @escaping (SwiftlyHTTPClient.DownloadProgress) -> Void - ) async throws +public protocol HTTPRequestExecutor { + func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse } -/// The default implementation of a toolchain downloader. -/// Downloads toolchains from swift.org. -private struct HTTPToolchainDownloader: ToolchainDownloader { - func downloadToolchain( - _: ToolchainVersion, - url: String, - to destination: String, - reportProgress: @escaping (SwiftlyHTTPClient.DownloadProgress) -> Void - ) async throws { - let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination)) - defer { - try? fileHandle.close() - } - - let request = SwiftlyHTTPClient.client.makeRequest(url: url) - let response = try await SwiftlyHTTPClient.client.inner.execute(request, timeout: .seconds(30)) - - guard case response.status = HTTPResponseStatus.ok else { - throw Error(message: "Received \(response.status) when trying to download \(url)") - } - - // Unknown download.swift.org paths redirect to a 404 page which then returns a 200 status. - // As a heuristic for if we've hit the 404 page, we check to see if the content is HTML. - guard !response.headers["Content-Type"].contains(where: { $0.contains("text/html") }) else { - throw SwiftlyHTTPClient.DownloadNotFoundError(url: url) - } - - // if defined, the content-length headers announces the size of the body - let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init) - - var receivedBytes = 0 - for try await buffer in response.body { - receivedBytes += buffer.readableBytes - - try buffer.withUnsafeReadableBytes { bufferPtr in - try fileHandle.write(contentsOf: bufferPtr) - } - reportProgress(SwiftlyHTTPClient.DownloadProgress( - receivedBytes: receivedBytes, - totalBytes: expectedBytes - ) - ) - } +/// An `HTTPRequestExecutor` backed by an `HTTPClient`. +internal struct HTTPRequestExecutorImpl: HTTPRequestExecutor { + fileprivate static let client = HTTPClientWrapper() - try fileHandle.synchronize() + public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse { + try await Self.client.inner.execute(request, timeout: timeout) } } +private func makeRequest(url: String) -> HTTPClientRequest { + var request = HTTPClientRequest(url: url) + request.headers.add(name: "User-Agent", value: "swiftly/\(SwiftlyCore.version)") + return request +} + /// HTTPClient wrapper used for interfacing with various REST APIs and downloading things. public struct SwiftlyHTTPClient { - fileprivate static let client = HTTPClientWrapper() - private struct Response { let status: HTTPResponseStatus let buffer: ByteBuffer } - private let downloader: ToolchainDownloader + private let executor: HTTPRequestExecutor /// The GitHub authentication token to use for any requests made to the GitHub API. public var githubToken: String? - public init(toolchainDownloader: ToolchainDownloader? = nil) { - self.downloader = toolchainDownloader ?? HTTPToolchainDownloader() + public init(executor: HTTPRequestExecutor? = nil) { + self.executor = executor ?? HTTPRequestExecutorImpl() } private func get(url: String, headers: [String: String]) async throws -> Response { - var request = Self.client.makeRequest(url: url) + var request = makeRequest(url: url) for (k, v) in headers { request.headers.add(name: k, value: v) } - let response = try await Self.client.inner.execute(request, timeout: .seconds(30)) + let response = try await self.executor.execute(request, timeout: .seconds(30)) // if defined, the content-length headers announces the size of the body let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init) ?? 1024 * 1024 @@ -179,30 +137,53 @@ public struct SwiftlyHTTPClient { public let url: String } - public func downloadToolchain( - _ toolchain: ToolchainVersion, - url: String, - to destination: String, - reportProgress: @escaping (DownloadProgress) -> Void - ) async throws { - try await self.downloader.downloadToolchain( - toolchain, - url: url, - to: destination, - reportProgress: reportProgress - ) + public func downloadFile(url: URL, to destination: URL, reportProgress: @escaping (DownloadProgress) -> Void) async throws { + let fileHandle = try FileHandle(forWritingTo: destination) + defer { + try? fileHandle.close() + } + + let request = makeRequest(url: url.absoluteString) + let response = try await self.executor.execute(request, timeout: .seconds(30)) + + switch response.status { + case .ok: + break + case .notFound: + throw SwiftlyHTTPClient.DownloadNotFoundError(url: url.path) + default: + throw Error(message: "Received \(response.status) when trying to download \(url)") + } + + // if defined, the content-length headers announces the size of the body + let expectedBytes = response.headers.first(name: "content-length").flatMap(Int.init) + + var lastUpdate = Date() + var receivedBytes = 0 + for try await buffer in response.body { + receivedBytes += buffer.readableBytes + + try buffer.withUnsafeReadableBytes { bufferPtr in + try fileHandle.write(contentsOf: bufferPtr) + } + + let now = Date() + if lastUpdate.distance(to: now) > 0.25 || receivedBytes == expectedBytes { + lastUpdate = now + reportProgress(SwiftlyHTTPClient.DownloadProgress( + receivedBytes: receivedBytes, + totalBytes: expectedBytes + )) + } + } + + try fileHandle.synchronize() } } private class HTTPClientWrapper { fileprivate let inner = HTTPClient(eventLoopGroupProvider: .singleton) - fileprivate func makeRequest(url: String) -> HTTPClientRequest { - var request = HTTPClientRequest(url: url) - request.headers.add(name: "User-Agent", value: "swiftly") - return request - } - deinit { try? self.inner.syncShutdown() } diff --git a/Sources/SwiftlyCore/Platform.swift b/Sources/SwiftlyCore/Platform.swift index 1c4ee2dd..d5260b0c 100644 --- a/Sources/SwiftlyCore/Platform.swift +++ b/Sources/SwiftlyCore/Platform.swift @@ -33,9 +33,8 @@ public protocol Platform { /// This will likely have a default implementation. func listAvailableSnapshots(version: String?) async -> [Snapshot] - /// Update swiftly itself, if a new version has been released. - /// This will likely have a default implementation. - func selfUpdate() async throws + /// Get the name of the release binary for this platform with the given CPU arch. + func getExecutableName(forArch: String) -> String /// Get a path pointing to a unique, temporary file. /// This does not need to actually create the file. diff --git a/Sources/SwiftlyCore/SwiftlyCore.swift b/Sources/SwiftlyCore/SwiftlyCore.swift index 8f5d2551..b683ed0c 100644 --- a/Sources/SwiftlyCore/SwiftlyCore.swift +++ b/Sources/SwiftlyCore/SwiftlyCore.swift @@ -1,5 +1,7 @@ import Foundation +public let version = SwiftlyVersion(major: 0, minor: 1, patch: 0) + /// A separate home directory to use for testing purposes. This overrides swiftly's default /// home directory location logic. public var mockedHomeDir: URL? diff --git a/Sources/SwiftlyCore/SwiftlyVersion.swift b/Sources/SwiftlyCore/SwiftlyVersion.swift new file mode 100644 index 00000000..15c27ad9 --- /dev/null +++ b/Sources/SwiftlyCore/SwiftlyVersion.swift @@ -0,0 +1,61 @@ +import _StringProcessing +import Foundation + +/// Struct modeling a version of swiftly itself. +public struct SwiftlyVersion: Equatable, Comparable, CustomStringConvertible { + /// Regex matching versions like "a.b.c", "a.b.c-alpha", and "a.b.c-alpha2". + static let regex: Regex<(Substring, Substring, Substring, Substring, Substring?)> = + try! Regex("^(\\d+)\\.(\\d+)\\.(\\d+)(?:-([a-zA-Z0-9]+))?$") + + public let major: Int + public let minor: Int + public let patch: Int + public let suffix: String? + + public init(major: Int, minor: Int, patch: Int, suffix: String? = nil) { + self.major = major + self.minor = minor + self.patch = patch + self.suffix = suffix + } + + public init(parsing tag: String) throws { + guard let match = try Self.regex.wholeMatch(in: tag) else { + throw Error(message: "unable to parse release tag: \"\(tag)\"") + } + + self.major = Int(match.output.1)! + self.minor = Int(match.output.2)! + self.patch = Int(match.output.3)! + self.suffix = match.output.4.flatMap(String.init) + } + + public static func < (lhs: Self, rhs: Self) -> Bool { + if lhs.major != rhs.major { + return lhs.major < rhs.major + } else if lhs.minor != rhs.minor { + return lhs.minor < rhs.minor + } else if lhs.patch != rhs.patch { + return lhs.patch < rhs.patch + } else { + switch (lhs.suffix, rhs.suffix) { + case (.none, .some): + return false + case (.some, .none): + return true + case let (.some(lhsSuffix), .some(rhsSuffix)): + return lhsSuffix < rhsSuffix + case (.none, .none): + return false + } + } + } + + public var description: String { + var base = "\(self.major).\(self.minor).\(self.patch)" + if let suffix = self.suffix { + base += "-\(suffix)" + } + return base + } +} diff --git a/Tests/SwiftlyTests/SelfUpdateTests.swift b/Tests/SwiftlyTests/SelfUpdateTests.swift new file mode 100644 index 00000000..18f82a8e --- /dev/null +++ b/Tests/SwiftlyTests/SelfUpdateTests.swift @@ -0,0 +1,72 @@ +import AsyncHTTPClient +import Foundation +import NIO +@testable import Swiftly +@testable import SwiftlyCore +import XCTest + +final class SelfUpdateTests: SwiftlyTests { + private static var newMajorVersion: String { + "\(SwiftlyCore.version.major + 1).0.0" + } + + private static var newMinorVersion: String { + "\(SwiftlyCore.version.major).\(SwiftlyCore.version.minor + 1).0" + } + + private static var newPatchVersion: String { + "\(SwiftlyCore.version.major).\(SwiftlyCore.version.minor).\(SwiftlyCore.version.patch + 1)" + } + + private static func makeMockHTTPClient(latestVersion: String) -> SwiftlyHTTPClient { + .mocked { request in + guard let url = URL(string: request.url) else { + throw SwiftlyTestError(message: "invalid url \(request.url)") + } + + switch url.host { + case "api.github.com": + let nextRelease = SwiftlyGitHubRelease(tag: latestVersion) + var buffer = ByteBuffer() + try buffer.writeJSONEncodable(nextRelease) + return HTTPClientResponse(body: .bytes(buffer)) + case "github.com": + var buffer = ByteBuffer() + buffer.writeString(latestVersion) + return HTTPClientResponse(body: .bytes(buffer)) + default: + throw SwiftlyTestError(message: "unknown url host: \(String(describing: url.host))") + } + } + } + + func runSelfUpdateTest(latestVersion: String, shouldUpdate: Bool = true) async throws { + try await self.withTestHome { + let swiftlyURL = Swiftly.currentPlatform.swiftlyBinDir.appendingPathComponent("swiftly", isDirectory: false) + try "old".data(using: .utf8)!.write(to: swiftlyURL) + + var update = try self.parseCommand(SelfUpdate.self, ["self-update"]) + update.httpClient = Self.makeMockHTTPClient(latestVersion: latestVersion) + try await update.run() + + let swiftly = try Data(contentsOf: swiftlyURL) + + if shouldUpdate { + XCTAssertEqual(String(data: swiftly, encoding: .utf8), latestVersion) + } else { + XCTAssertEqual(String(data: swiftly, encoding: .utf8), "old") + } + } + } + + /// Verify updating the most up-to-date toolchain has no effect. + func testSelfUpdate() async throws { + try await self.runSelfUpdateTest(latestVersion: Self.newPatchVersion) + try await self.runSelfUpdateTest(latestVersion: Self.newMinorVersion) + try await self.runSelfUpdateTest(latestVersion: Self.newMajorVersion) + } + + func testSelfUpdateAlreadyUpToDate() async throws { + try await self.runSelfUpdateTest(latestVersion: String(describing: SwiftlyCore.version), shouldUpdate: false) + } +} diff --git a/Tests/SwiftlyTests/SwiftlyTests.swift b/Tests/SwiftlyTests/SwiftlyTests.swift index 605a5631..822091ec 100644 --- a/Tests/SwiftlyTests/SwiftlyTests.swift +++ b/Tests/SwiftlyTests/SwiftlyTests.swift @@ -1,5 +1,7 @@ import _StringProcessing import ArgumentParser +import AsyncHTTPClient +import NIO @testable import Swiftly @testable import SwiftlyCore import XCTest @@ -79,12 +81,11 @@ class SwiftlyTests: XCTestCase { SwiftlyCore.mockedHomeDir = testHome defer { SwiftlyCore.mockedHomeDir = nil + try? testHome.deleteIfExists() } - - try testHome.deleteIfExists() - try FileManager.default.createDirectory(at: testHome, withIntermediateDirectories: false) - defer { - try? FileManager.default.removeItem(at: testHome) + for dir in Swiftly.requiredDirectories { + try dir.deleteIfExists() + try FileManager.default.createDirectory(at: dir, withIntermediateDirectories: false) } let config = try self.baseTestConfig() @@ -174,7 +175,7 @@ class SwiftlyTests: XCTestCase { /// When executed, the mocked executables will simply print the toolchain version and return. func installMockedToolchain(selector: String, args: [String] = [], executables: [String]? = nil) async throws { var install = try self.parseCommand(Install.self, ["install", "\(selector)"] + args) - install.httpClient = SwiftlyHTTPClient(toolchainDownloader: MockToolchainDownloader(executables: executables)) + install.httpClient = SwiftlyHTTPClient(executor: MockToolchainDownloader(executables: executables)) try await install.run() } @@ -347,28 +348,75 @@ public struct SwiftExecutable { } } -public struct MockToolchainDownloader: ToolchainDownloader { +/// An `HTTPRequestExecutor` that responds to all HTTP requests by invoking the provided closure. +private struct MockHTTPRequestExecutor: HTTPRequestExecutor { + private let handler: (HTTPClientRequest) async throws -> HTTPClientResponse + + public init(handler: @escaping (HTTPClientRequest) async throws -> HTTPClientResponse) { + self.handler = handler + } + + public func execute(_ request: HTTPClientRequest, timeout _: TimeAmount) async throws -> HTTPClientResponse { + try await self.handler(request) + } +} + +extension SwiftlyHTTPClient { + public static func mocked(_ handler: @escaping (HTTPClientRequest) async throws -> HTTPClientResponse) -> Self { + Self(executor: MockHTTPRequestExecutor(handler: handler)) + } +} + +/// An `HTTPRequestExecutor` which will return a mocked response to any toolchain download requests. +/// All other requests are performed using an actual HTTP client. +public struct MockToolchainDownloader: HTTPRequestExecutor { + private static let releaseURLRegex: Regex<(Substring, Substring, Substring, Substring?)> = + try! Regex("swift-(\\d+)\\.(\\d+)(?:\\.(\\d+))?-RELEASE") + private static let snapshotURLRegex: Regex = + try! Regex("swift(?:-[0-9]+\\.[0-9]+)?-DEVELOPMENT-SNAPSHOT-[0-9]{4}-[0-9]{2}-[0-9]{2}") + private let executables: [String] + private let httpRequestExecutor: HTTPRequestExecutor public init(executables: [String]? = nil) { self.executables = executables ?? ["swift"] + self.httpRequestExecutor = HTTPRequestExecutorImpl() } - public func downloadToolchain( - _ toolchain: ToolchainVersion, - url _: String, - to destination: String, - reportProgress: @escaping (SwiftlyHTTPClient.DownloadProgress) -> Void - ) async throws { - let fileHandle = try FileHandle(forWritingTo: URL(fileURLWithPath: destination)) - defer { - try? fileHandle.close() + public func execute(_ request: HTTPClientRequest, timeout: TimeAmount) async throws -> HTTPClientResponse { + guard let url = URL(string: request.url) else { + throw SwiftlyTestError(message: "invalid request URL: \(request.url)") + } + + if url.host == "download.swift.org" { + return try self.makeToolchainDownloadResponse(from: url) + } else { + return try await self.httpRequestExecutor.execute(request, timeout: timeout) + } + } + + private func makeToolchainDownloadResponse(from url: URL) throws -> HTTPClientResponse { + let toolchain: ToolchainVersion + if let match = try Self.releaseURLRegex.firstMatch(in: url.path) { + var version = "\(match.output.1).\(match.output.2)." + if let patch = match.output.3 { + version += patch + } else { + version += "0" + } + toolchain = try ToolchainVersion(parsing: version) + } else if let match = try Self.snapshotURLRegex.firstMatch(in: url.path) { + let selector = try ToolchainSelector(parsing: String(match.output)) + guard case let .snapshot(branch, date) = selector else { + throw SwiftlyTestError(message: "unexpected selector: \(selector)") + } + toolchain = .init(snapshotBranch: branch, date: date!) + } else { + throw SwiftlyTestError(message: "invalid toolchain download URL: \(url.path)") } - let data = try self.makeMockedToolchain(toolchain: toolchain) - try fileHandle.write(contentsOf: data) - reportProgress(SwiftlyHTTPClient.DownloadProgress(receivedBytes: data.count, totalBytes: data.count)) - try fileHandle.synchronize() + let mockedToolchain = try self.makeMockedToolchain(toolchain: toolchain) + return HTTPClientResponse(body: .bytes(ByteBuffer(data: mockedToolchain))) } func makeMockedToolchain(toolchain: ToolchainVersion) throws -> Data { diff --git a/Tests/SwiftlyTests/UpdateTests.swift b/Tests/SwiftlyTests/UpdateTests.swift index bc34b70c..6a08754e 100644 --- a/Tests/SwiftlyTests/UpdateTests.swift +++ b/Tests/SwiftlyTests/UpdateTests.swift @@ -4,7 +4,7 @@ import Foundation import XCTest final class UpdateTests: SwiftlyTests { - private let mockHttpClient = SwiftlyHTTPClient(toolchainDownloader: MockToolchainDownloader()) + private let mockHttpClient = SwiftlyHTTPClient(executor: MockToolchainDownloader()) /// Verify updating the most up-to-date toolchain has no effect. func testUpdateLatest() async throws {