├── .github └── workflows │ ├── alex-readme.yml │ └── swift.yml ├── .gitignore ├── .swiftpm └── xcode │ ├── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ └── xcschemes │ └── CloudKitFeatureFlags.xcscheme ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── CloudKitFeatureFlags │ ├── AdditionalUserData.swift │ ├── CloudKitAbstractions.swift │ ├── FeatureFlag+CloudKit.swift │ ├── FeatureFlag.swift │ ├── FeatureFlagCoordinator.swift │ ├── FeatureFlagStorage.swift │ └── FlaggingLogic.swift └── Tests ├── CloudKitFeatureFlagsTests ├── FeatureFlagCoordinatorTests.swift ├── FeatureFlagStorageTests.swift ├── FlaggingLogicTests.swift └── XCTestManifests.swift └── LinuxMain.swift /.github/workflows/alex-readme.yml: -------------------------------------------------------------------------------- 1 | name: Alex Recommends 2 | 3 | on: pull_request 4 | 5 | jobs: 6 | alex: 7 | runs-on: ubuntu-latest 8 | 9 | steps: 10 | - uses: actions/checkout@v2 11 | - name: Use Node.js 12 | uses: actions/setup-node@v1 13 | with: 14 | node-version: 12.x 15 | - name: Comment on new PR 16 | uses: brown-ccv/alex-recommends@v1.0.0 17 | with: 18 | GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} 19 | message_id: 'alex' 20 | no_binary: false 21 | profanity_sureness: 2 22 | pr_only: false 23 | glob_pattern: "services/**" 24 | -------------------------------------------------------------------------------- /.github/workflows/swift.yml: -------------------------------------------------------------------------------- 1 | name: Swift 2 | 3 | on: 4 | push: 5 | branches: [main] 6 | pull_request: 7 | branches: [main] 8 | 9 | jobs: 10 | build: 11 | runs-on: macos-latest 12 | 13 | steps: 14 | - uses: actions/checkout@v2 15 | - name: Build 16 | run: swift build -v 17 | test: 18 | runs-on: macos-latest 19 | 20 | steps: 21 | - uses: actions/checkout@v2 22 | - name: Run tests 23 | run: swift test -v --parallel 24 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /.swiftpm/xcode/xcshareddata/xcschemes/CloudKitFeatureFlags.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 29 | 35 | 36 | 37 | 38 | 39 | 45 | 46 | 48 | 54 | 55 | 56 | 57 | 58 | 68 | 69 | 75 | 76 | 82 | 83 | 84 | 85 | 87 | 88 | 91 | 92 | 93 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Robin Malhotra 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.3 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "CloudKitFeatureFlags", 8 | platforms: [.iOS(.v13), .macOS(.v10_15)], 9 | products: [ 10 | // Products define the executables and libraries a package produces, and make them visible to other packages. 11 | .library( 12 | name: "CloudKitFeatureFlags", 13 | targets: ["CloudKitFeatureFlags"]) 14 | ], 15 | dependencies: [ 16 | // Dependencies declare other packages that this package depends on. 17 | // .package(url: /* package url */, from: "1.0.0"), 18 | ], 19 | targets: [ 20 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 21 | // Targets can depend on other targets in this package, and on products in packages this package depends on. 22 | .target( 23 | name: "CloudKitFeatureFlags", 24 | dependencies: []), 25 | .testTarget( 26 | name: "CloudKitFeatureFlagsTests", 27 | dependencies: ["CloudKitFeatureFlags"]), 28 | ] 29 | ) 30 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # CloudKitFeatureFlags 2 | 3 | ![Swift](https://github.com/codeOfRobin/CloudKitFeatureFlags/workflows/Swift/badge.svg?branch=main) 4 | 5 | ## Features (hah!): 6 | 7 | - Rollouts in 10% increments (10%, 20%, 30% etc.) 8 | - A unique statistical approach, we don't need to store a large number of records or do lots of operations to rollback/deploy feature flags. 9 | - Most operations typically involve editing one row in your CK Public database. 10 | - All operations take place via the public databse, so no worrying about users running out of iCloud space. 11 | - Tests to ensure rollouts are reasonably accurate (there's tests to ensure rollouts are at least 0.5% accurate on user populations of 100k users, and 1% accurate on 10k users) 12 | - (Coming soon) Release features to specific allow-listed users - such as app reviewers 13 | - (Coming Soon) Admin interface to create and deploy feature flags 14 | - (Coming Soon) Gate feature flags to avoid leaking features currently in development, may require a CloudKit Schema migration 15 | 16 | Broad ideas and implementation ideas here: https://www.craft.do/s/VIzO95A9chLeoW 17 | 18 | Test application here: https://github.com/codeOfRobin/TestingCloudKitFeatureFlags (requires setting up a CloudKit container and changing the signing capabilities with your  Developer account) 19 | 20 | # Guide 21 | 22 | ## Installation 23 | 24 | Add to your project via Swift Package manager, package URL: `https://github.com/codeOfRobin/CloudKitFeatureFlags`. Since we're still early along, it's recommended to use the `main` branch. 25 | 26 | ## Usage 27 | 28 | - Create feature flags somehow You can use the test app, or simply do it via the CloudKit dashboard. This is roughly the schema you're looking for (I'm working on making this experience better, feel free to open an issue if you need help!) ![](https://i.imgur.com/Zj6MmGR.png) 29 | - In your app, install the package and create an instance of `CloudKitFeatureFlagsRepository` 30 | 31 | ```swift 32 | let container = CKContainer(identifier: "your container goes here, please make sure it's correctly set up in the "Signing and Capabilities section in Xcode") 33 | 34 | lazy var featureFlags = CloudKitFeatureFlagsRepository(container: container) 35 | 36 | /// For Combine reasons 37 | var cancellables = Set() 38 | ``` 39 | 40 | - Use the `featureFlagsRepository` to query the status of a feature flag. In the future ([coming soon!](https://github.com/codeOfRobin/CloudKitFeatureFlags/issues/1)) it'll update realtime via a `CKSubscription` 41 | 42 | ```swift 43 | featureFlags.featureEnabled(name: "some_feature_flag").sink(receiveCompletion: { (_) in }) { (value) in 44 | /// use `value` to change your UI imperatively, or bind the publisher directly! 45 | print(value) 46 | }.store(in: &cancellables) 47 | ``` 48 | 49 | - And that's it! You can control feature flags and rollouts directly from the CloudKit dashboard 🎉🎉🎉 50 | -------------------------------------------------------------------------------- /Sources/CloudKitFeatureFlags/AdditionalUserData.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AdditionalUserData.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 11/07/20. 6 | // 7 | 8 | import Foundation 9 | import CloudKit 10 | 11 | enum AdditionalUserDataKeys: String { 12 | case userFeatureFlaggingID 13 | } 14 | 15 | struct AdditionalUserData { 16 | let featureFlaggingID: UUID 17 | } 18 | 19 | extension AdditionalUserData { 20 | init?(record: CKRecord) { 21 | guard let featureFlagIDString = record[.userFeatureFlaggingID] as? String, 22 | let uuid = UUID(uuidString: featureFlagIDString) else { 23 | return nil 24 | } 25 | self.featureFlaggingID = uuid 26 | } 27 | } 28 | 29 | extension CKRecord { 30 | 31 | subscript(key: AdditionalUserDataKeys) -> Any? { 32 | get { 33 | return self[key.rawValue] 34 | } 35 | set { 36 | self[key.rawValue] = newValue as? CKRecordValue 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/CloudKitFeatureFlags/CloudKitAbstractions.swift: -------------------------------------------------------------------------------- 1 | // 2 | // CloudKitAbstractions.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 17/07/20. 6 | // 7 | 8 | import CloudKit 9 | 10 | public protocol Database { 11 | func fetch(withRecordID recordID: CKRecord.ID, completionHandler: @escaping (CKRecord?, Error?) -> Void) 12 | func save(_ record: CKRecord, completionHandler: @escaping (CKRecord?, Error?) -> Void) 13 | func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) 14 | } 15 | 16 | public protocol Container { 17 | 18 | /// I'd love to name this `publicCloudDatabase` but Swift won't let me 19 | var featureFlaggingDatabase: Database { get } 20 | 21 | func fetchUserRecordID(completionHandler: @escaping (CKRecord.ID?, Error?) -> Void) 22 | } 23 | 24 | extension CKDatabase: Database { } 25 | 26 | extension CKContainer: Container { 27 | public var featureFlaggingDatabase: Database { 28 | return publicCloudDatabase 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/CloudKitFeatureFlags/FeatureFlag+CloudKit.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlag+CloudKit.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 11/07/20. 6 | // 7 | 8 | import Foundation 9 | import CloudKit 10 | 11 | extension FeatureFlag { 12 | init?(record: CKRecord) { 13 | guard let uuidString = record[.featureFlagUUID] as? String, 14 | let uuid = UUID(uuidString: uuidString), 15 | let rollout = record[.rollout] as? Double, 16 | let value = record[.value] as? Bool 17 | else { 18 | return nil 19 | } 20 | 21 | self.name = FeatureFlag.Name(rawValue: record.recordID.recordName) 22 | self.uuid = uuid 23 | self.rollout = Float(rollout) 24 | self.value = value 25 | } 26 | 27 | //TODO: Fix 28 | public func convertToRecord() -> CKRecord { 29 | let record = CKRecord(recordType: "FeatureFlag", recordID: .init(recordName: self.name.rawValue)) 30 | record[.rollout] = self.rollout 31 | record[.value] = self.value 32 | record[.featureFlagUUID] = self.uuid.uuidString 33 | return record 34 | } 35 | } 36 | 37 | extension CKRecord { 38 | subscript(key: FeatureFlagKey) -> Any? { 39 | get { 40 | return self[key.rawValue] 41 | } 42 | set { 43 | self[key.rawValue] = newValue as? CKRecordValue 44 | } 45 | } 46 | 47 | } 48 | -------------------------------------------------------------------------------- /Sources/CloudKitFeatureFlags/FeatureFlag.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlag.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 11/07/20. 6 | // 7 | 8 | import Foundation 9 | 10 | enum FeatureFlagKey: String { 11 | /// "uuid" appears to be reserved 12 | case featureFlagUUID 13 | case rollout 14 | case value 15 | } 16 | 17 | public struct FeatureFlag { 18 | 19 | public struct Name: RawRepresentable, Hashable, Equatable { 20 | public let rawValue: String 21 | public init(rawValue: String) { self.rawValue = rawValue } 22 | } 23 | 24 | let name: FeatureFlag.Name 25 | let uuid: UUID 26 | let rollout: Float 27 | let value: Bool 28 | 29 | public init(name: Name, uuid: UUID, rollout: Float, value: Bool) { 30 | self.name = name 31 | self.uuid = uuid 32 | self.rollout = rollout 33 | self.value = value 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/CloudKitFeatureFlags/FeatureFlagCoordinator.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagCoordinator.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 11/07/20. 6 | // 7 | 8 | import Foundation 9 | import CloudKit 10 | import Combine 11 | import CryptoKit 12 | 13 | public class CloudKitFeatureFlagsRepository { 14 | 15 | let container: Container 16 | //TODO: make this a store that's updated from CK subscription 17 | private let featureFlagsFuture: Future<[FeatureFlag.Name: FeatureFlag], Error> 18 | private let userDataFuture: Future 19 | 20 | public init(container: Container) { 21 | self.container = container 22 | self.userDataFuture = Future { (promise) in 23 | container.fetchUserRecordID { (recordID, error) in 24 | guard let recordID = recordID else { 25 | //TODO: Fix 26 | promise(.failure(error!)) 27 | return 28 | } 29 | container.featureFlaggingDatabase.fetch(withRecordID: recordID) { (record, error) in 30 | guard let record = record else { 31 | //TODO: Fix 32 | promise(.failure(error!)) 33 | return 34 | } 35 | guard let data = AdditionalUserData(record: record) else { 36 | /// User doesn't have an ID set 37 | record[.userFeatureFlaggingID] = UUID().uuidString 38 | container.featureFlaggingDatabase.save(record) { (record, error) in 39 | guard let record = record, let data = AdditionalUserData(record: record) else { 40 | //TODO: Fix 41 | promise(.failure(error!)) 42 | return 43 | } 44 | promise(.success(data)) 45 | } 46 | return 47 | } 48 | promise(.success(data)) 49 | } 50 | } 51 | } 52 | 53 | self.featureFlagsFuture = Future { (promise) in 54 | let query = CKQuery(recordType: "FeatureFlag", predicate: NSPredicate(value: true)) 55 | 56 | container.featureFlaggingDatabase.perform(query, inZoneWith: nil) { (records, error) in 57 | guard let records = records else { 58 | //TODO: Fix 59 | promise(.failure(error!)) 60 | return 61 | } 62 | let flags = records.compactMap(FeatureFlag.init).reduce(into: [:], { (dict, flag) in 63 | dict[flag.name] = flag 64 | }) 65 | promise(.success(flags)) 66 | } 67 | } 68 | } 69 | 70 | @discardableResult public func featureEnabled(name: String) -> AnyPublisher { 71 | Publishers.CombineLatest(featureFlagsFuture, userDataFuture).map { (dict, userData) -> Bool in 72 | guard let ff = dict[FeatureFlag.Name(rawValue: name)] else { 73 | return false 74 | } 75 | //TODO: figure out what to do here 76 | return FlaggingLogic.shouldBeActive(hash: FlaggingLogic.userFeatureFlagHash(flagUUID: ff.uuid, userUUID: userData.featureFlaggingID), rollout: ff.rollout) 77 | }.eraseToAnyPublisher() 78 | } 79 | } 80 | 81 | 82 | extension CloudKitFeatureFlagsRepository { 83 | public struct VerificationFunctions { 84 | private var base: CloudKitFeatureFlagsRepository 85 | private var session: URLSession 86 | init(_ base: CloudKitFeatureFlagsRepository, session: URLSession = .shared) { 87 | self.base = base 88 | self.session = session 89 | } 90 | 91 | func getDebuggingData() -> AnyPublisher<([FeatureFlag.Name: FeatureFlag], AdditionalUserData),Error> { 92 | return base.getDebuggingData() 93 | } 94 | 95 | public func sendDataToVerificationServer(url: URL) -> AnyPublisher<(data: Data, response: URLResponse), Error> { 96 | let requests = getDebuggingData() 97 | .tryMap { (flags, userData) -> URLRequest? in 98 | guard let userIDData = userData.featureFlaggingID.uuidString.data(using: .utf8) else { 99 | return nil 100 | } 101 | let hash = SHA256.hash(data: userIDData) 102 | var bodyObj: [String: Any] = [ 103 | "userID": hash.compactMap { String(format: "%02x", $0) }.joined() 104 | ] 105 | for (key, value) in flags { 106 | bodyObj[key.rawValue] = value.value 107 | } 108 | let data = try JSONSerialization.data(withJSONObject: bodyObj, options: []) 109 | 110 | var request = URLRequest(url: url) 111 | request.addValue("application/json; charset=utf-8", forHTTPHeaderField: "Content-Type") 112 | request.httpBody = data 113 | request.httpMethod = "POST" 114 | return request 115 | }.compactMap { $0 } 116 | .map({ (request) in 117 | return URLSession.shared.dataTaskPublisher(for: request).mapError{ $0 as Error } 118 | }) 119 | 120 | return Publishers.SwitchToLatest(upstream: requests).eraseToAnyPublisher() 121 | } 122 | } 123 | 124 | public var DEBUGGING_AND_VERIFICATION: VerificationFunctions { .init(self) } 125 | private func getDebuggingData() -> AnyPublisher<([FeatureFlag.Name: FeatureFlag], AdditionalUserData),Error> { 126 | return Publishers.CombineLatest(featureFlagsFuture, userDataFuture).eraseToAnyPublisher() 127 | } 128 | } 129 | -------------------------------------------------------------------------------- /Sources/CloudKitFeatureFlags/FeatureFlagStorage.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagStorage.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 04/04/2021. 6 | // 7 | 8 | import Foundation 9 | 10 | public protocol FeatureFlagStorage: AnyObject { 11 | func set(name: String, value: Bool) 12 | func get(name: String) -> Bool 13 | } 14 | 15 | public class UserDefaultsFeatureFlagStorage: FeatureFlagStorage { 16 | 17 | let userDefaults: UserDefaults 18 | 19 | 20 | public init(userDefaults: UserDefaults) { 21 | self.userDefaults = userDefaults 22 | } 23 | 24 | public func set(name: String, value: Bool) { 25 | userDefaults.setValue(value, forKey: name) 26 | } 27 | 28 | public func get(name: String) -> Bool { 29 | userDefaults.bool(forKey: name) 30 | } 31 | 32 | } 33 | -------------------------------------------------------------------------------- /Sources/CloudKitFeatureFlags/FlaggingLogic.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlaggingLogic.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 11/07/20. 6 | // 7 | 8 | import Foundation 9 | 10 | struct FlaggingLogic { 11 | 12 | static func hashEvaluation(uuid: UUID) -> Int { 13 | Int(uuid.uuidString.unicodeScalars.map { $0.value }.reduce(0, +)) 14 | } 15 | 16 | static func userFeatureFlagHash(flagUUID: UUID, userUUID: UUID) -> Int { 17 | hashEvaluation(uuid: flagUUID) / 2 + hashEvaluation(uuid: userUUID) / 2 18 | } 19 | 20 | static func shouldBeActive(hash: Int, rollout: Float) -> Bool { 21 | if rollout < 0.1 { 22 | return false 23 | } else if rollout > 0.9 { 24 | return true 25 | } else { 26 | return Float(hash % 10) < (rollout * Float(10)) 27 | } 28 | } 29 | 30 | } 31 | -------------------------------------------------------------------------------- /Tests/CloudKitFeatureFlagsTests/FeatureFlagCoordinatorTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagCoordinatorTests.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 17/07/20. 6 | // 7 | 8 | import XCTest 9 | import CloudKit 10 | @testable import CloudKitFeatureFlags 11 | 12 | /// Simulates the user data retrieval that CloudKit would do for us 13 | protocol UserDataDelegate: class { 14 | func fetchUserRecordID(completionHandler: @escaping (CKRecord.ID?, Error?) -> Void) 15 | func fetch(withRecordID recordID: CKRecord.ID, completionHandler: @escaping (CKRecord?, Error?) -> Void) 16 | } 17 | 18 | class TestUserDataDelegate: UserDataDelegate { 19 | 20 | let userRecordName: UUID 21 | let userFeatureFlagID: UUID 22 | 23 | internal init(userRecordName: UUID, userFeatureFlagID: UUID) { 24 | self.userRecordName = userRecordName 25 | self.userFeatureFlagID = userFeatureFlagID 26 | } 27 | 28 | func fetch(withRecordID recordID: CKRecord.ID, completionHandler: @escaping (CKRecord?, Error?) -> Void) { 29 | let record = CKRecord(recordType: "random", recordID: .init(recordName: userRecordName.uuidString)) 30 | record[.userFeatureFlaggingID] = userFeatureFlagID 31 | completionHandler(record, nil) 32 | } 33 | 34 | func fetchUserRecordID(completionHandler: @escaping (CKRecord.ID?, Error?) -> Void) { 35 | completionHandler(.init(recordName: userRecordName.uuidString), nil) 36 | } 37 | } 38 | 39 | class TestContainer: Container { 40 | 41 | let database: Database 42 | weak var userDataDelegate: UserDataDelegate? 43 | 44 | internal init(database: Database) { 45 | self.database = database 46 | } 47 | 48 | func fetchUserRecordID(completionHandler: @escaping (CKRecord.ID?, Error?) -> Void) { 49 | userDataDelegate?.fetchUserRecordID(completionHandler: completionHandler) 50 | } 51 | 52 | var featureFlaggingDatabase: Database { 53 | return database 54 | } 55 | } 56 | 57 | class TestDatabase: Database { 58 | 59 | let featureFlags: [FeatureFlag] 60 | weak var delegate: UserDataDelegate? 61 | 62 | init(featureFlags: [FeatureFlag]) { 63 | self.featureFlags = featureFlags 64 | } 65 | 66 | func fetch(withRecordID recordID: CKRecord.ID, completionHandler: @escaping (CKRecord?, Error?) -> Void) { 67 | self.delegate?.fetch(withRecordID: recordID, completionHandler: completionHandler) 68 | } 69 | 70 | func save(_ record: CKRecord, completionHandler: @escaping (CKRecord?, Error?) -> Void) { 71 | completionHandler(record, nil) 72 | } 73 | 74 | func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) { 75 | completionHandler(featureFlags.map { $0.convertToRecord() }, nil) 76 | } 77 | } 78 | 79 | 80 | final class FeatureFlagCoordinatorTests: XCTestCase { 81 | 82 | func testStubbedCoordinator() { 83 | let userPopulation = 100_000 84 | 85 | let rollouts = (0...10).map { i in Float(i)/10.0 } 86 | let featureFlags = rollouts.map { rollout in FeatureFlag(name: FeatureFlag.Name(rawValue: UUID().uuidString), uuid: UUID(), rollout: rollout, value: true) } 87 | let testDatabase = TestDatabase(featureFlags: featureFlags) 88 | let testContainer = TestContainer(database: testDatabase) 89 | let userProxies = (0.. TestContainer { 93 | container.userDataDelegate = userDataDelegate 94 | (container.database as? TestDatabase)?.delegate = userDataDelegate 95 | return container 96 | } 97 | 98 | func simulate(flags: [FeatureFlag], in repo: CloudKitFeatureFlagsRepository, currentResultSet: [FeatureFlag.Name: Int]) -> [FeatureFlag.Name: Int] { 99 | return flags.reduce(into: currentResultSet) { (currentResults, flag) in 100 | var calculatedValue: Bool! 101 | let dispatchSemaphore = DispatchSemaphore(value: 0) 102 | _ = repo.featureEnabled(name: flag.name.rawValue) 103 | .sink(receiveCompletion: { (_) in }, receiveValue: { (value) in 104 | calculatedValue = value 105 | dispatchSemaphore.signal() 106 | }) 107 | dispatchSemaphore.wait() 108 | currentResults[flag.name, default: 0] += (calculatedValue == true) ? 1 : 0 109 | } 110 | } 111 | 112 | let collectedResults = userProxies.reduce([FeatureFlag.Name: Int]()) { (calculation, userProxy) in 113 | let testContainer = switchUser(in: testContainer, with: userProxy) 114 | let coordinator = CloudKitFeatureFlagsRepository(container: testContainer) 115 | return simulate(flags: featureFlags, in: coordinator, currentResultSet: calculation) 116 | } 117 | 118 | for flag in featureFlags { 119 | /// 0.5% accuracy 120 | let measuredRollout = Float(collectedResults[flag.name]!) / (Float(userPopulation)) 121 | print(measuredRollout) 122 | XCTAssertEqual(measuredRollout, flag.rollout, accuracy: 0.005) 123 | } 124 | 125 | 126 | } 127 | 128 | static var allTests = [ 129 | ("testStubbedCoordinator", testStubbedCoordinator), 130 | ] 131 | 132 | } 133 | -------------------------------------------------------------------------------- /Tests/CloudKitFeatureFlagsTests/FeatureFlagStorageTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FeatureFlagStorageTests.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 04/04/2021. 6 | // 7 | 8 | import XCTest 9 | import CloudKit 10 | @testable import CloudKitFeatureFlags 11 | 12 | final class UserDefaultFeatureFlagStorageTests: XCTestCase { 13 | 14 | let featureFlagStorage = UserDefaultsFeatureFlagStorage(userDefaults: .standard) 15 | 16 | func testGetterAndSetterInStorage() { 17 | featureFlagStorage.set(name: "test", value: true) 18 | XCTAssertEqual(featureFlagStorage.get(name: "test"), true) 19 | } 20 | 21 | static var allTests = [ 22 | ("testGetterAndSetterInStorage", testGetterAndSetterInStorage) 23 | ] 24 | } 25 | -------------------------------------------------------------------------------- /Tests/CloudKitFeatureFlagsTests/FlaggingLogicTests.swift: -------------------------------------------------------------------------------- 1 | // 2 | // FlaggingLogicTests.swift 3 | // 4 | // 5 | // Created by Robin Malhotra on 11/07/20. 6 | // 7 | 8 | import XCTest 9 | @testable import CloudKitFeatureFlags 10 | 11 | final class FlaggingLogicTests: XCTestCase { 12 | 13 | func testIncrementalRollouTo100Percent() { 14 | let population: Float = 100_000 15 | let users = (0.. [XCTestCaseEntry] { 5 | return [ 6 | testCase(CloudKitFeatureFlagsTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import CloudKitFeatureFlagsTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += CloudKitFeatureFlagsTests.allTests() 7 | XCTMain(tests) 8 | --------------------------------------------------------------------------------