Skip to content

Commit c192812

Browse files
authored
Mock out test interactions with swift.org and github.com (#133)
Mock out test interactions with swift.org and github.com The unit tests currently perform numerous requests against these websites, which causes problems. First, GitHub API's are rate limited, which is often encountered while running the tests both in CI and at desk since not all environments are prepared to manage their own app/dev token. Downloads from swift.org, usually toolchains, are large, take a significant time for each to download in aggregate during testing. Ultimately, network interactions can fail due to network conditions outside of the control of the tests. There is already the infrastructure in the tests to mock the downloading of toolchains by locally constructing them with executable shell scripts in place of the real tools. In the case of macOS there is also logic to assemble the .pkg file. Use this mock for the majority of the unit tests. Create a small set of integration and e2e tests that do make real network requests. Create static files that represent a mocked snapshot of the GitHub API's response JSON and use those when using the mock toolchain downloader during the automated tests. Add these as resources to the test target so that they can be loaded at the runtime of the tests. In order to decrease the exposure to network issues related to the Ubuntu keyserver in the automated tests add a latch so that the GPG keys are only refreshed once per session of the swiftly executable. Since swiftly operates normally as a short-lived process this should not affect the real-world usage of it, but it makes a big improvement in both the speed and reliability of the automated tests. It's very unlikely that in the span of a test run that the swift keys will need to be refreshed.
1 parent bcfd843 commit c192812

40 files changed

+17252
-310
lines changed

Package.swift

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,8 @@
22

33
import PackageDescription
44

5+
let ghApiCacheResources = (1...16).map { Resource.embedInCode("gh-api-cache/swift-tags-page\($0).json") }
6+
57
let package = Package(
68
name: "swiftly",
79
platforms: [
@@ -62,7 +64,11 @@ let package = Package(
6264
),
6365
.testTarget(
6466
name: "SwiftlyTests",
65-
dependencies: ["Swiftly"]
67+
dependencies: ["Swiftly"],
68+
resources: ghApiCacheResources + [
69+
.embedInCode("gh-api-cache/swift-releases-page1.json"),
70+
.embedInCode("mock-signing-key-private.pgp"),
71+
]
6672
),
6773
]
6874
)

Sources/LinuxPlatform/Linux.swift

Lines changed: 22 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
import Foundation
22
import SwiftlyCore
33

4+
var swiftGPGKeysRefreshed = false
5+
46
/// `Platform` implementation for Linux systems.
57
/// This implementation can be reused for any supported Linux platform.
68
/// TODO: replace dummy implementations
@@ -65,18 +67,22 @@ public struct Linux: Platform {
6567
Self.skipVerificationMessage)
6668
}
6769

68-
SwiftlyCore.print("Refreshing Swift PGP keys...")
69-
do {
70-
try self.runProgram(
71-
"gpg",
72-
"--quiet",
73-
"--keyserver",
74-
"hkp://keyserver.ubuntu.com",
75-
"--refresh-keys",
76-
"Swift"
77-
)
78-
} catch {
79-
throw Error(message: "Failed to refresh PGP keys: \(error)")
70+
// We only need to refresh the keys once per session, which will help with performance in tests
71+
if !swiftGPGKeysRefreshed {
72+
SwiftlyCore.print("Refreshing Swift PGP keys...")
73+
do {
74+
try self.runProgram(
75+
"gpg",
76+
"--quiet",
77+
"--keyserver",
78+
"hkp://keyserver.ubuntu.com",
79+
"--refresh-keys",
80+
"Swift"
81+
)
82+
} catch {
83+
throw Error(message: "Failed to refresh PGP keys: \(error)")
84+
}
85+
swiftGPGKeysRefreshed = true
8086
}
8187
}
8288

@@ -116,6 +122,10 @@ public struct Linux: Platform {
116122
.appendingPathComponent("usr", isDirectory: true)
117123
.appendingPathComponent("bin", isDirectory: true)
118124

125+
if !FileManager.default.fileExists(atPath: toolchainBinURL.path) {
126+
return false
127+
}
128+
119129
// Delete existing symlinks from previously in-use toolchain.
120130
if let currentToolchain {
121131
try self.unUse(currentToolchain: currentToolchain)

Sources/MacOSPlatform/MacOS.swift

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,10 @@ public struct MacOS: Platform {
102102
.appendingPathComponent("usr", isDirectory: true)
103103
.appendingPathComponent("bin", isDirectory: true)
104104

105+
if !FileManager.default.fileExists(atPath: toolchainBinURL.path) {
106+
return false
107+
}
108+
105109
// Delete existing symlinks from previously in-use toolchain.
106110
if let currentToolchain {
107111
try self.unUse(currentToolchain: currentToolchain)

Sources/SwiftlyCore/HTTPClient+GitHubAPI.swift

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ extension SwiftlyHTTPClient {
6262
/// page number.
6363
///
6464
/// The results are returned in lexicographic order.
65-
internal func getReleases(page: Int, perPage: Int = 100) async throws -> [GitHubTag] {
65+
public func getReleases(page: Int, perPage: Int = 100) async throws -> [GitHubTag] {
6666
let url = "https://api.github.com/repos/apple/swift/releases?per_page=\(perPage)&page=\(page)"
6767
let releases: [GitHubRelease] = try await self.getFromGitHub(url: url)
6868
return releases.filter { !$0.prerelease }.map { $0.toGitHubTag() }
@@ -72,15 +72,15 @@ extension SwiftlyHTTPClient {
7272
/// The tags are returned in pages of 100. The page argument specifies the page number.
7373
///
7474
/// The results are returned in lexicographic order.
75-
internal func getTags(page: Int) async throws -> [GitHubTag] {
75+
public func getTags(page: Int) async throws -> [GitHubTag] {
7676
let url = "https://api.github.com/repos/apple/swift/tags?per_page=100&page=\(page)"
7777
return try await self.getFromGitHub(url: url)
7878
}
7979
}
8080

8181
/// Model of a GitHub REST API release object.
8282
/// See: https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#list-releases
83-
private struct GitHubRelease: Decodable {
83+
public struct GitHubRelease: Decodable {
8484
fileprivate let name: String
8585
fileprivate let prerelease: Bool
8686

@@ -90,7 +90,7 @@ private struct GitHubRelease: Decodable {
9090
}
9191

9292
/// Model of a GitHub REST API tag/release object.
93-
internal struct GitHubTag: Decodable {
93+
public struct GitHubTag: Decodable {
9494
internal struct Commit: Decodable {
9595
internal let sha: String
9696
}

Sources/SwiftlyCore/Platform.swift

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,7 +21,7 @@ public protocol Platform {
2121
var toolchainFileExtension: String { get }
2222

2323
/// Checks whether a given system dependency has been installed yet or not.
24-
/// This will only really be used on Linux.
24+
/// This will only really used on Linux.
2525
func isSystemDependencyPresent(_ dependency: SystemDependency) -> Bool
2626

2727
/// Installs a toolchain from a file on disk pointed to by the given URL.

Tests/SwiftlyTests/E2ETests.swift

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import Foundation
2+
@testable import Swiftly
3+
@testable import SwiftlyCore
4+
import XCTest
5+
6+
final class E2ETests: SwiftlyTests {
7+
/// Tests that `swiftly install latest` successfully installs the latest stable release of Swift end-to-end.
8+
///
9+
/// This will modify the user's system, but will undo those changes afterwards.
10+
func testInstallLatest() async throws {
11+
try await self.rollbackLocalChanges {
12+
var cmd = try self.parseCommand(Install.self, ["install", "latest"])
13+
try await cmd.run()
14+
15+
let config = try Config.load()
16+
17+
guard !config.installedToolchains.isEmpty else {
18+
XCTFail("expected to install latest main snapshot toolchain but installed toolchains is empty in the config")
19+
return
20+
}
21+
22+
let installedToolchain = config.installedToolchains.first!
23+
24+
guard case let .stable(release) = installedToolchain else {
25+
XCTFail("expected swiftly install latest to insall release toolchain but got \(installedToolchain)")
26+
return
27+
}
28+
29+
// As of writing this, 5.8.0 is the latest stable release. Assert it is at least that new.
30+
XCTAssertTrue(release >= ToolchainVersion.StableRelease(major: 5, minor: 8, patch: 0))
31+
32+
try await validateInstalledToolchains([installedToolchain], description: "install latest")
33+
}
34+
}
35+
}
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
@testable import Swiftly
2+
@testable import SwiftlyCore
3+
import XCTest
4+
5+
final class HTTPClientTests: SwiftlyTests {
6+
func testGet() async throws {
7+
// GIVEN: we have a swiftly http client
8+
// WHEN: we make get request for a particular type of JSON
9+
var releases: [GitHubRelease] = try await SwiftlyCore.httpClient.getFromJSON(
10+
url: "https://api.github.com/repos/apple/swift/releases?per_page=100&page=1",
11+
type: [GitHubRelease].self,
12+
headers: [:]
13+
)
14+
// THEN: we get a decoded JSON response
15+
XCTAssertTrue(releases.count > 0)
16+
17+
// GIVEN: we have a swiftly http client
18+
// WHEN: we make a request to an invalid URL path
19+
var exceptionThrown = false
20+
do {
21+
releases = try await SwiftlyCore.httpClient.getFromJSON(
22+
url: "https://api.github.com/repos/apple/swift/releases2",
23+
type: [GitHubRelease].self,
24+
headers: [:]
25+
)
26+
} catch {
27+
exceptionThrown = true
28+
}
29+
// THEN: we receive an exception
30+
XCTAssertTrue(exceptionThrown)
31+
32+
// GIVEN: we have a swiftly http client
33+
// WHEN: we make a request to an invalid host path
34+
exceptionThrown = false
35+
do {
36+
releases = try await SwiftlyCore.httpClient.getFromJSON(
37+
url: "https://inavlid.github.com/repos/apple/swift/releases",
38+
type: [GitHubRelease].self,
39+
headers: [:]
40+
)
41+
} catch {
42+
exceptionThrown = true
43+
}
44+
// THEN: we receive an exception
45+
XCTAssertTrue(exceptionThrown)
46+
}
47+
48+
func testGetFromGitHub() async throws {
49+
// GIVEN: we have a swiftly http client with github capability
50+
// WHEN: we ask for the first page of releases with page size 5
51+
var releases = try await SwiftlyCore.httpClient.getReleases(page: 1, perPage: 5)
52+
// THEN: we get five releases
53+
XCTAssertEqual(5, releases.count)
54+
55+
let firstRelease = releases[0]
56+
57+
// GIVEN: we have a swiftly http client with github capability
58+
// WHEN: we ask for the second page of releases with page size 5
59+
releases = try await SwiftlyCore.httpClient.getReleases(page: 2, perPage: 5)
60+
// THEN: we get five different releases
61+
XCTAssertEqual(5, releases.count)
62+
XCTAssertTrue(releases[0].name != firstRelease.name)
63+
64+
// GIVEN: we have a swiftly http client with github capability
65+
// WHEN: we ask for the first page of tags
66+
var tags = try await SwiftlyCore.httpClient.getTags(page: 1)
67+
// THEN: we get a collection of tags
68+
XCTAssertTrue(tags.count > 0)
69+
70+
let firstTag = tags[0]
71+
72+
// GIVEN: we have a swiftly http client with github capability
73+
// WHEN: we ask for the second page of tags
74+
tags = try await SwiftlyCore.httpClient.getTags(page: 2)
75+
// THEN: we get a different collection of tags
76+
XCTAssertTrue(tags.count > 0)
77+
XCTAssertTrue(tags[0].name != firstTag.name)
78+
}
79+
}

0 commit comments

Comments
 (0)