From 7768087c33ddc238839eae231fe0384d7a52939d Mon Sep 17 00:00:00 2001 From: Sara Robinson Date: Thu, 22 Jan 2026 10:42:53 -0500 Subject: [PATCH 1/2] Add initial Swift wrapper for test server --- sdks/swift/.gitignore | 8 ++ sdks/swift/Package.swift | 24 ++++ .../Sources/TestServer/BinaryInstaller.swift | 106 ++++++++++++++ .../swift/Sources/TestServer/TestServer.swift | 132 ++++++++++++++++++ .../TestServerTests/TestServerTests.swift | 43 ++++++ 5 files changed, 313 insertions(+) create mode 100644 sdks/swift/.gitignore create mode 100644 sdks/swift/Package.swift create mode 100644 sdks/swift/Sources/TestServer/BinaryInstaller.swift create mode 100644 sdks/swift/Sources/TestServer/TestServer.swift create mode 100644 sdks/swift/Tests/TestServerTests/TestServerTests.swift diff --git a/sdks/swift/.gitignore b/sdks/swift/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/sdks/swift/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/sdks/swift/Package.swift b/sdks/swift/Package.swift new file mode 100644 index 0000000..4b3ea47 --- /dev/null +++ b/sdks/swift/Package.swift @@ -0,0 +1,24 @@ +// swift-tools-version: 6.1 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +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"] + ), + ] +) \ No newline at end of file diff --git a/sdks/swift/Sources/TestServer/BinaryInstaller.swift b/sdks/swift/Sources/TestServer/BinaryInstaller.swift new file mode 100644 index 0000000..ed56cf0 --- /dev/null +++ b/sdks/swift/Sources/TestServer/BinaryInstaller.swift @@ -0,0 +1,106 @@ +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..6b04a0a --- /dev/null +++ b/sdks/swift/Sources/TestServer/TestServer.swift @@ -0,0 +1,132 @@ +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.. Date: Thu, 22 Jan 2026 13:17:19 -0500 Subject: [PATCH 2/2] Add license headers --- sdks/swift/Package.swift | 16 +++++++++++++++- .../Sources/TestServer/BinaryInstaller.swift | 14 ++++++++++++++ sdks/swift/Sources/TestServer/TestServer.swift | 14 ++++++++++++++ .../Tests/TestServerTests/TestServerTests.swift | 14 ++++++++++++++ 4 files changed, 57 insertions(+), 1 deletion(-) diff --git a/sdks/swift/Package.swift b/sdks/swift/Package.swift index 4b3ea47..9884bf5 100644 --- a/sdks/swift/Package.swift +++ b/sdks/swift/Package.swift @@ -1,6 +1,20 @@ // 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( @@ -21,4 +35,4 @@ let package = Package( dependencies: ["TestServer"] ), ] -) \ No newline at end of file +) diff --git a/sdks/swift/Sources/TestServer/BinaryInstaller.swift b/sdks/swift/Sources/TestServer/BinaryInstaller.swift index ed56cf0..c967015 100644 --- a/sdks/swift/Sources/TestServer/BinaryInstaller.swift +++ b/sdks/swift/Sources/TestServer/BinaryInstaller.swift @@ -1,3 +1,17 @@ +// 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 { diff --git a/sdks/swift/Sources/TestServer/TestServer.swift b/sdks/swift/Sources/TestServer/TestServer.swift index 6b04a0a..a2cb7fb 100644 --- a/sdks/swift/Sources/TestServer/TestServer.swift +++ b/sdks/swift/Sources/TestServer/TestServer.swift @@ -1,3 +1,17 @@ +// 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 { diff --git a/sdks/swift/Tests/TestServerTests/TestServerTests.swift b/sdks/swift/Tests/TestServerTests/TestServerTests.swift index 0c1a4ab..fb75597 100644 --- a/sdks/swift/Tests/TestServerTests/TestServerTests.swift +++ b/sdks/swift/Tests/TestServerTests/TestServerTests.swift @@ -1,3 +1,17 @@ +// 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