Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions sdks/swift/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
.DS_Store
/.build
/Packages
xcuserdata/
DerivedData/
.swiftpm/configuration/registries.json
.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata
.netrc
38 changes: 38 additions & 0 deletions sdks/swift/Package.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
// swift-tools-version: 6.1
// The swift-tools-version declares the minimum version of Swift required to build this package.

// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import PackageDescription

let package = Package(
name: "TestServer",
platforms: [
.macOS(.v12)
],
products: [
.library(name: "TestServer", targets: ["TestServer"])
],
targets: [
.target(
name: "TestServer",
dependencies: []
),
.testTarget(
name: "TestServerTests",
dependencies: ["TestServer"]
),
]
)
120 changes: 120 additions & 0 deletions sdks/swift/Sources/TestServer/BinaryInstaller.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation

enum BinaryInstallerError: Error {
case platformNotSupported
case downloadFailed(Error)
case extractionFailed
case fileSystemError(Error)
}

/// Ensures the `test-server` binary is available on the local machine.
struct BinaryInstaller {
private static let githubOwner = "google"
private static let githubRepo = "test-server"
private static let projectName = "test-server"
static let testServerVersion = "v0.2.9"

static func ensureBinary(at outputDirectory: URL, version: String = testServerVersion) async throws -> URL {
let (os, arch, ext) = try getPlatformDetails()
let archiveName = "\(projectName)_\(os)_\(arch)\(ext)"

try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)

let binaryName = projectName
let finalBinaryURL = outputDirectory.appendingPathComponent(binaryName)

// Check if binary already exists
if FileManager.default.fileExists(atPath: finalBinaryURL.path) {
print("[SDK] \(projectName) binary already exists at \(finalBinaryURL.path).")
return finalBinaryURL
}

let downloadURLString = "https://github.com/\(githubOwner)/\(githubRepo)/releases/download/\(version)/\(archiveName)"
guard let downloadURL = URL(string: downloadURLString) else {
throw BinaryInstallerError.platformNotSupported
}

print("[SDK] Downloading \(downloadURLString)...")

let (tempLocalURL, response) = try await URLSession.shared.download(from: downloadURL)

guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else {
throw BinaryInstallerError.downloadFailed(NSError(domain: "Download", code: -1, userInfo: nil))
}

// Move to a temporary location with correct extension for extraction
let tempArchiveURL = outputDirectory.appendingPathComponent(archiveName)
try? FileManager.default.removeItem(at: tempArchiveURL)
try FileManager.default.moveItem(at: tempLocalURL, to: tempArchiveURL)

defer {
try? FileManager.default.removeItem(at: tempArchiveURL)
}

print("[SDK] Extracting to \(outputDirectory.path)...")
try extract(archive: tempArchiveURL, to: outputDirectory, extension: ext)

try setExecutablePermissions(at: finalBinaryURL)

print("[SDK] Ready at \(finalBinaryURL.path)")
return finalBinaryURL
}

private static func getPlatformDetails() throws -> (os: String, arch: String, ext: String) {
#if os(macOS)
let os = "Darwin"
let ext = ".tar.gz"
#elseif os(Linux)
let os = "Linux"
let ext = ".tar.gz"
#else
throw BinaryInstallerError.platformNotSupported
#endif

#if arch(x86_64)
let arch = "x86_64"
#elseif arch(arm64)
let arch = "arm64"
#else
throw BinaryInstallerError.platformNotSupported
#endif

return (os, arch, ext)
}

private static func extract(archive: URL, to directory: URL, extension ext: String) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/usr/bin/tar")
process.arguments = ["-xzf", archive.path, "-C", directory.path]

try process.run()
process.waitUntilExit()

if process.terminationStatus != 0 {
throw BinaryInstallerError.extractionFailed
}
}

private static func setExecutablePermissions(at url: URL) throws {
let process = Process()
process.executableURL = URL(fileURLWithPath: "/bin/chmod")
process.arguments = ["+x", url.path]

try process.run()
process.waitUntilExit()
}
}
146 changes: 146 additions & 0 deletions sdks/swift/Sources/TestServer/TestServer.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,146 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import Foundation

struct TestServerOptions {
let configPath: String
let recordingDir: String
let mode: String // "record" or "replay"
let binaryPath: String
let testServerSecrets: String?
}

class TestServer {
private var process: Process?
private let options: TestServerOptions

init(options: TestServerOptions) {
self.options = options
}

func start() async throws {
let binaryURL: URL
let fileManager = FileManager.default

if fileManager.fileExists(atPath: options.binaryPath) {
binaryURL = URL(fileURLWithPath: options.binaryPath)
} else {
let targetDir = URL(fileURLWithPath: options.binaryPath).deletingLastPathComponent()
print("[TestServerSdk] Installing binary to \(targetDir.path)...")
binaryURL = try await BinaryInstaller.ensureBinary(at: targetDir)
}

let arguments = [
options.mode,
"--config", options.configPath,
"--recording-dir", options.recordingDir
]

let process = Process()
process.executableURL = binaryURL
process.arguments = arguments

if let secrets = options.testServerSecrets {
var env = ProcessInfo.processInfo.environment
env["TEST_SERVER_SECRETS"] = secrets
process.environment = env
}

let pipe = Pipe()
process.standardOutput = pipe
process.standardError = pipe

pipe.fileHandleForReading.readabilityHandler = { handle in
if let data = try? handle.read(upToCount: handle.availableData.count),
let str = String(data: data, encoding: .utf8), !str.isEmpty {
print("[TestServer] \(str)", terminator: "")
}
}

try process.run()
self.process = process

try await awaitHealthyTestServer()
}

func stop() {
process?.terminate()
process = nil
}

private func awaitHealthyTestServer() async throws {
let healthURLString = try extractHealthURL(from: options.configPath)
guard let url = URL(string: healthURLString) else {
throw NSError(domain: "TestServer", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid health URL"])
}

print("[TestServer] Waiting for healthy server at \(url)...")
try await checkHealth(url: url)
}

private func extractHealthURL(from configPath: String) throws -> String {
let content = try String(contentsOfFile: configPath, encoding: .utf8)
let fullRange = NSRange(content.startIndex..., in: content)

// Find the first 'source_port', looks for "source_port: 1234"
let portPattern = #"source_port:\s*(\d+)"#
let portRegex = try NSRegularExpression(pattern: portPattern)
let portMatch = portRegex.firstMatch(in: content, range: fullRange)

guard let portRange = portMatch?.range(at: 1),
let portRangeInString = Range(portRange, in: content) else {
print("[TestServer] Warning: Could not parse source_port from config. Defaulting to 9000.")
return "http://localhost:9000/health"
}
let port = String(content[portRangeInString])

var healthPath = "/health" // Default
let healthPattern = #"health:\s*([\w/]+)"#

if let healthRegex = try? NSRegularExpression(pattern: healthPattern),
let healthMatch = healthRegex.firstMatch(in: content, range: fullRange),
let healthRangeInString = Range(healthMatch.range(at: 1), in: content) {
healthPath = String(content[healthRangeInString])
}

return "http://localhost:\(port)\(healthPath)"
}



private func checkHealth(url: URL) async throws {
let session = URLSession.shared
let maxRetries = 20
let delay = 0.5

for _ in 0..<maxRetries {
if let process = process, !process.isRunning {
throw NSError(domain: "TestServer", code: -1,
userInfo: [NSLocalizedDescriptionKey: "Server process died unexpectedly during startup."])
}

do {
let (_, response) = try await session.data(from: url)
if let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 {
return
}
} catch { /* retry */ }

try await Task.sleep(nanoseconds: UInt64(delay * 1_000_000_000))
}
throw NSError(domain: "TestServer", code: -1, userInfo: [NSLocalizedDescriptionKey: "Health check failed"])
}

}
57 changes: 57 additions & 0 deletions sdks/swift/Tests/TestServerTests/TestServerTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
// Copyright 2026 Google LLC
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

import XCTest
@testable import TestServer

/// Validates the core functionality of the `TestServer` class
final class TestServerTests: XCTestCase {

func testServerLifecycle() async throws {
let tempDir = FileManager.default.temporaryDirectory.appendingPathComponent("TestServerTests")
try? FileManager.default.createDirectory(at: tempDir, withIntermediateDirectories: true)

let binDir = tempDir.appendingPathComponent("bin")


let recordingsDir = tempDir.appendingPathComponent("recordings")
try FileManager.default.createDirectory(at: recordingsDir, withIntermediateDirectories: true)

let configURL = tempDir.appendingPathComponent("test-server.yml")

let placeholderConfig = """
endpoints:
- source_type: http
source_port: 1453
health: /healthz
"""
try placeholderConfig.write(to: configURL, atomically: true, encoding: .utf8)

let options = TestServerOptions(
configPath: configURL.path,
recordingDir: recordingsDir.path,
mode: "replay",
binaryPath: binDir.appendingPathComponent("test-server").path,
testServerSecrets: nil
)

let server = TestServer(options: options)

try await server.start()
print("✅ Server started and healthy!")

server.stop()
print("🛑 Server stopped.")
}
}
Loading