├── .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 | 
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!) 
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 |
--------------------------------------------------------------------------------