diff --git a/.gitignore b/.gitignore index 4620c2a..f2ad9f5 100644 --- a/.gitignore +++ b/.gitignore @@ -39,3 +39,17 @@ sdks/python/src/test_server_sdk.egg-info # Python SDK Sample specific sdks/python/sample/__pycache__ sdks/python/sample/.pytest_cache + +# Swift / Package Manager +.build/ +Packages/ +.swiftpm/ + +# Xcode +build/ +DerivedData/ +*.xcodeproj/ +xcuserdata/ + +# Credentials +.netrc \ No newline at end of file diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..420aa21 --- /dev/null +++ b/Package.swift @@ -0,0 +1,45 @@ +// 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) // Matches your current toolchain requirement + ], + products: [ + .library( + name: "TestServer", + targets: ["TestServer"] + ), + ], + targets: [ + .target( + name: "TestServer", + dependencies: [], + // Point to the subdirectory containing your wrapper code + path: "sdks/swift/Sources/TestServer" + ), + .testTarget( + name: "TestServerTests", + dependencies: ["TestServer"], + // Point to the subdirectory containing your verification tests + path: "sdks/swift/Tests/TestServerTests" + ), + ] +) diff --git a/sdks/swift/Sources/TestServer/BinaryInstaller.swift b/sdks/swift/Sources/TestServer/BinaryInstaller.swift new file mode 100644 index 0000000..c967015 --- /dev/null +++ b/sdks/swift/Sources/TestServer/BinaryInstaller.swift @@ -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() + } +} diff --git a/sdks/swift/Sources/TestServer/TestServer.swift b/sdks/swift/Sources/TestServer/TestServer.swift new file mode 100644 index 0000000..a2cb7fb --- /dev/null +++ b/sdks/swift/Sources/TestServer/TestServer.swift @@ -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..