diff --git a/Package.swift b/Package.swift index 397262d..972a39c 100644 --- a/Package.swift +++ b/Package.swift @@ -61,7 +61,7 @@ let targets: [PackageDescription.Target] = [ ), .testTarget( name: "SelectiveTestingTests", - dependencies: ["xcode-selective-test", "PathKit"], + dependencies: ["xcode-selective-test", "PathKit", "Workspace"], resources: [.copy("ExampleProject")], swiftSettings: flags ), diff --git a/Sources/TestConfigurator/TestConfigurator.swift b/Sources/TestConfigurator/TestConfigurator.swift index 927508e..5d5e862 100644 --- a/Sources/TestConfigurator/TestConfigurator.swift +++ b/Sources/TestConfigurator/TestConfigurator.swift @@ -36,12 +36,10 @@ extension TestPlanHelper { packagesToTest.contains(target.target.name) guard enabled else { return nil } - - return TestTarget(parallelizable: target.parallelizable, - skippedTests: target.skippedTests, - selectedTests: target.selectedTests, - target: target.target, - enabled: enabled) + + var updatedTarget = target + updatedTarget.enabled = true + return updatedTarget } } } diff --git a/Sources/TestConfigurator/xctestplanner/Core/Entity/TestPlanModel.swift b/Sources/TestConfigurator/xctestplanner/Core/Entity/TestPlanModel.swift index d0f441b..a72dc72 100644 --- a/Sources/TestConfigurator/xctestplanner/Core/Entity/TestPlanModel.swift +++ b/Sources/TestConfigurator/xctestplanner/Core/Entity/TestPlanModel.swift @@ -1,111 +1,174 @@ import Foundation -// MARK: - Welcome +typealias JSONObject = [String: Any] -public struct TestPlanModel: Codable { - public var configurations: [Configuration] - public var defaultOptions: DefaultOptions +public struct TestPlanModel { + private var rawJSON: JSONObject public var testTargets: [TestTarget] - public var version: Int -} - -// MARK: - Configuration - -public struct Configuration: Codable { - public var id, name: String - public var options: Options + + public init(data: Data) throws { + let object = try JSONSerialization.jsonObject(with: data, options: []) + guard let dictionary = object as? JSONObject else { + throw TestPlanModelError.invalidFormat + } + + self.rawJSON = dictionary + self.testTargets = try Self.decodeTargets(from: dictionary["testTargets"]) + } + + func encodedData() throws -> Data { + var json = rawJSON + json["testTargets"] = try Self.encodeTargets(testTargets) + return try JSONSerialization.data(withJSONObject: json, + options: [.prettyPrinted, .sortedKeys]) + } + + private static func decodeTargets(from value: Any?) throws -> [TestTarget] { + guard let value, !(value is NSNull) else { return [] } + guard let array = value as? [Any] else { + throw TestPlanModelError.invalidTargets + } + + return try array.map { element in + guard let dictionary = element as? JSONObject else { + throw TestPlanModelError.invalidTargets + } + return try TestTarget(json: dictionary) + } + } + + private static func encodeTargets(_ targets: [TestTarget]) throws -> [Any] { + return try targets.map { try $0.encodeJSON() } + } } -// MARK: - Options - -public struct Options: Codable { - public var targetForVariableExpansion: Target? +public enum TestPlanModelError: Error { + case invalidFormat + case invalidTargets + case invalidTargetObject } // MARK: - Target -public struct Target: Codable { - public var containerPath, identifier, name: String -} - -// MARK: - DefaultOptions - -public struct DefaultOptions: Codable { - public var commandLineArgumentEntries: [CommandLineArgumentEntry]? - public var environmentVariableEntries: [EnvironmentVariableEntry]? - public var language: String? - public var region: String? - public var locationScenario: LocationScenario? - public var testTimeoutsEnabled: Bool? - public var testRepetitionMode: String? - public var maximumTestRepetitions: Int? - public var maximumTestExecutionTimeAllowance: Int? - public var targetForVariableExpansion: Target? -} - -// MARK: - CommandLineArgumentEntry - -public struct CommandLineArgumentEntry: Codable { - public let argument: String - public let enabled: Bool? -} - -// MARK: - EnvironmentVariableEntry - -public struct EnvironmentVariableEntry: Codable { - public var key, value: String - public let enabled: Bool? -} - -// MARK: - LocationScenario - -public struct LocationScenario: Codable { +public struct Target { + private var rawJSON: JSONObject + public var containerPath: String public var identifier: String + public var name: String + + init(json: JSONObject) throws { + guard let containerPath = json["containerPath"] as? String, + let identifier = json["identifier"] as? String, + let name = json["name"] as? String else { + throw TestPlanModelError.invalidTargetObject + } + + self.rawJSON = json + self.containerPath = containerPath + self.identifier = identifier + self.name = name + } + + func encodeJSON() -> JSONObject { + var json = rawJSON + json["containerPath"] = containerPath + json["identifier"] = identifier + json["name"] = name + return json + } } // MARK: - TestTarget -public struct TestTarget: Codable { +public struct TestTarget { + private var rawJSON: JSONObject public var parallelizable: Bool? public var skippedTests: Tests? public var selectedTests: Tests? public var target: Target public var enabled: Bool? + + init(json: JSONObject) throws { + guard let targetJSON = json["target"] as? JSONObject else { + throw TestPlanModelError.invalidTargetObject + } + self.rawJSON = json + self.parallelizable = json["parallelizable"] as? Bool + self.enabled = json["enabled"] as? Bool + self.target = try Target(json: targetJSON) + self.skippedTests = try Tests.fromJSONValue(json["skippedTests"]) + self.selectedTests = try Tests.fromJSONValue(json["selectedTests"]) + } + + func encodeJSON() throws -> JSONObject { + var json = rawJSON + json.setJSONValue(parallelizable, forKey: "parallelizable") + json.setJSONValue(enabled, forKey: "enabled") + json.setJSONValue(try skippedTests?.jsonValue(), forKey: "skippedTests") + json.setJSONValue(try selectedTests?.jsonValue(), forKey: "selectedTests") + json["target"] = target.encodeJSON() + return json + } } public enum Tests: Codable { - case array([String]) - case dictionary(Suites) - - public struct Suites: Codable { - let suites: [Suite] - - public struct Suite: Codable { - let name: String + case array([String]) + case dictionary(Suites) + + public struct Suites: Codable { + let suites: [Suite] + + public struct Suite: Codable { + let name: String + } } - } - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if let array = try? container.decode([String].self) { - self = .array(array) - return + + public init(from decoder: Decoder) throws { + let container = try decoder.singleValueContainer() + if let array = try? container.decode([String].self) { + self = .array(array) + return + } + + if let dict = try? container.decode(Suites.self) { + self = .dictionary(dict) + return + } + throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid type for skippedTests") + } + + public func encode(to encoder: Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .array(let array): + try container.encode(array) + case .dictionary(let dict): + try container.encode(dict) + } } +} - if let dict = try? container.decode(Suites.self) { - self = .dictionary(dict) - return +extension Tests { + static func fromJSONValue(_ value: Any?) throws -> Tests? { + guard let value, !(value is NSNull) else { return nil } + let data = try JSONSerialization.data(withJSONObject: value) + let decoder = JSONDecoder() + return try decoder.decode(Tests.self, from: data) + } + + func jsonValue() throws -> Any { + let encoder = JSONEncoder() + let data = try encoder.encode(self) + return try JSONSerialization.jsonObject(with: data, options: []) } - throw DecodingError.dataCorruptedError(in: container, debugDescription: "Invalid type for skippedTests") - } +} - public func encode(to encoder: Encoder) throws { - var container = encoder.singleValueContainer() - switch self { - case .array(let array): - try container.encode(array) - case .dictionary(let dict): - try container.encode(dict) +extension Dictionary where Key == String, Value == Any { + mutating func setJSONValue(_ value: Any?, forKey key: String) { + if let value { + self[key] = value + } else { + self.removeValue(forKey: key) + } } - } } diff --git a/Sources/TestConfigurator/xctestplanner/Core/Extensions/Array+Extensions.swift b/Sources/TestConfigurator/xctestplanner/Core/Extensions/Array+Extensions.swift deleted file mode 100644 index 0a448ee..0000000 --- a/Sources/TestConfigurator/xctestplanner/Core/Extensions/Array+Extensions.swift +++ /dev/null @@ -1,13 +0,0 @@ -// -// Array+Extensions.swift -// -// -// Created by Atakan Karslı on 27/02/2023. -// - -extension Array where Element: Equatable { - mutating func remove(object: Element) { - guard let index = firstIndex(of: object) else { return } - remove(at: index) - } -} diff --git a/Sources/TestConfigurator/xctestplanner/Core/Helper/TestPlanHelper.swift b/Sources/TestConfigurator/xctestplanner/Core/Helper/TestPlanHelper.swift index d1c097a..51273b4 100644 --- a/Sources/TestConfigurator/xctestplanner/Core/Helper/TestPlanHelper.swift +++ b/Sources/TestConfigurator/xctestplanner/Core/Helper/TestPlanHelper.swift @@ -5,7 +5,6 @@ // Created by Atakan Karslı on 20/12/2022. // -import ArgumentParser import Foundation import Logging @@ -16,60 +15,16 @@ public class TestPlanHelper { logger.info("Reading test plan from file: \(filePath)") let url = URL(fileURLWithPath: filePath) let data = try Data(contentsOf: url) - - let decoder = JSONDecoder() - return try decoder.decode(TestPlanModel.self, from: data) + return try TestPlanModel(data: data) } static func writeTestPlan(_ testPlan: TestPlanModel, filePath: String) throws { logger.info("Writing updated test plan to file: \(filePath)") - let encoder = JSONEncoder() - encoder.outputFormatting = [.prettyPrinted, .sortedKeys] - let updatedData = try encoder.encode(testPlan) - + let updatedData = try testPlan.encodedData() let url = URL(fileURLWithPath: filePath) try updatedData.write(to: url) } - static func updateRerunCount(testPlan: inout TestPlanModel, to count: Int) { - logger.info("Updating rerun count in test plan to: \(count)") - if testPlan.defaultOptions.testRepetitionMode == nil { - testPlan.defaultOptions.testRepetitionMode = TestPlanValue.retryOnFailure.rawValue - } - testPlan.defaultOptions.maximumTestRepetitions = count - } - - static func updateLanguage(testPlan: inout TestPlanModel, to language: String) { - logger.info("Updating language in test plan to: \(language)") - testPlan.defaultOptions.language = language.lowercased() - } - - static func updateRegion(testPlan: inout TestPlanModel, to region: String) { - logger.info("Updating region in test plan to: \(region)") - testPlan.defaultOptions.region = region.uppercased() - } - - static func setEnvironmentVariable(testPlan: inout TestPlanModel, key: String, value: String, enabled: Bool? = true) { - logger.info("Setting environment variable with key '\(key)' and value '\(value)' in test plan") - if testPlan.defaultOptions.environmentVariableEntries == nil { - testPlan.defaultOptions.environmentVariableEntries = [] - } - testPlan.defaultOptions.environmentVariableEntries?.append(EnvironmentVariableEntry(key: key, value: value, enabled: enabled)) - } - - static func setArgument(testPlan: inout TestPlanModel, key: String, disabled: Bool) { - if testPlan.defaultOptions.commandLineArgumentEntries == nil { - testPlan.defaultOptions.commandLineArgumentEntries = [] - } - if disabled { - logger.info("Setting command line argument with key '\(key)' in test plan as disabled") - testPlan.defaultOptions.commandLineArgumentEntries?.append(CommandLineArgumentEntry(argument: key, enabled: !disabled)) - } else { - logger.info("Setting command line argument with key '\(key)', enabled by default") - testPlan.defaultOptions.commandLineArgumentEntries?.append(CommandLineArgumentEntry(argument: key, enabled: nil)) - } - } - static func checkForTestTargets(testPlan: TestPlanModel) { if testPlan.testTargets.isEmpty { logger.error("Test plan does not have any test targets. Add a test target before attempting to update the selected or skipped tests.") @@ -77,7 +32,3 @@ public class TestPlanHelper { } } } - -enum TestPlanValue: String { - case retryOnFailure -} diff --git a/Tests/SelectiveTestingTests/TestPlanModelTests.swift b/Tests/SelectiveTestingTests/TestPlanModelTests.swift new file mode 100644 index 0000000..32b5d93 --- /dev/null +++ b/Tests/SelectiveTestingTests/TestPlanModelTests.swift @@ -0,0 +1,90 @@ +import Foundation +import PathKit +@testable import TestConfigurator +import Testing +import Workspace + +@Suite +struct TestPlanModelTests { + private let samplePlanData = """ + { + "configurations": [], + "defaultOptions": { + "language": "en" + }, + "futureTopLevel": { + "flag": true + }, + "testTargets": [ + { + "parallelizable": true, + "customField": "keepMe", + "target": { + "containerPath": "container", + "identifier": "com.example.tests", + "name": "ExampleTests", + "newTargetMetadata": { + "foo": "bar" + } + } + } + ], + "version": 1 + } + """.data(using: .utf8)! + + @Test + func preservesUnknownKeysWhenUpdatingPlan() throws { + var plan = try TestPlanModel(data: samplePlanData) + let identity = TargetIdentity.project(path: Path("/tmp/Example.xcodeproj"), + targetName: "ExampleTests", + testTarget: true) + + TestPlanHelper.updateSelectedTestTargets(testPlan: &plan, with: [identity]) + + let encoded = try plan.encodedData() + guard let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any], + let futureTopLevel = json["futureTopLevel"] as? [String: Bool], + let testTargets = json["testTargets"] as? [Any], + let firstTarget = testTargets.first as? [String: Any], + let nestedTarget = firstTarget["target"] as? [String: Any], + let metadata = nestedTarget["newTargetMetadata"] as? [String: String] + else { + Issue.record("Failed to decode encoded test plan JSON") + return + } + + #expect(futureTopLevel["flag"] == true) + #expect(firstTarget["customField"] as? String == "keepMe") + #expect(metadata["foo"] == "bar") + #expect(firstTarget["enabled"] as? Bool == true) + } + + @Test + func targetPreservesUnknownKeysWhenMutated() throws { + var plan = try TestPlanModel(data: samplePlanData) + guard var testTarget = plan.testTargets.first else { + Issue.record("Missing test target") + return + } + + testTarget.target.identifier = "com.example.updated" + testTarget.target.name = "RenamedTests" + plan.testTargets = [testTarget] + + let encoded = try plan.encodedData() + guard let json = try JSONSerialization.jsonObject(with: encoded) as? [String: Any], + let testTargets = json["testTargets"] as? [Any], + let firstTarget = testTargets.first as? [String: Any], + let nestedTarget = firstTarget["target"] as? [String: Any], + let metadata = nestedTarget["newTargetMetadata"] as? [String: String] + else { + Issue.record("Failed to decode encoded test plan JSON") + return + } + + #expect(nestedTarget["identifier"] as? String == "com.example.updated") + #expect(nestedTarget["name"] as? String == "RenamedTests") + #expect(metadata["foo"] == "bar") + } +}