Skip to content

Commit fa0f734

Browse files
authored
Add initial Swift wrapper for test server (#61)
* Add initial Swift wrapper for test server * Add license headers * Move Package.swift to root
1 parent 69391ec commit fa0f734

5 files changed

Lines changed: 382 additions & 0 deletions

File tree

.gitignore

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,3 +39,17 @@ sdks/python/src/test_server_sdk.egg-info
3939
# Python SDK Sample specific
4040
sdks/python/sample/__pycache__
4141
sdks/python/sample/.pytest_cache
42+
43+
# Swift / Package Manager
44+
.build/
45+
Packages/
46+
.swiftpm/
47+
48+
# Xcode
49+
build/
50+
DerivedData/
51+
*.xcodeproj/
52+
xcuserdata/
53+
54+
# Credentials
55+
.netrc

Package.swift

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
// swift-tools-version: 6.1
2+
// The swift-tools-version declares the minimum version of Swift required to build this package.
3+
4+
// Copyright 2026 Google LLC
5+
//
6+
// Licensed under the Apache License, Version 2.0 (the "License");
7+
// you may not use this file except in compliance with the License.
8+
// You may obtain a copy of the License at
9+
//
10+
// http://www.apache.org/licenses/LICENSE-2.0
11+
//
12+
// Unless required by applicable law or agreed to in writing, software
13+
// distributed under the License is distributed on an "AS IS" BASIS,
14+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
15+
// See the License for the specific language governing permissions and
16+
// limitations under the License.
17+
18+
import PackageDescription
19+
20+
let package = Package(
21+
name: "TestServer",
22+
platforms: [
23+
.macOS(.v12) // Matches your current toolchain requirement
24+
],
25+
products: [
26+
.library(
27+
name: "TestServer",
28+
targets: ["TestServer"]
29+
),
30+
],
31+
targets: [
32+
.target(
33+
name: "TestServer",
34+
dependencies: [],
35+
// Point to the subdirectory containing your wrapper code
36+
path: "sdks/swift/Sources/TestServer"
37+
),
38+
.testTarget(
39+
name: "TestServerTests",
40+
dependencies: ["TestServer"],
41+
// Point to the subdirectory containing your verification tests
42+
path: "sdks/swift/Tests/TestServerTests"
43+
),
44+
]
45+
)
Lines changed: 120 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,120 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
enum BinaryInstallerError: Error {
18+
case platformNotSupported
19+
case downloadFailed(Error)
20+
case extractionFailed
21+
case fileSystemError(Error)
22+
}
23+
24+
/// Ensures the `test-server` binary is available on the local machine.
25+
struct BinaryInstaller {
26+
private static let githubOwner = "google"
27+
private static let githubRepo = "test-server"
28+
private static let projectName = "test-server"
29+
static let testServerVersion = "v0.2.9"
30+
31+
static func ensureBinary(at outputDirectory: URL, version: String = testServerVersion) async throws -> URL {
32+
let (os, arch, ext) = try getPlatformDetails()
33+
let archiveName = "\(projectName)_\(os)_\(arch)\(ext)"
34+
35+
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
36+
37+
let binaryName = projectName
38+
let finalBinaryURL = outputDirectory.appendingPathComponent(binaryName)
39+
40+
// Check if binary already exists
41+
if FileManager.default.fileExists(atPath: finalBinaryURL.path) {
42+
print("[SDK] \(projectName) binary already exists at \(finalBinaryURL.path).")
43+
return finalBinaryURL
44+
}
45+
46+
let downloadURLString = "https://github.com/\(githubOwner)/\(githubRepo)/releases/download/\(version)/\(archiveName)"
47+
guard let downloadURL = URL(string: downloadURLString) else {
48+
throw BinaryInstallerError.platformNotSupported
49+
}
50+
51+
print("[SDK] Downloading \(downloadURLString)...")
52+
53+
let (tempLocalURL, response) = try await URLSession.shared.download(from: downloadURL)
54+
55+
guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
56+
throw BinaryInstallerError.downloadFailed(NSError(domain: "Download", code: -1, userInfo: nil))
57+
}
58+
59+
// Move to a temporary location with correct extension for extraction
60+
let tempArchiveURL = outputDirectory.appendingPathComponent(archiveName)
61+
try? FileManager.default.removeItem(at: tempArchiveURL)
62+
try FileManager.default.moveItem(at: tempLocalURL, to: tempArchiveURL)
63+
64+
defer {
65+
try? FileManager.default.removeItem(at: tempArchiveURL)
66+
}
67+
68+
print("[SDK] Extracting to \(outputDirectory.path)...")
69+
try extract(archive: tempArchiveURL, to: outputDirectory, extension: ext)
70+
71+
try setExecutablePermissions(at: finalBinaryURL)
72+
73+
print("[SDK] Ready at \(finalBinaryURL.path)")
74+
return finalBinaryURL
75+
}
76+
77+
private static func getPlatformDetails() throws -> (os: String, arch: String, ext: String) {
78+
#if os(macOS)
79+
let os = "Darwin"
80+
let ext = ".tar.gz"
81+
#elseif os(Linux)
82+
let os = "Linux"
83+
let ext = ".tar.gz"
84+
#else
85+
throw BinaryInstallerError.platformNotSupported
86+
#endif
87+
88+
#if arch(x86_64)
89+
let arch = "x86_64"
90+
#elseif arch(arm64)
91+
let arch = "arm64"
92+
#else
93+
throw BinaryInstallerError.platformNotSupported
94+
#endif
95+
96+
return (os, arch, ext)
97+
}
98+
99+
private static func extract(archive: URL, to directory: URL, extension ext: String) throws {
100+
let process = Process()
101+
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
102+
process.arguments = ["-xzf", archive.path, "-C", directory.path]
103+
104+
try process.run()
105+
process.waitUntilExit()
106+
107+
if process.terminationStatus != 0 {
108+
throw BinaryInstallerError.extractionFailed
109+
}
110+
}
111+
112+
private static func setExecutablePermissions(at url: URL) throws {
113+
let process = Process()
114+
process.executableURL = URL(fileURLWithPath: "/bin/chmod")
115+
process.arguments = ["+x", url.path]
116+
117+
try process.run()
118+
process.waitUntilExit()
119+
}
120+
}
Lines changed: 146 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,146 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import Foundation
16+
17+
struct TestServerOptions {
18+
let configPath: String
19+
let recordingDir: String
20+
let mode: String // "record" or "replay"
21+
let binaryPath: String
22+
let testServerSecrets: String?
23+
}
24+
25+
class TestServer {
26+
private var process: Process?
27+
private let options: TestServerOptions
28+
29+
init(options: TestServerOptions) {
30+
self.options = options
31+
}
32+
33+
func start() async throws {
34+
let binaryURL: URL
35+
let fileManager = FileManager.default
36+
37+
if fileManager.fileExists(atPath: options.binaryPath) {
38+
binaryURL = URL(fileURLWithPath: options.binaryPath)
39+
} else {
40+
let targetDir = URL(fileURLWithPath: options.binaryPath).deletingLastPathComponent()
41+
print("[TestServerSdk] Installing binary to \(targetDir.path)...")
42+
binaryURL = try await BinaryInstaller.ensureBinary(at: targetDir)
43+
}
44+
45+
let arguments = [
46+
options.mode,
47+
"--config", options.configPath,
48+
"--recording-dir", options.recordingDir
49+
]
50+
51+
let process = Process()
52+
process.executableURL = binaryURL
53+
process.arguments = arguments
54+
55+
if let secrets = options.testServerSecrets {
56+
var env = ProcessInfo.processInfo.environment
57+
env["TEST_SERVER_SECRETS"] = secrets
58+
process.environment = env
59+
}
60+
61+
let pipe = Pipe()
62+
process.standardOutput = pipe
63+
process.standardError = pipe
64+
65+
pipe.fileHandleForReading.readabilityHandler = { handle in
66+
if let data = try? handle.read(upToCount: handle.availableData.count),
67+
let str = String(data: data, encoding: .utf8), !str.isEmpty {
68+
print("[TestServer] \(str)", terminator: "")
69+
}
70+
}
71+
72+
try process.run()
73+
self.process = process
74+
75+
try await awaitHealthyTestServer()
76+
}
77+
78+
func stop() {
79+
process?.terminate()
80+
process = nil
81+
}
82+
83+
private func awaitHealthyTestServer() async throws {
84+
let healthURLString = try extractHealthURL(from: options.configPath)
85+
guard let url = URL(string: healthURLString) else {
86+
throw NSError(domain: "TestServer", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid health URL"])
87+
}
88+
89+
print("[TestServer] Waiting for healthy server at \(url)...")
90+
try await checkHealth(url: url)
91+
}
92+
93+
private func extractHealthURL(from configPath: String) throws -> String {
94+
let content = try String(contentsOfFile: configPath, encoding: .utf8)
95+
let fullRange = NSRange(content.startIndex..., in: content)
96+
97+
// Find the first 'source_port', looks for "source_port: 1234"
98+
let portPattern = #"source_port:\s*(\d+)"#
99+
let portRegex = try NSRegularExpression(pattern: portPattern)
100+
let portMatch = portRegex.firstMatch(in: content, range: fullRange)
101+
102+
guard let portRange = portMatch?.range(at: 1),
103+
let portRangeInString = Range(portRange, in: content) else {
104+
print("[TestServer] Warning: Could not parse source_port from config. Defaulting to 9000.")
105+
return "http://localhost:9000/health"
106+
}
107+
let port = String(content[portRangeInString])
108+
109+
var healthPath = "/health" // Default
110+
let healthPattern = #"health:\s*([\w/]+)"#
111+
112+
if let healthRegex = try? NSRegularExpression(pattern: healthPattern),
113+
let healthMatch = healthRegex.firstMatch(in: content, range: fullRange),
114+
let healthRangeInString = Range(healthMatch.range(at: 1), in: content) {
115+
healthPath = String(content[healthRangeInString])
116+
}
117+
118+
return "http://localhost:\(port)\(healthPath)"
119+
}
120+
121+
122+
123+
private func checkHealth(url: URL) async throws {
124+
let session = URLSession.shared
125+
let maxRetries = 20
126+
let delay = 0.5
127+
128+
for _ in 0..<maxRetries {
129+
if let process = process, !process.isRunning {
130+
throw NSError(domain: "TestServer", code: -1,
131+
userInfo: [NSLocalizedDescriptionKey: "Server process died unexpectedly during startup."])
132+
}
133+
134+
do {
135+
let (_, response) = try await session.data(from: url)
136+
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
137+
return
138+
}
139+
} catch { /* retry */ }
140+
141+
try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
142+
}
143+
throw NSError(domain: "TestServer", code: -1, userInfo: [NSLocalizedDescriptionKey: "Health check failed"])
144+
}
145+
146+
}
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
// Copyright 2026 Google LLC
2+
//
3+
// Licensed under the Apache License, Version 2.0 (the "License");
4+
// you may not use this file except in compliance with the License.
5+
// You may obtain a copy of the License at
6+
//
7+
// http://www.apache.org/licenses/LICENSE-2.0
8+
//
9+
// Unless required by applicable law or agreed to in writing, software
10+
// distributed under the License is distributed on an "AS IS" BASIS,
11+
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
12+
// See the License for the specific language governing permissions and
13+
// limitations under the License.
14+
15+
import XCTest
16+
@testable import TestServer
17+
18+
/// Validates the core functionality of the `TestServer` class
19+
final class TestServerTests: XCTestCase {
20+
21+
func testServerLifecycle() async throws {
22+
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("TestServerTests")
23+
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)
24+
25+
let binDir = tempDir.appendingPathComponent("bin")
26+
27+
28+
let recordingsDir = tempDir.appendingPathComponent("recordings")
29+
try FileManager.default.createDirectory(at: recordingsDir, withIntermediateDirectories: true)
30+
31+
let configURL = tempDir.appendingPathComponent("test-server.yml")
32+
33+
let placeholderConfig = """
34+
endpoints:
35+
- source_type: http
36+
source_port: 1453
37+
health: /healthz
38+
"""
39+
try placeholderConfig.write(to: configURL, atomically: true, encoding: .utf8)
40+
41+
let options = TestServerOptions(
42+
configPath: configURL.path,
43+
recordingDir: recordingsDir.path,
44+
mode: "replay",
45+
binaryPath: binDir.appendingPathComponent("test-server").path,
46+
testServerSecrets: nil
47+
)
48+
49+
let server = TestServer(options: options)
50+
51+
try await server.start()
52+
print("✅ Server started and healthy!")
53+
54+
server.stop()
55+
print("🛑 Server stopped.")
56+
}
57+
}

0 commit comments

Comments
 (0)