├── .github
├── ISSUE_TEMPLATE
│ ├── bug_report.md
│ └── feature_request.md
└── workflows
│ └── swift.yml
├── .gitignore
├── .swiftpm
└── xcode
│ └── package.xcworkspace
│ ├── contents.xcworkspacedata
│ └── xcshareddata
│ └── IDEWorkspaceChecks.plist
├── LICENSE
├── Package.swift
├── README.md
├── Sources
└── CloudKitFeatureToggles
│ ├── CloudKitSubscriptionProtocol.swift
│ ├── FeatureToggleApplicationService.swift
│ ├── FeatureToggleMapper.swift
│ ├── FeatureToggleRepository.swift
│ ├── FeatureToggleSubscriptor.swift
│ └── Notification+Extensions.swift
└── Tests
├── CloudKitFeatureTogglesTests
├── FeatureToggleApplicationServiceTests.swift
├── FeatureToggleMapperTests.swift
├── FeatureToggleRepositoryTests.swift
├── FeatureToggleSubscriptorTests.swift
└── XCTestManifests.swift
└── LinuxMain.swift
/.github/ISSUE_TEMPLATE/bug_report.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Bug report
3 | about: Create a report to help us improve
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Describe the bug**
11 | A clear and concise description of what the bug is.
12 |
13 | **To Reproduce**
14 | Steps to reproduce the behavior:
15 | 1. Go to '...'
16 | 2. Click on '....'
17 | 3. Scroll down to '....'
18 | 4. See error
19 |
20 | **Expected behavior**
21 | A clear and concise description of what you expected to happen.
22 |
23 | **Screenshots**
24 | If applicable, add screenshots to help explain your problem.
25 |
26 | **Additional context**
27 | Add any other context about the problem here.
28 |
--------------------------------------------------------------------------------
/.github/ISSUE_TEMPLATE/feature_request.md:
--------------------------------------------------------------------------------
1 | ---
2 | name: Feature request
3 | about: Suggest an idea for this project
4 | title: ''
5 | labels: ''
6 | assignees: ''
7 |
8 | ---
9 |
10 | **Is your feature request related to a problem? Please describe.**
11 | A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
12 |
13 | **Describe the solution you'd like**
14 | A clear and concise description of what you want to happen.
15 |
16 | **Describe alternatives you've considered**
17 | A clear and concise description of any alternative solutions or features you've considered.
18 |
19 | **Additional context**
20 | Add any other context or screenshots about the feature request here.
21 |
--------------------------------------------------------------------------------
/.github/workflows/swift.yml:
--------------------------------------------------------------------------------
1 | name: Swift
2 |
3 | on: [push]
4 |
5 | jobs:
6 | build:
7 |
8 | runs-on: macOS-latest
9 |
10 | steps:
11 | - uses: actions/checkout@v1
12 | - name: Build
13 | run: swift build -v
14 | - name: Prepare xcodeproj
15 | run: swift package generate-xcodeproj
16 | - name: Run tests
17 | run: xcodebuild test -scheme CloudKitFeatureToggles-Package -destination platform="macOS" -enableCodeCoverage YES -derivedDataPath .build/derivedData
18 | - name: Codecov
19 | run: bash <(curl -s https://codecov.io/bash) -D .build/derivedData/ -t ${{ secrets.CODECOV_TOKEN }} -J '^CloudKitFeatureToggles$'
--------------------------------------------------------------------------------
/.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 |
--------------------------------------------------------------------------------
/LICENSE:
--------------------------------------------------------------------------------
1 | MIT License
2 |
3 | Copyright (c) 2020 Jonas Reichert
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.1
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: "CloudKitFeatureToggles",
8 | platforms: [
9 | .iOS(SupportedPlatform.IOSVersion.v10),
10 | .macOS(SupportedPlatform.MacOSVersion.v10_12),
11 | .tvOS(SupportedPlatform.TVOSVersion.v9),
12 | .watchOS(SupportedPlatform.WatchOSVersion.v3)
13 | ],
14 | products: [
15 | // Products define the executables and libraries produced by a package, and make them visible to other packages.
16 | .library(
17 | name: "CloudKitFeatureToggles",
18 | targets: ["CloudKitFeatureToggles"]),
19 | ],
20 | dependencies: [
21 | // Dependencies declare other packages that this package depends on.
22 | // .package(url: /* package url */, from: "1.0.0"),
23 | ],
24 | targets: [
25 | // Targets are the basic building blocks of a package. A target can define a module or a test suite.
26 | // Targets can depend on other targets in this package, and on products in packages which this package depends on.
27 | .target(
28 | name: "CloudKitFeatureToggles",
29 | dependencies: []),
30 | .testTarget(
31 | name: "CloudKitFeatureTogglesTests",
32 | dependencies: ["CloudKitFeatureToggles"]),
33 | ]
34 | )
35 |
--------------------------------------------------------------------------------
/README.md:
--------------------------------------------------------------------------------
1 | # CloudKit FeatureToggles
2 |
3 | 
4 | [](https://codecov.io/gh/JonnyBeeGod/CloudKitFeatureToggles)
5 |
6 |
7 |
8 |
9 |
10 |
11 |
12 |
13 |
14 | ## What does it do?
15 | Feature Toggles offer a way to enable or disable certain features that are present in your codebase, switch environments or configurations or toggle between multiple implementations of a protocol - even in your live system at runtime. *CloudKit FeatureToggles* are implemented using `CloudKit` and are therefor associated with no run costs for the developer. Existing Feature Toggles can be changed in the [CloudKit Dashboard](https://icloud.developer.apple.com/dashboard/) and are delivered immediately via silent push notifications to your users.
16 |
17 | ## How to install?
18 | CloudKitFeatureToggles is compatible with Swift Package Manager. To install, simply add this repository URL to your swift packages as package dependency in Xcode.
19 | Alternatively, add this line to your `Package.swift` file:
20 |
21 | ```
22 | dependencies: [
23 | .package(url: "https://github.com/JonnyBeeGod/CloudKitFeatureToggles", from: "0.1.0")
24 | ]
25 | ```
26 |
27 | And don't forget to add the dependency to your target(s).
28 |
29 | ## How to use?
30 |
31 | ### CloudKit Preparations
32 | 1. If your application does not support CloudKit yet start with adding the `CloudKit` and `remote background notification` entitlements to your application
33 | 2. Add a new custom record type 'FeatureStatus' with two fields:
34 |
35 | | Field | Type |
36 | | --- | --- |
37 | | `featureName` | `String` |
38 | | `isActive` | `Int64` |
39 |
40 | For each feature toggle you want to support in your application later add a new record in your CloudKit *public database*.
41 |
42 | ### In your project
43 | 1. In your AppDelegate, initialize a `FeatureToggleApplicationService` and hook its two `UIApplicationDelegate` methods into the AppDelegate lifecycle like so:
44 |
45 | ```
46 | func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool {
47 | // Override point for customization after application launch.
48 | return featureToggleApplicationService.application(application, didFinishLaunchingWithOptions: launchOptions)
49 | }
50 | func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable : Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
51 | featureToggleApplicationService.application(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: completionHandler)
52 | }
53 |
54 | ```
55 | 2. Anywhere in your code you can create an instance of `FeatureToggleUserDefaultsRepository` and call `retrieve` to fetch the current status of a feature toggle.
56 |
57 | > :warning: Note that `retrieve` returns the locally saved status of your toggle, this command does not trigger a fetch from CloudKit. Feature Toggles are fetched from CloudKit once at app start from within the `FeatureToggleApplicationService` `UIApplicationDelegate` hook. Additionally you can subscribe to updates whenever there was a change to the feature toggles in CloudKit as shown in the next section.
58 |
59 | 3. You have to call `retrieve` with your implementation of a `FeatureToggleIdentifiable`. What I think works well is creating an enum which implements `FeatureToggleIdentifiable`:
60 |
61 | ```
62 | enum FeatureToggle: String, FeatureToggleIdentifiable {
63 | case feature1
64 | case feature2
65 |
66 | var identifier: String {
67 | return self.rawValue
68 | }
69 |
70 | var fallbackValue: Bool {
71 | switch self {
72 | case .feature1:
73 | return false
74 | case .feature2:
75 | return true
76 | }
77 | }
78 | }
79 | ```
80 | ### Notifications
81 |
82 | You can subscribe to updates from your feature toggles in CloudKit by subscribing to the `onRecordsUpdated` Notification like so:
83 |
84 | ```
85 | NotificationCenter.default.addObserver(self, selector: #selector(updateToggleStatusFromNotification), name: NSNotification.Name.onRecordsUpdated, object: nil)
86 | ```
87 |
88 | ```
89 | @objc
90 | private func updateToggleStatusFromNotification(notification NSNotification) {
91 | guard let updatedToggles = notification.userInfo[Notification.featureToggleUserInfoKey] as? [FeatureToggle] else {
92 | return
93 | }
94 |
95 | // do something with the updated toggle like e.g. disabling UI elements
96 | }
97 | ```
98 |
99 | Note that the updated Feature Toggles are attached to the notifications userInfo dictionary. When this notification has been sent the updated values are also already stored in the repository.
100 |
--------------------------------------------------------------------------------
/Sources/CloudKitFeatureToggles/CloudKitSubscriptionProtocol.swift:
--------------------------------------------------------------------------------
1 | //
2 | // CloudKitSubscriptionProtocol.swift
3 | // nSuns
4 | //
5 | // Created by Jonas Reichert on 01.09.18.
6 | // Copyright © 2018 Jonas Reichert. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CloudKit
11 |
12 | protocol CloudKitDatabaseConformable {
13 | func add(_ operation: CKDatabaseOperation)
14 | func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void)
15 | }
16 |
17 | extension CKDatabase: CloudKitDatabaseConformable {}
18 |
19 | protocol CloudKitSubscriptionProtocol {
20 | var subscriptionID: String { get }
21 | var database: CloudKitDatabaseConformable { get }
22 |
23 | func fetchAll()
24 | func saveSubscription()
25 | func handleNotification()
26 | }
27 |
28 | extension CloudKitSubscriptionProtocol {
29 | func saveSubscription(subscriptionID: String, recordType: String, defaults: UserDefaults) {
30 | // Let's keep a local flag handy to avoid saving the subscription more than once.
31 | // Even if you try saving the subscription multiple times, the server doesn't save it more than once
32 | // Nevertheless, let's save some network operation and conserve resources
33 | let subscriptionSaved = defaults.bool(forKey: subscriptionID)
34 | guard !subscriptionSaved else {
35 | return
36 | }
37 |
38 | // Subscribing is nothing but saving a query which the server would use to generate notifications.
39 | // The below predicate (query) will raise a notification for all changes.
40 | let predicate = NSPredicate(value: true)
41 | let subscription = CKQuerySubscription(recordType: recordType, predicate: predicate, subscriptionID: subscriptionID, options: CKQuerySubscription.Options.firesOnRecordUpdate)
42 |
43 | let notificationInfo = CKSubscription.NotificationInfo()
44 | // Set shouldSendContentAvailable to true for receiving silent pushes
45 | // Silent notifications are not shown to the user and don’t require the user's permission.
46 | notificationInfo.shouldSendContentAvailable = true
47 | subscription.notificationInfo = notificationInfo
48 |
49 | // Use CKModifySubscriptionsOperation to save the subscription to CloudKit
50 | let operation = CKModifySubscriptionsOperation(subscriptionsToSave: [subscription], subscriptionIDsToDelete: [])
51 | operation.modifySubscriptionsCompletionBlock = { (_, _, error) in
52 | guard error == nil else {
53 | return
54 | }
55 | defaults.set(true, forKey: subscriptionID)
56 | }
57 | operation.qualityOfService = .utility
58 | // Add the operation to the corresponding private or public database
59 | database.add(operation)
60 | }
61 |
62 | func handleNotification(recordType: String, recordFetchedBlock: @escaping (CKRecord) -> Void) {
63 | let queryOperation = CKQueryOperation(query: query(recordType: recordType))
64 |
65 | queryOperation.recordFetchedBlock = recordFetchedBlock
66 | queryOperation.qualityOfService = .utility
67 |
68 | database.add(queryOperation)
69 | }
70 |
71 | func fetchAll(recordType: String, handler: @escaping ([CKRecord]) -> Void) {
72 | database.perform(query(recordType: recordType), inZoneWith: nil) { (ckRecords, error) in
73 | guard error == nil, let ckRecords = ckRecords else {
74 | // don't update last fetched date, simply do nothing and try again next time
75 | return
76 | }
77 |
78 | handler(ckRecords)
79 | }
80 | }
81 |
82 | private func query(recordType: String) -> CKQuery {
83 | let predicate = NSPredicate(value: true)
84 | return CKQuery(recordType: recordType, predicate: predicate)
85 | }
86 | }
87 |
--------------------------------------------------------------------------------
/Sources/CloudKitFeatureToggles/FeatureToggleApplicationService.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureToggleApplicationService.swift
3 | // CloudKitFeatureToggles
4 | //
5 | // Created by Jonas Reichert on 04.01.20.
6 | //
7 |
8 | import Foundation
9 | import CloudKit
10 | #if canImport(UIKit)
11 | import UIKit
12 | #endif
13 |
14 | public protocol FeatureToggleApplicationServiceProtocol {
15 | var featureToggleRepository: FeatureToggleRepository { get }
16 |
17 | #if canImport(UIKit)
18 | func register(application: UIApplication)
19 | func handleRemoteNotification(subscriptionID: String?, completionHandler: @escaping (UIBackgroundFetchResult) -> Void)
20 | #endif
21 | }
22 |
23 | public class FeatureToggleApplicationService: NSObject, FeatureToggleApplicationServiceProtocol {
24 |
25 | private var featureToggleSubscriptor: CloudKitSubscriptionProtocol
26 | private (set) public var featureToggleRepository: FeatureToggleRepository
27 |
28 | public convenience init(featureToggleRepository: FeatureToggleRepository = FeatureToggleUserDefaultsRepository()) {
29 | self.init(featureToggleSubscriptor: FeatureToggleSubscriptor(toggleRepository: featureToggleRepository), featureToggleRepository: featureToggleRepository)
30 | }
31 |
32 | init(featureToggleSubscriptor: CloudKitSubscriptionProtocol, featureToggleRepository: FeatureToggleRepository) {
33 | self.featureToggleSubscriptor = featureToggleSubscriptor
34 | self.featureToggleRepository = featureToggleRepository
35 | }
36 |
37 | #if canImport(UIKit)
38 | public func register(application: UIApplication) {
39 | application.registerForRemoteNotifications()
40 | featureToggleSubscriptor.saveSubscription()
41 | featureToggleSubscriptor.fetchAll()
42 | }
43 |
44 | public func handleRemoteNotification(subscriptionID: String?, completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
45 | if let subscriptionID = subscriptionID, featureToggleSubscriptor.subscriptionID == subscriptionID {
46 | featureToggleSubscriptor.handleNotification()
47 | completionHandler(.newData)
48 | }
49 | else {
50 | completionHandler(.noData)
51 | }
52 | }
53 | #endif
54 | }
55 |
56 | #if canImport(UIKit)
57 | extension FeatureToggleApplicationService: UIApplicationDelegate {
58 | public func application(_ application: UIApplication, didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]? = nil) -> Bool {
59 | register(application: application)
60 |
61 | return true
62 | }
63 |
64 | public func application(_ application: UIApplication, didReceiveRemoteNotification userInfo: [AnyHashable: Any], fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void) {
65 | guard let notification = CKNotification(fromRemoteNotificationDictionary: userInfo), let subscriptionID = notification.subscriptionID else {
66 | return
67 | }
68 |
69 | handleRemoteNotification(subscriptionID: subscriptionID, completionHandler: completionHandler)
70 | }
71 | }
72 | #endif
73 |
--------------------------------------------------------------------------------
/Sources/CloudKitFeatureToggles/FeatureToggleMapper.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureToggleMapper.swift
3 | // CloudKitFeatureToggles
4 | //
5 | // Created by Jonas Reichert on 06.01.20.
6 | //
7 |
8 | import Foundation
9 | import CloudKit
10 |
11 | public protocol FeatureToggleRepresentable {
12 | var identifier: String { get }
13 | var isActive: Bool { get }
14 | }
15 |
16 | public protocol FeatureToggleIdentifiable {
17 | var identifier: String { get }
18 | var fallbackValue: Bool { get }
19 | }
20 |
21 | public struct FeatureToggle: FeatureToggleRepresentable, Equatable {
22 | public let identifier: String
23 | public let isActive: Bool
24 | }
25 |
26 | protocol FeatureToggleMappable {
27 | func map(record: CKRecord) -> FeatureToggle?
28 | }
29 |
30 | class FeatureToggleMapper: FeatureToggleMappable {
31 | private let featureToggleNameFieldID: String
32 | private let featureToggleIsActiveFieldID: String
33 |
34 | init(featureToggleNameFieldID: String, featureToggleIsActiveFieldID: String) {
35 | self.featureToggleNameFieldID = featureToggleNameFieldID
36 | self.featureToggleIsActiveFieldID = featureToggleIsActiveFieldID
37 | }
38 |
39 | func map(record: CKRecord) -> FeatureToggle? {
40 | guard let isActive = record[featureToggleIsActiveFieldID] as? Int64, let featureName = record[featureToggleNameFieldID] as? String else {
41 | return nil
42 | }
43 |
44 | return FeatureToggle(identifier: featureName, isActive: NSNumber(value: isActive).boolValue)
45 | }
46 | }
47 |
--------------------------------------------------------------------------------
/Sources/CloudKitFeatureToggles/FeatureToggleRepository.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureToggleManager.swift
3 | // CloudKitFeatureToggles
4 | //
5 | // Created by Jonas Reichert on 01.01.20.
6 | //
7 |
8 | import Foundation
9 |
10 | public protocol FeatureToggleRepository {
11 | /// retrieves a stored `FeatureToggleRepresentable` from the underlying store.
12 | func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable
13 | /// saves a supplied `FeatureToggleRepresentable` to the underlying store
14 | func save(featureToggle: FeatureToggleRepresentable)
15 | }
16 |
17 | public class FeatureToggleUserDefaultsRepository {
18 |
19 | private static let defaultsSuiteName = "featureToggleUserDefaultsRepositorySuite"
20 | private let defaults: UserDefaults
21 |
22 | public init(defaults: UserDefaults? = nil) {
23 | self.defaults = defaults ?? UserDefaults(suiteName: FeatureToggleUserDefaultsRepository.defaultsSuiteName) ?? .standard
24 | }
25 | }
26 |
27 | extension FeatureToggleUserDefaultsRepository: FeatureToggleRepository {
28 | public func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable {
29 | let isActive = defaults.value(forKey: identifiable.identifier) as? Bool
30 |
31 | return FeatureToggle(identifier: identifiable.identifier, isActive: isActive ?? identifiable.fallbackValue)
32 | }
33 |
34 | public func save(featureToggle: FeatureToggleRepresentable) {
35 | defaults.set(featureToggle.isActive, forKey: featureToggle.identifier)
36 | }
37 | }
38 |
--------------------------------------------------------------------------------
/Sources/CloudKitFeatureToggles/FeatureToggleSubscriptor.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureSwitchHelper.swift
3 | // nSuns
4 | //
5 | // Created by Jonas Reichert on 22.07.18.
6 | // Copyright © 2018 Jonas Reichert. All rights reserved.
7 | //
8 |
9 | import Foundation
10 | import CloudKit
11 |
12 | class FeatureToggleSubscriptor: CloudKitSubscriptionProtocol {
13 |
14 | private static let defaultsSuiteName = "featureToggleDefaultsSuite"
15 | private let featureToggleRecordID: String
16 |
17 | private let toggleRepository: FeatureToggleRepository
18 | private let toggleMapper: FeatureToggleMappable
19 | private let defaults: UserDefaults
20 | private let notificationCenter: NotificationCenter
21 |
22 | let subscriptionID = "cloudkit-recordType-FeatureToggle"
23 | let database: CloudKitDatabaseConformable
24 |
25 | init(toggleRepository: FeatureToggleRepository = FeatureToggleUserDefaultsRepository(), toggleMapper: FeatureToggleMappable? = nil, featureToggleRecordID: String = "FeatureStatus", featureToggleNameFieldID: String = "featureName", featureToggleIsActiveFieldID: String = "isActive", defaults: UserDefaults = UserDefaults(suiteName: FeatureToggleSubscriptor.defaultsSuiteName) ?? .standard, notificationCenter: NotificationCenter = .default, cloudKitDatabaseConformable: CloudKitDatabaseConformable = CKContainer.default().publicCloudDatabase) {
26 | self.toggleRepository = toggleRepository
27 | self.toggleMapper = toggleMapper ?? FeatureToggleMapper(featureToggleNameFieldID: featureToggleNameFieldID, featureToggleIsActiveFieldID: featureToggleIsActiveFieldID)
28 | self.featureToggleRecordID = featureToggleRecordID
29 | self.defaults = defaults
30 | self.notificationCenter = notificationCenter
31 | self.database = cloudKitDatabaseConformable
32 | }
33 |
34 | func fetchAll() {
35 | fetchAll(recordType: featureToggleRecordID, handler: { (ckRecords) in
36 | let toggles = ckRecords.compactMap { (record) -> FeatureToggle? in
37 | return self.toggleMapper.map(record: record)
38 | }
39 |
40 | self.updateRepository(with: toggles)
41 | self.sendNotification(with: toggles)
42 | })
43 | }
44 |
45 | func saveSubscription() {
46 | saveSubscription(subscriptionID: subscriptionID, recordType: featureToggleRecordID, defaults: defaults)
47 | }
48 |
49 | func handleNotification() {
50 | handleNotification(recordType: featureToggleRecordID) { (record) in
51 | let toggle = self.toggleMapper.map(record: record)
52 |
53 | self.updateRepository(with: [toggle].compactMap { $0 })
54 | self.sendNotification(with: [toggle].compactMap { $0 })
55 | }
56 | }
57 |
58 | private func updateRepository(with toggles: [FeatureToggle]) {
59 | toggles.forEach { (toggle) in
60 | toggleRepository.save(featureToggle: toggle)
61 | }
62 | }
63 |
64 | private func sendNotification(with toggles: [FeatureToggle]) {
65 | notificationCenter.post(name: Notification.Name.onRecordsUpdated, object: nil, userInfo: [Notification.featureTogglesUserInfoKey : toggles])
66 | }
67 | }
68 |
--------------------------------------------------------------------------------
/Sources/CloudKitFeatureToggles/Notification+Extensions.swift:
--------------------------------------------------------------------------------
1 | //
2 | // Notification+Extensions.swift
3 | // CloudKitFeatureToggles
4 | //
5 | // Created by Jonas Reichert on 06.01.20.
6 | //
7 |
8 | import Foundation
9 |
10 | extension Notification.Name {
11 | public static let onRecordsUpdated = Notification.Name("ckFeatureTogglesRecordsUpdatedNotification")
12 | }
13 |
14 | extension Notification {
15 | public static let featureTogglesUserInfoKey = "featureToggles"
16 | }
17 |
--------------------------------------------------------------------------------
/Tests/CloudKitFeatureTogglesTests/FeatureToggleApplicationServiceTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureToggleApplicationServiceTests.swift
3 | // CloudKitFeatureTogglesTests
4 | //
5 | // Created by Jonas Reichert on 05.01.20.
6 | //
7 |
8 | import XCTest
9 | @testable import CloudKitFeatureToggles
10 |
11 | class FeatureToggleApplicationServiceTests: XCTestCase {
12 |
13 | var repository: MockToggleRepository!
14 | var subscriptor: MockFeatureToggleSubscriptor!
15 | var subject: FeatureToggleApplicationServiceProtocol!
16 |
17 | override func setUp() {
18 | repository = MockToggleRepository()
19 | subscriptor = MockFeatureToggleSubscriptor()
20 | subject = FeatureToggleApplicationService(featureToggleSubscriptor: subscriptor, featureToggleRepository: repository)
21 | }
22 |
23 | func testRegister() {
24 | #if canImport(UIKit)
25 | XCTAssertFalse(subscriptor.saveSubscriptionCalled)
26 | XCTAssertFalse(subscriptor.handleCalled)
27 | XCTAssertFalse(subscriptor.fetchAllCalled)
28 |
29 | subject.register(application: UIApplication.shared)
30 |
31 | XCTAssertTrue(subscriptor.saveSubscriptionCalled)
32 | XCTAssertFalse(subscriptor.handleCalled)
33 | XCTAssertTrue(subscriptor.fetchAllCalled)
34 | #endif
35 | }
36 |
37 | func testHandle() {
38 | #if canImport(UIKit)
39 | XCTAssertFalse(subscriptor.saveSubscriptionCalled)
40 | XCTAssertFalse(subscriptor.handleCalled)
41 | XCTAssertFalse(subscriptor.fetchAllCalled)
42 |
43 | subject.handleRemoteNotification(subscriptionID: "Mock", completionHandler: { result in
44 |
45 | })
46 | XCTAssertFalse(subscriptor.saveSubscriptionCalled)
47 | XCTAssertTrue(subscriptor.handleCalled)
48 | XCTAssertFalse(subscriptor.fetchAllCalled)
49 | #endif
50 | }
51 |
52 | static var allTests = [
53 | ("testRegister", testRegister),
54 | ("testHandle", testHandle),
55 | ]
56 | }
57 |
58 | class MockFeatureToggleSubscriptor: CloudKitSubscriptionProtocol {
59 | var subscriptionID: String = "Mock"
60 | var database: CloudKitDatabaseConformable = MockCloudKitDatabaseConformable()
61 |
62 | var saveSubscriptionCalled = false
63 | var handleCalled = false
64 | var fetchAllCalled = false
65 |
66 | func handleNotification() {
67 | handleCalled = true
68 | }
69 |
70 | func saveSubscription() {
71 | saveSubscriptionCalled = true
72 | }
73 |
74 | func fetchAll() {
75 | fetchAllCalled = true
76 | }
77 | }
78 |
--------------------------------------------------------------------------------
/Tests/CloudKitFeatureTogglesTests/FeatureToggleMapperTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureToggleMapperTests.swift
3 | // CloudKitFeatureTogglesTests
4 | //
5 | // Created by Jonas Reichert on 06.01.20.
6 | //
7 |
8 | import XCTest
9 | import CloudKit
10 | @testable import CloudKitFeatureToggles
11 |
12 | class FeatureToggleMapperTests: XCTestCase {
13 |
14 | var subject: FeatureToggleMappable!
15 |
16 | override func setUp() {
17 | subject = FeatureToggleMapper(featureToggleNameFieldID: "featureName", featureToggleIsActiveFieldID: "isActive")
18 | }
19 |
20 | func testMapInvalidInput() {
21 | let everythingWrong = CKRecord(recordType: "RecordType", recordID: CKRecord.ID(recordName: "identifier"))
22 | everythingWrong["bla"] = true
23 | everythingWrong["muh"] = 1283765
24 |
25 | XCTAssertNil(subject.map(record: everythingWrong))
26 |
27 | let wrongFields = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier2"))
28 | wrongFields["bla"] = true
29 | wrongFields["muh"] = 1283765
30 |
31 | XCTAssertNil(subject.map(record: wrongFields))
32 |
33 | let wrongIsActiveField = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier3"))
34 | wrongIsActiveField["bla"] = true
35 | wrongIsActiveField["featureName"] = 1283765
36 |
37 | XCTAssertNil(subject.map(record: wrongIsActiveField))
38 |
39 | let wrongFeatureNameField = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier4"))
40 | wrongFeatureNameField["isActive"] = true
41 | wrongFeatureNameField["muh"] = 1283765
42 |
43 | XCTAssertNil(subject.map(record: wrongFeatureNameField))
44 |
45 | let wrongIsActiveType = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier5"))
46 | wrongIsActiveType["isActive"] = "true"
47 | wrongIsActiveType["featureName"] = "1283765"
48 |
49 | XCTAssertNil(subject.map(record: wrongIsActiveType))
50 |
51 | let wrongFeatureNameType = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier6"))
52 | wrongFeatureNameType["isActive"] = true
53 | wrongFeatureNameType["featureName"] = 1283765
54 |
55 | XCTAssertNil(subject.map(record: wrongFeatureNameType))
56 | }
57 |
58 | func testMap() {
59 | let expectedIdentifier = "1283765"
60 | let expectedIsActive = true
61 |
62 | let record = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier"))
63 | record["isActive"] = expectedIsActive
64 | record["featureName"] = expectedIdentifier
65 |
66 | let result = subject.map(record: record)
67 | XCTAssertNotNil(result)
68 | XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, isActive: expectedIsActive))
69 | }
70 |
71 | func testMap2() {
72 | let expectedIdentifier = "akjshgdjaskd(/(/&%$§"
73 | let expectedIsActive = false
74 |
75 | let record = CKRecord(recordType: "FeatureStatus", recordID: CKRecord.ID(recordName: "identifier"))
76 | record["isActive"] = expectedIsActive
77 | record["featureName"] = expectedIdentifier
78 |
79 | let result = subject.map(record: record)
80 | XCTAssertNotNil(result)
81 | XCTAssertEqual(result, FeatureToggle(identifier: expectedIdentifier, isActive: expectedIsActive))
82 | }
83 |
84 | static var allTests = [
85 | ("testMapInvalidInput", testMapInvalidInput),
86 | ("testMap", testMap),
87 | ("testMap2", testMap2),
88 | ]
89 |
90 | }
91 |
--------------------------------------------------------------------------------
/Tests/CloudKitFeatureTogglesTests/FeatureToggleRepositoryTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureToggleRepositoryTests.swift
3 | // CloudKitFeatureTogglesTests
4 | //
5 | // Created by Jonas Reichert on 01.01.20.
6 | //
7 |
8 | import XCTest
9 | @testable import CloudKitFeatureToggles
10 |
11 | class FeatureToggleRepositoryTests: XCTestCase {
12 |
13 | enum TestToggle: String, FeatureToggleIdentifiable {
14 | var identifier: String {
15 | return self.rawValue
16 | }
17 |
18 | var fallbackValue: Bool {
19 | switch self {
20 | case .feature1:
21 | return false
22 | case .feature2:
23 | return true
24 | }
25 | }
26 |
27 | case feature1
28 | case feature2
29 | }
30 |
31 | let suiteName = "repositoryTest"
32 | var defaults: UserDefaults!
33 |
34 | var subject: FeatureToggleRepository!
35 |
36 | override func setUp() {
37 | super.setUp()
38 |
39 | guard let defaults = UserDefaults(suiteName: suiteName) else {
40 | XCTFail()
41 | return
42 | }
43 |
44 | self.defaults = defaults
45 | self.subject = FeatureToggleUserDefaultsRepository(defaults: defaults)
46 | }
47 |
48 | override func tearDown() {
49 | self.defaults.removePersistentDomain(forName: suiteName)
50 |
51 | super.tearDown()
52 | }
53 |
54 | func testRetrieveBeforeSave() {
55 | XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature1).isActive, TestToggle.feature1.fallbackValue)
56 | XCTAssertEqual(subject.retrieve(identifiable: TestToggle.feature2).isActive, TestToggle.feature2.fallbackValue)
57 |
58 | XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).isActive)
59 | subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, isActive: true))
60 | XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive)
61 | }
62 |
63 | func testSaveAndRetrieve() {
64 | XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature1).isActive)
65 | XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).isActive)
66 |
67 | subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature1.rawValue, isActive: true))
68 | XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive)
69 | XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature2).isActive)
70 |
71 | subject.save(featureToggle: FeatureToggle(identifier: TestToggle.feature2.rawValue, isActive: false))
72 | XCTAssertTrue(subject.retrieve(identifiable: TestToggle.feature1).isActive)
73 | XCTAssertFalse(subject.retrieve(identifiable: TestToggle.feature2).isActive)
74 | }
75 |
76 | static var allTests = [
77 | ("testSaveAndRetrieve", testSaveAndRetrieve),
78 | ("testRetrieveBeforeSave", testRetrieveBeforeSave),
79 | ]
80 |
81 | }
82 |
--------------------------------------------------------------------------------
/Tests/CloudKitFeatureTogglesTests/FeatureToggleSubscriptorTests.swift:
--------------------------------------------------------------------------------
1 | //
2 | // FeatureToggleSubscriptorTests.swift
3 | // CloudKitFeatureTogglesTests
4 | //
5 | // Created by Jonas Reichert on 02.01.20.
6 | //
7 |
8 | import XCTest
9 | import CloudKit
10 | @testable import CloudKitFeatureToggles
11 |
12 | class FeatureToggleSubscriptorTests: XCTestCase {
13 |
14 | enum TestError: Error {
15 | case generic
16 | }
17 |
18 | var subject: FeatureToggleSubscriptor!
19 | var cloudKitDatabase: MockCloudKitDatabaseConformable!
20 | var repository: MockToggleRepository!
21 | let defaults = UserDefaults(suiteName: "testSuite") ?? .standard
22 |
23 | override func setUp() {
24 | super.setUp()
25 |
26 | cloudKitDatabase = MockCloudKitDatabaseConformable()
27 | repository = MockToggleRepository()
28 | subject = FeatureToggleSubscriptor(toggleRepository: repository, featureToggleRecordID: "TestFeatureStatus", featureToggleNameFieldID: "toggleName", featureToggleIsActiveFieldID: "isActive", defaults: defaults, cloudKitDatabaseConformable: cloudKitDatabase)
29 | }
30 |
31 | override func tearDown() {
32 | defaults.removePersistentDomain(forName: "testSuite")
33 |
34 | super.tearDown()
35 | }
36 |
37 | func testFetchAll() {
38 | XCTAssertNil(cloudKitDatabase.recordType)
39 | XCTAssertEqual(repository.toggles.count, 0)
40 |
41 | cloudKitDatabase.recordFetched["isActive"] = 1
42 | cloudKitDatabase.recordFetched["toggleName"] = "Toggle1"
43 |
44 | subject.fetchAll()
45 |
46 | XCTAssertEqual(repository.toggles.count, 1)
47 | guard let toggle = repository.toggles.first else {
48 | XCTFail()
49 | return
50 | }
51 | XCTAssertEqual(toggle.identifier, "Toggle1")
52 | XCTAssertTrue(toggle.isActive)
53 |
54 | cloudKitDatabase.recordFetched["isActive"] = 0
55 | cloudKitDatabase.recordFetched["toggleName"] = "Toggle1"
56 |
57 | subject.fetchAll()
58 |
59 | XCTAssertEqual(repository.toggles.count, 1)
60 |
61 | guard let toggle2 = repository.toggles.first else {
62 | XCTFail()
63 | return
64 | }
65 | XCTAssertEqual(toggle2.identifier, "Toggle1")
66 | XCTAssertFalse(toggle2.isActive)
67 | }
68 |
69 | func testFetchAllError() {
70 | cloudKitDatabase.error = TestError.generic
71 |
72 | XCTAssertNil(cloudKitDatabase.recordType)
73 | XCTAssertEqual(repository.toggles.count, 0)
74 |
75 | cloudKitDatabase.recordFetched["isActive"] = 1
76 | cloudKitDatabase.recordFetched["toggleName"] = "Toggle1"
77 |
78 | subject.fetchAll()
79 |
80 | XCTAssertNil(cloudKitDatabase.recordType)
81 | XCTAssertEqual(repository.toggles.count, 0)
82 | }
83 |
84 | func testFetchAllNotification() {
85 | let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in
86 | guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else {
87 | return false
88 | }
89 |
90 | return toggles.count == 1
91 | }
92 | cloudKitDatabase.recordFetched["isActive"] = 1
93 | cloudKitDatabase.recordFetched["toggleName"] = "Toggle1"
94 |
95 | subject.fetchAll()
96 | wait(for: [expectation], timeout: 0.1)
97 | }
98 |
99 | func testFetchAllNotMappableRecord() {
100 | let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in
101 | guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else {
102 | return false
103 | }
104 |
105 | return toggles.count == 0
106 | }
107 | cloudKitDatabase.recordFetched["isActive123"] = 1
108 | cloudKitDatabase.recordFetched["toggleName1234"] = "Toggle1"
109 |
110 | subject.fetchAll()
111 | wait(for: [expectation], timeout: 0.1)
112 | XCTAssertEqual(repository.toggles.count, 0)
113 | }
114 |
115 | func testSaveSubscription() {
116 | XCTAssertNil(cloudKitDatabase.subscriptionsToSave)
117 | XCTAssertFalse(defaults.bool(forKey: subject.subscriptionID))
118 |
119 | subject.saveSubscription()
120 |
121 | guard let firstSubscription = cloudKitDatabase.subscriptionsToSave?.first else {
122 | XCTFail()
123 | return
124 | }
125 |
126 | XCTAssertEqual(firstSubscription.subscriptionID, subject.subscriptionID)
127 | XCTAssertTrue(defaults.bool(forKey: subject.subscriptionID))
128 | XCTAssertEqual(cloudKitDatabase.addCalledCount, 1)
129 |
130 | subject.saveSubscription()
131 | XCTAssertEqual(firstSubscription.subscriptionID, subject.subscriptionID)
132 | XCTAssertTrue(defaults.bool(forKey: subject.subscriptionID))
133 | XCTAssertEqual(cloudKitDatabase.addCalledCount, 1)
134 | }
135 |
136 | func testSaveSubscriptionError() {
137 | cloudKitDatabase.error = TestError.generic
138 |
139 | XCTAssertNil(cloudKitDatabase.subscriptionsToSave)
140 | XCTAssertFalse(defaults.bool(forKey: subject.subscriptionID))
141 |
142 | subject.saveSubscription()
143 |
144 | XCTAssertFalse(defaults.bool(forKey: subject.subscriptionID))
145 | }
146 |
147 | func testHandleNotification() {
148 | XCTAssertNil(cloudKitDatabase.recordType)
149 | XCTAssertEqual(cloudKitDatabase.addCalledCount, 0)
150 | XCTAssertEqual(repository.toggles.count, 0)
151 |
152 | cloudKitDatabase.recordFetched["isActive"] = 1
153 | cloudKitDatabase.recordFetched["toggleName"] = "Toggle1"
154 |
155 | subject.handleNotification()
156 |
157 | XCTAssertEqual(cloudKitDatabase.addCalledCount, 1)
158 | XCTAssertEqual(cloudKitDatabase.recordType, "TestFeatureStatus")
159 | XCTAssertEqual(repository.toggles.count, 1)
160 | guard let toggle = repository.toggles.first else {
161 | XCTFail()
162 | return
163 | }
164 | XCTAssertEqual(toggle.identifier, "Toggle1")
165 | XCTAssertTrue(toggle.isActive)
166 |
167 | cloudKitDatabase.recordFetched["isActive"] = 0
168 | cloudKitDatabase.recordFetched["toggleName"] = "Toggle1"
169 |
170 | subject.handleNotification()
171 |
172 | XCTAssertEqual(cloudKitDatabase.addCalledCount, 2)
173 | XCTAssertEqual(cloudKitDatabase.recordType, "TestFeatureStatus")
174 | XCTAssertEqual(repository.toggles.count, 1)
175 |
176 | guard let toggle2 = repository.toggles.first else {
177 | XCTFail()
178 | return
179 | }
180 | XCTAssertEqual(toggle2.identifier, "Toggle1")
181 | XCTAssertFalse(toggle2.isActive)
182 | }
183 |
184 | func testHandleNotificationSendNotification() {
185 | let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in
186 | guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else {
187 | return false
188 | }
189 |
190 | return toggles.count == 1
191 | }
192 | cloudKitDatabase.recordFetched["isActive"] = 1
193 | cloudKitDatabase.recordFetched["toggleName"] = "Toggle1"
194 |
195 | subject.handleNotification()
196 | wait(for: [expectation], timeout: 0.1)
197 |
198 | }
199 |
200 | func testHandleNotificationNotMappableRecord() {
201 | let expectation = self.expectation(forNotification: NSNotification.Name.onRecordsUpdated, object: nil) { (notification) -> Bool in
202 | guard let userInfo = notification.userInfo, let toggles = userInfo["featureToggles"] as? [FeatureToggle] else {
203 | return false
204 | }
205 |
206 | return toggles.count == 0
207 | }
208 |
209 | cloudKitDatabase.recordFetched["isActive123"] = 1
210 | cloudKitDatabase.recordFetched["toggleName1234"] = "Toggle1"
211 |
212 | subject.handleNotification()
213 | wait(for: [expectation], timeout: 0.1)
214 | XCTAssertEqual(repository.toggles.count, 0)
215 | }
216 |
217 | static var allTests = [
218 | ("testFetchAll", testFetchAll),
219 | ("testFetchAllError", testFetchAllError),
220 | ("testFetchAllNotification", testFetchAllNotification),
221 | ("testSaveSubscription", testSaveSubscription),
222 | ("testSaveSubscriptionError", testSaveSubscriptionError),
223 | ("testHandleNotification", testHandleNotification),
224 | ("testHandleNotificationSendNotification", testHandleNotificationSendNotification),
225 | ]
226 |
227 | }
228 |
229 | class MockToggleRepository: FeatureToggleRepository {
230 | var toggles: [FeatureToggleRepresentable] = []
231 |
232 | func save(featureToggle: FeatureToggleRepresentable) {
233 | toggles.removeAll { (representable) -> Bool in
234 | representable.identifier == featureToggle.identifier
235 | }
236 | toggles.append(featureToggle)
237 | }
238 |
239 | func retrieve(identifiable: FeatureToggleIdentifiable) -> FeatureToggleRepresentable {
240 | toggles.first { (representable) -> Bool in
241 | representable.identifier == identifiable.identifier
242 | } ?? MockToggleRepresentable(identifier: identifiable.identifier, isActive: identifiable.fallbackValue)
243 | }
244 | }
245 |
246 | struct MockToggleRepresentable: FeatureToggleRepresentable {
247 | var identifier: String
248 | var isActive: Bool
249 | }
250 |
251 | class MockCloudKitDatabaseConformable: CloudKitDatabaseConformable {
252 | var addCalledCount = 0
253 | var subscriptionsToSave: [CKSubscription]?
254 | var recordType: CKRecord.RecordType?
255 |
256 | var recordFetched = CKRecord(recordType: "TestFeatureStatus")
257 | var error: Error?
258 |
259 | func add(_ operation: CKDatabaseOperation) {
260 | if let op = operation as? CKModifySubscriptionsOperation {
261 | subscriptionsToSave = op.subscriptionsToSave
262 | op.modifySubscriptionsCompletionBlock?(nil, nil, error)
263 | } else if let op = operation as? CKQueryOperation {
264 | recordType = op.query?.recordType
265 | op.recordFetchedBlock?(recordFetched)
266 | }
267 | addCalledCount += 1
268 | }
269 |
270 | func perform(_ query: CKQuery, inZoneWith zoneID: CKRecordZone.ID?, completionHandler: @escaping ([CKRecord]?, Error?) -> Void) {
271 | if let error = error {
272 | completionHandler(nil, error)
273 | } else {
274 | completionHandler([recordFetched], error)
275 | }
276 | }
277 | }
278 |
--------------------------------------------------------------------------------
/Tests/CloudKitFeatureTogglesTests/XCTestManifests.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | #if !canImport(ObjectiveC)
4 | public func allTests() -> [XCTestCaseEntry] {
5 | return [
6 | testCase(FeatureToggleRepositoryTests.allTests),
7 | testCase(FeatureToggleSubscriptorTests.allTests),
8 | testCase(FeatureToggleApplicationServiceTests.allTests),
9 | testCase(FeatureToggleMapperTests.allTests),
10 | ]
11 | }
12 | #endif
13 |
--------------------------------------------------------------------------------
/Tests/LinuxMain.swift:
--------------------------------------------------------------------------------
1 | import XCTest
2 |
3 | import CloudKitFeatureTogglesTests
4 |
5 | var tests = [XCTestCaseEntry]()
6 | tests += CloudKitFeatureTogglesTests.allTests()
7 | XCTMain(tests)
8 |
--------------------------------------------------------------------------------