Replace ruby/fastlane tools with swift variants. (#5105)
* Replace ruby/fastlane unit and preview test flows with swift variants. * Switch to swift-log and Logger for logging * Address (my own!) PR comments. --------- Co-authored-by: Doug <douglase@element.io>
This commit is contained in:
103
Tools/Sources/Commands/CI/CI.swift
Normal file
103
Tools/Sources/Commands/CI/CI.swift
Normal file
@@ -0,0 +1,103 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Subprocess
|
||||
|
||||
struct CI: ParsableCommand {
|
||||
static let configuration = CommandConfiguration(abstract: "CI workflow commands that can be run both locally and in CI environments.",
|
||||
subcommands: [
|
||||
UnitTests.self,
|
||||
RunTests.self
|
||||
])
|
||||
|
||||
static let testOutputDirectory = "test_output"
|
||||
|
||||
// MARK: - Linting
|
||||
|
||||
/// Runs SwiftFormat in lint mode against the current directory.
|
||||
static func lint() async throws {
|
||||
logger.info("\n🔍 Running SwiftFormat lint…\n")
|
||||
|
||||
do {
|
||||
try await run(.name("swiftformat"), ["--lint", "."])
|
||||
} catch {
|
||||
logger.error("\n❌ SwiftFormat failed.\n")
|
||||
throw error
|
||||
}
|
||||
logger.info("\n✅ SwiftFormat passed.\n")
|
||||
}
|
||||
|
||||
// MARK: - Test Results
|
||||
|
||||
/// Collects coverage from an xcresult bundle using xcresultparser (cobertura format).
|
||||
/// Failures are non-fatal — the output file simply won't be created.
|
||||
static func collectCoverage(resultBundle: String, target: String = "ElementX", outputName: String) async {
|
||||
let projectPath = URL.projectDirectory.path
|
||||
let resultBundlePath = "\(testOutputDirectory)/\(resultBundle)"
|
||||
let outputPath = "\(testOutputDirectory)/\(outputName)"
|
||||
|
||||
guard FileManager.default.fileExists(atPath: resultBundlePath) else {
|
||||
logger.error("\n❌ Result bundle not found at \(resultBundlePath), skipping coverage collection.\n")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await run(.path("/bin/zsh"), ["-cu", "xcresultparser -q -o cobertura -t \(target) -p \(projectPath) \(resultBundlePath) > \(outputPath)"])
|
||||
logger.info("\n📊 Coverage report: \(outputPath)\n")
|
||||
} catch {
|
||||
logger.error("\n❌ Failed to collect coverage for \(resultBundle): \(error.localizedDescription)\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Collects test results from an xcresult bundle using xcresultparser (junit format).
|
||||
/// Failures are non-fatal — the output file simply won't be created.
|
||||
static func collectTestResults(resultBundle: String, outputName: String) async {
|
||||
let projectPath = URL.projectDirectory.path
|
||||
let resultBundlePath = "\(testOutputDirectory)/\(resultBundle)"
|
||||
let outputPath = "\(testOutputDirectory)/\(outputName)"
|
||||
|
||||
guard FileManager.default.fileExists(atPath: resultBundlePath) else {
|
||||
logger.info(" Result bundle not found at \(resultBundlePath), skipping test result collection.")
|
||||
return
|
||||
}
|
||||
|
||||
do {
|
||||
try await run(.path("/bin/zsh"), ["-cu", "xcresultparser -q -o junit -p \(projectPath) \(resultBundlePath) > \(outputPath)"])
|
||||
logger.info("📋 Test results: \(outputPath)")
|
||||
} catch {
|
||||
logger.error("\n❌ Failed to collect test results for \(resultBundle): \(error.localizedDescription)\n")
|
||||
}
|
||||
}
|
||||
|
||||
/// Zips xcresult bundles in the test output directory for faster artifact uploads.
|
||||
static func zipResults(bundles: [String], outputName: String) async {
|
||||
let bundleArgs = bundles.joined(separator: " ")
|
||||
do {
|
||||
logger.info("\n📦 Zipping test results…")
|
||||
try await run(.path("/bin/zsh"), ["-cu", "cd \(testOutputDirectory) && zip -rq \(outputName) \(bundleArgs)"])
|
||||
logger.info("📦 Zipped: \(testOutputDirectory)/\(outputName)\n")
|
||||
} catch {
|
||||
logger.error("\n❌ Failed to zip results: \(error.localizedDescription)\n")
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: - Shell Interaction
|
||||
|
||||
@discardableResult
|
||||
static func run<Output: OutputProtocol, Error: ErrorOutputProtocol>(_ executable: Executable,
|
||||
_ arguments: Arguments = [],
|
||||
environment: Environment = .inherit,
|
||||
output: Output = .standardOutput,
|
||||
error: Error = .standardError) async throws -> CollectedResult<Output, Error> {
|
||||
let result = try await Subprocess.run(executable,
|
||||
arguments: arguments,
|
||||
environment: environment,
|
||||
output: output,
|
||||
error: error)
|
||||
|
||||
if case let .exited(code) = result.terminationStatus, code != 0 {
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
}
|
||||
148
Tools/Sources/Commands/CI/RunTests.swift
Normal file
148
Tools/Sources/Commands/CI/RunTests.swift
Normal file
@@ -0,0 +1,148 @@
|
||||
import ArgumentParser
|
||||
import CommandLineTools
|
||||
import Foundation
|
||||
|
||||
struct RunTests: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(abstract: "Runs xcodebuild tests with simulator management, retries, and formatting.",
|
||||
discussion: """
|
||||
Uses xcodebuild's native -retry-tests-on-failure flag to retry only \
|
||||
failing tests instead of re-running the entire suite.
|
||||
|
||||
Examples:
|
||||
swift run tools run-tests --scheme UnitTests
|
||||
swift run tools run-tests --scheme UITests --device iPhone --os-version 26.1
|
||||
swift run tools run-tests --scheme PreviewTests --create-simulator-name "iPhone SE (3rd generation)" \
|
||||
--create-simulator-type com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation
|
||||
""")
|
||||
|
||||
@Option(help: "The Xcode scheme to test.")
|
||||
var scheme: String
|
||||
|
||||
@Option(help: "The simulator device name to run tests on (e.g. 'iPhone 17').")
|
||||
var device = "iPhone 17"
|
||||
|
||||
@Option(help: "The iOS version to use for the simulator runtime (e.g. '26.1').")
|
||||
var osVersion = "26.1"
|
||||
|
||||
@Option(help: "Number of times to retry failed tests. Only the failing tests are re-run, not the entire suite.")
|
||||
var retries = 3
|
||||
|
||||
@Option(help: "When set, create a simulator with this name if one doesn't already exist.")
|
||||
var createSimulatorName: String?
|
||||
|
||||
@Option(help: "The simulator device type identifier for creating a new simulator (e.g. 'com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation').")
|
||||
var createSimulatorType: String?
|
||||
|
||||
@Option(help: "Only run a specific test (format: 'ClassName/testName').")
|
||||
var testName: String?
|
||||
|
||||
private var isCI: Bool {
|
||||
ProcessInfo.processInfo.environment["CI"] != nil
|
||||
}
|
||||
|
||||
private var resultBundlePath: String {
|
||||
"test_output/\(scheme).xcresult"
|
||||
}
|
||||
|
||||
private var formatter: String {
|
||||
"xcbeautify -q --disable-logging --is-ci --renderer github-actions"
|
||||
}
|
||||
|
||||
private var simulatorRuntime: String {
|
||||
"com.apple.CoreSimulator.SimRuntime.iOS-\(osVersion.replacingOccurrences(of: ".", with: "-"))"
|
||||
}
|
||||
|
||||
func run() async throws {
|
||||
if let createName = createSimulatorName {
|
||||
guard let createType = createSimulatorType else {
|
||||
throw ValidationError("--create-simulator-type must be provided when --create-simulator-name is set.")
|
||||
}
|
||||
try await createSimulatorIfNecessary(name: createName, type: createType)
|
||||
}
|
||||
|
||||
// Remove any previous result bundle at this path
|
||||
let resultBundleURL = URL.projectDirectory.appendingPathComponent(resultBundlePath)
|
||||
if FileManager.default.fileExists(atPath: resultBundleURL.path) {
|
||||
try? FileManager.default.removeItem(at: resultBundleURL)
|
||||
}
|
||||
|
||||
// Ensure the output directory exists
|
||||
let outputDirectory = resultBundleURL.deletingLastPathComponent()
|
||||
try FileManager.default.createDirectory(at: outputDirectory, withIntermediateDirectories: true)
|
||||
|
||||
try await executeXcodeBuild()
|
||||
|
||||
try await shutdownSimulator()
|
||||
|
||||
logger.info("\n✅ Tests passed.\n")
|
||||
}
|
||||
|
||||
// MARK: - Simulator Management
|
||||
|
||||
private func createSimulatorIfNecessary(name: String, type: String) async throws {
|
||||
logger.info("Checking for simulator '\(name)'…")
|
||||
|
||||
guard let simulators = try await CI.run(.path("/bin/zsh"), ["-cu", "xcrun simctl list devices \"iOS \(osVersion)\" available"],
|
||||
output: .string(limit: 4096)).standardOutput else {
|
||||
logger.info("No simulators found for iOS \(osVersion). Creating '\(name)'…")
|
||||
try await createSimulator(name: name, type: type)
|
||||
return
|
||||
}
|
||||
|
||||
// Use a `(` to avoid matching e.g. "iPhone 14 Pro" on "iPhone 14 Pro Max"
|
||||
let hasExisting = simulators.components(separatedBy: "\n").contains { line in
|
||||
line.contains("\(name) (")
|
||||
}
|
||||
|
||||
if hasExisting {
|
||||
logger.info("Simulator '\(name)' already exists.")
|
||||
} else {
|
||||
logger.info("Simulator '\(name)' not found. Creating…")
|
||||
try await createSimulator(name: name, type: type)
|
||||
}
|
||||
}
|
||||
|
||||
private func createSimulator(name: String, type: String) async throws {
|
||||
let deviceID = try await CI.run(.path("/bin/zsh"), ["-cu", "xcrun simctl create '\(name)' \(type) \(simulatorRuntime)"],
|
||||
output: .string(limit: 4096)).standardOutput
|
||||
logger.info("Created simulator '\(name)' (\(deviceID?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "unknown")).")
|
||||
}
|
||||
|
||||
// MARK: - Simulator Shutdown
|
||||
|
||||
private func shutdownSimulator() async throws {
|
||||
print("Shutting down simulator '\(device)'…")
|
||||
|
||||
let command = "xcrun simctl shutdown '\(device)' 2>/dev/null || true"
|
||||
try await CI.run(.path("/bin/zsh"), ["-cu", command])
|
||||
|
||||
print("Simulator shut down.")
|
||||
}
|
||||
|
||||
// MARK: - Test Running
|
||||
|
||||
private func executeXcodeBuild() async throws {
|
||||
var command = "set -o pipefail && xcodebuild test"
|
||||
command += " -scheme \(scheme)"
|
||||
command += " -sdk iphonesimulator"
|
||||
command += " -destination 'platform=iOS Simulator,name=\(device),OS=\(osVersion),arch=arm64'"
|
||||
command += " -resultBundlePath \(resultBundlePath)"
|
||||
command += " -skipPackagePluginValidation"
|
||||
|
||||
// Use xcodebuild's native retry support to re-run only failing tests
|
||||
// instead of re-running the entire suite. retries=0 means no retries (single run).
|
||||
if retries > 0 {
|
||||
// -test-iterations is the total number of attempts (initial + retries)
|
||||
command += " -retry-tests-on-failure"
|
||||
command += " -test-iterations \(retries + 1)"
|
||||
}
|
||||
|
||||
if let testName {
|
||||
command += " -only-testing:\(scheme)/\(testName)"
|
||||
}
|
||||
|
||||
command += " | \(formatter)"
|
||||
|
||||
try await CI.run(.path("/bin/zsh"), ["-cu", command])
|
||||
}
|
||||
}
|
||||
67
Tools/Sources/Commands/CI/UnitTests.swift
Normal file
67
Tools/Sources/Commands/CI/UnitTests.swift
Normal file
@@ -0,0 +1,67 @@
|
||||
import ArgumentParser
|
||||
import CommandLineTools
|
||||
import Foundation
|
||||
|
||||
struct UnitTests: AsyncParsableCommand {
|
||||
static let configuration = CommandConfiguration(commandName: "unit-tests",
|
||||
abstract: "Runs the unit test CI workflow: lint, unit tests, preview tests, and result collection.")
|
||||
|
||||
@Option(help: "Device name for unit tests.")
|
||||
var device = "iPhone 17"
|
||||
|
||||
@Option(help: "iOS version for the simulator.")
|
||||
var osVersion = "26.1"
|
||||
|
||||
func run() async throws {
|
||||
try await CI.lint()
|
||||
|
||||
var failures: [String] = []
|
||||
|
||||
// Run unit tests
|
||||
do {
|
||||
logger.info("\n🧪 Running unit tests…\n")
|
||||
try await RunTests.parse([
|
||||
"--scheme", "UnitTests",
|
||||
"--device", device,
|
||||
"--os-version", osVersion
|
||||
]).run()
|
||||
} catch {
|
||||
failures.append("Unit tests failed: \(error)")
|
||||
logger.error("\n❌ Unit tests failed. \(error)\n")
|
||||
}
|
||||
|
||||
// Run preview tests on a smaller device
|
||||
do {
|
||||
logger.info("\n🧪 Running preview tests…")
|
||||
try await RunTests.parse([
|
||||
"--scheme", "PreviewTests",
|
||||
"--device", "iPhone SE (3rd generation)",
|
||||
"--os-version", osVersion,
|
||||
"--create-simulator-name", "iPhone SE (3rd generation)",
|
||||
"--create-simulator-type", "com.apple.CoreSimulator.SimDeviceType.iPhone-SE-3rd-generation"
|
||||
]).run()
|
||||
} catch {
|
||||
failures.append("Preview tests failed: \(error)")
|
||||
logger.error("\n❌ Preview tests failed.\n")
|
||||
}
|
||||
|
||||
// Zip results (best-effort, useful for CI artifact uploads)
|
||||
await CI.zipResults(bundles: ["UnitTests.xcresult", "PreviewTests.xcresult"],
|
||||
outputName: "UnitTests.zip")
|
||||
|
||||
// Collect coverage reports
|
||||
await CI.collectCoverage(resultBundle: "UnitTests.xcresult", outputName: "unit-cobertura.xml")
|
||||
await CI.collectCoverage(resultBundle: "PreviewTests.xcresult", outputName: "preview-cobertura.xml")
|
||||
|
||||
// Collect JUnit test results
|
||||
await CI.collectTestResults(resultBundle: "UnitTests.xcresult", outputName: "unit-junit.xml")
|
||||
await CI.collectTestResults(resultBundle: "PreviewTests.xcresult", outputName: "preview-junit.xml")
|
||||
|
||||
if !failures.isEmpty {
|
||||
logger.error("\n❌ \(failures.count) test suite(s) failed.\n")
|
||||
throw ExitCode.failure
|
||||
}
|
||||
|
||||
logger.info("\n✅ All unit test suites passed.\n")
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,8 @@
|
||||
import ArgumentParser
|
||||
import Foundation
|
||||
import Logging
|
||||
|
||||
let logger = Logger(label: "🚀")
|
||||
|
||||
@main
|
||||
struct Tools: AsyncParsableCommand {
|
||||
@@ -13,5 +16,6 @@ struct Tools: AsyncParsableCommand {
|
||||
GenerateSAS.self,
|
||||
AppIconBanner.self,
|
||||
UnusedStrings.self,
|
||||
BumpCalendarVersion.self])
|
||||
BumpCalendarVersion.self,
|
||||
CI.self])
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user