capacitor-launchdarkly 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CapacitorLaunchdarkly.podspec +20 -0
- package/LICENSE +21 -0
- package/Package.swift +32 -0
- package/README.md +747 -2
- package/android/build.gradle +68 -0
- package/android/src/main/AndroidManifest.xml +2 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/Json.kt +67 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyClient.kt +250 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyPlugin.kt +148 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LdConfigFactory.kt +34 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StartOptions.kt +76 -0
- package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StoredContext.kt +47 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/dist/docs.json +1174 -0
- package/dist/esm/definitions.d.ts +520 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +4 -0
- package/dist/esm/index.js +7 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/web.d.ts +46 -0
- package/dist/esm/web.js +204 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +221 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +224 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Sources/LaunchDarklyPlugin/ConfigFactory.swift +29 -0
- package/ios/Sources/LaunchDarklyPlugin/JSONValues.swift +68 -0
- package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyClient.swift +165 -0
- package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyPlugin.swift +169 -0
- package/ios/Sources/LaunchDarklyPlugin/PrivacyInfo.xcprivacy +23 -0
- package/ios/Sources/LaunchDarklyPlugin/StartOptions.swift +79 -0
- package/ios/Sources/LaunchDarklyPlugin/StoredContext.swift +43 -0
- package/ios/Tests/LaunchDarklyPluginTests/BillingSafetyTests.swift +53 -0
- package/ios/Tests/LaunchDarklyPluginTests/JSONValuesTests.swift +67 -0
- package/ios/Tests/LaunchDarklyPluginTests/LaunchDarklyPluginTests.swift +37 -0
- package/ios/Tests/LaunchDarklyPluginTests/PluginConfigurationTests.swift +52 -0
- package/ios/Tests/LaunchDarklyPluginTests/StartContextTests.swift +36 -0
- package/package.json +105 -4
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import Capacitor
|
|
3
|
+
import LaunchDarkly
|
|
4
|
+
|
|
5
|
+
@objc(LaunchDarklyPlugin)
|
|
6
|
+
public class LaunchDarklyPlugin: CAPPlugin, CAPBridgedPlugin, ClientEvents {
|
|
7
|
+
public let identifier = "LaunchDarklyPlugin"
|
|
8
|
+
public let jsName = "LaunchDarkly"
|
|
9
|
+
public let pluginMethods: [CAPPluginMethod] = [
|
|
10
|
+
CAPPluginMethod(name: "initialize", returnType: CAPPluginReturnPromise),
|
|
11
|
+
CAPPluginMethod(name: "waitForInitialization", returnType: CAPPluginReturnPromise),
|
|
12
|
+
CAPPluginMethod(name: "getState", returnType: CAPPluginReturnPromise),
|
|
13
|
+
CAPPluginMethod(name: "identify", returnType: CAPPluginReturnPromise),
|
|
14
|
+
CAPPluginMethod(name: "variation", returnType: CAPPluginReturnPromise),
|
|
15
|
+
CAPPluginMethod(name: "allFlags", returnType: CAPPluginReturnPromise),
|
|
16
|
+
CAPPluginMethod(name: "setStreaming", returnType: CAPPluginReturnPromise),
|
|
17
|
+
CAPPluginMethod(name: "setOnline", returnType: CAPPluginReturnPromise),
|
|
18
|
+
CAPPluginMethod(name: "flush", returnType: CAPPluginReturnPromise),
|
|
19
|
+
CAPPluginMethod(name: "close", returnType: CAPPluginReturnPromise),
|
|
20
|
+
CAPPluginMethod(name: "clearStoredContext", returnType: CAPPluginReturnPromise)
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
private let store: ContextStore = UserDefaultsContextStore()
|
|
24
|
+
private var client: LaunchDarklyClient { LaunchDarklyClient.shared }
|
|
25
|
+
|
|
26
|
+
/// Runs as the bridge is created, before the web view loads the app. With a `mobileKey` in `plugins.LaunchDarkly` of
|
|
27
|
+
/// capacitor.config the client starts here, with the stored context or else `initialContext`, so the flags are on their way
|
|
28
|
+
/// before any app code asks for them. Without one nothing starts, and `initialize()` does it.
|
|
29
|
+
override public func load() {
|
|
30
|
+
let config = getConfig().getConfigJSON() as [String: Any]
|
|
31
|
+
let autoStart = PluginConfiguration.autoStart(config)
|
|
32
|
+
LaunchDarklyClient.onMain { [weak self] in
|
|
33
|
+
guard let self else { return }
|
|
34
|
+
self.client.events = self
|
|
35
|
+
guard let autoStart else { return }
|
|
36
|
+
let context = StartContext.choose(stored: self.store.read(), initialContext: autoStart.initialContext)
|
|
37
|
+
self.client.start(autoStart.options, context: context)
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@objc func initialize(_ call: CAPPluginCall) {
|
|
42
|
+
guard let mobileKey = nonBlank(call.getString("mobileKey")) else {
|
|
43
|
+
return call.reject("initialize needs a mobileKey on iOS.")
|
|
44
|
+
}
|
|
45
|
+
guard let given = readContext(call) else { return }
|
|
46
|
+
let options = StartOptions(mobileKey: mobileKey, options: call.getObject("options"))
|
|
47
|
+
LaunchDarklyClient.onMain { [store, client] in
|
|
48
|
+
// One client per process: the same key changes nothing and answers the state once the start is done.
|
|
49
|
+
if client.options?.mobileKey == mobileKey {
|
|
50
|
+
return client.afterStart { call.resolve(Self.state(client)) }
|
|
51
|
+
}
|
|
52
|
+
// The given context plays the part of initialContext: a context an earlier identify() stored wins over it.
|
|
53
|
+
let context = StartContext.choose(stored: store.read(), initialContext: given.json)
|
|
54
|
+
client.start(options, context: context) { call.resolve(Self.state(client)) }
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
@objc func waitForInitialization(_ call: CAPPluginCall) {
|
|
59
|
+
let timeout = StartOptions.seconds(call.options["timeoutSeconds"])
|
|
60
|
+
LaunchDarklyClient.onMain { [client] in
|
|
61
|
+
client.waitForInitialization(timeout: timeout) { call.resolve(["initialized": $0]) }
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
@objc func getState(_ call: CAPPluginCall) {
|
|
66
|
+
LaunchDarklyClient.onMain { [client] in
|
|
67
|
+
client.afterStart { call.resolve(Self.state(client)) }
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
@objc func identify(_ call: CAPPluginCall) {
|
|
72
|
+
guard let context = readContext(call) else { return }
|
|
73
|
+
let timeout = StartOptions.seconds(call.options["timeoutSeconds"])
|
|
74
|
+
LaunchDarklyClient.onMain { [store, client] in
|
|
75
|
+
client.identify(context, timeout: timeout, store: store) { result in
|
|
76
|
+
switch result {
|
|
77
|
+
case .success(let timedOut): call.resolve(["timedOut": timedOut])
|
|
78
|
+
case .failure(let error): call.reject(error.localizedDescription, nil, error)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
@objc func variation(_ call: CAPPluginCall) {
|
|
85
|
+
guard let key = call.getString("key") else { return call.reject("variation needs a key.") }
|
|
86
|
+
let defaultValue = JSValues.toLDValue(call.options["defaultValue"])
|
|
87
|
+
LaunchDarklyClient.onMain { [client] in
|
|
88
|
+
call.resolve(["value": JSValues.toJS(client.variation(key, defaultValue: defaultValue))])
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
@objc func allFlags(_ call: CAPPluginCall) {
|
|
93
|
+
LaunchDarklyClient.onMain { [client] in
|
|
94
|
+
call.resolve(["flags": JSValues.flags(client.allFlags)])
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
@objc func setStreaming(_ call: CAPPluginCall) {
|
|
99
|
+
guard let enabled = call.getBool("enabled") else { return call.reject("setStreaming needs enabled.") }
|
|
100
|
+
LaunchDarklyClient.onMain { [client] in
|
|
101
|
+
client.restart({ options in
|
|
102
|
+
var next = options
|
|
103
|
+
next.streaming = enabled
|
|
104
|
+
return next
|
|
105
|
+
}, done: { error in
|
|
106
|
+
if let error { call.reject(error.localizedDescription, nil, error) } else { call.resolve() }
|
|
107
|
+
})
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
@objc func setOnline(_ call: CAPPluginCall) {
|
|
112
|
+
guard let online = call.getBool("online") else { return call.reject("setOnline needs online.") }
|
|
113
|
+
LaunchDarklyClient.onMain { [client] in
|
|
114
|
+
client.setOnline(online) { call.resolve() }
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
@objc func flush(_ call: CAPPluginCall) {
|
|
119
|
+
LaunchDarklyClient.onMain { [client] in
|
|
120
|
+
client.flush()
|
|
121
|
+
call.resolve()
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
@objc func close(_ call: CAPPluginCall) {
|
|
126
|
+
LaunchDarklyClient.onMain { [client] in
|
|
127
|
+
client.close()
|
|
128
|
+
call.resolve()
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
@objc func clearStoredContext(_ call: CAPPluginCall) {
|
|
133
|
+
store.clear()
|
|
134
|
+
call.resolve()
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
func flagChanged(key: String, value: LDValue, previous: LDValue?) {
|
|
138
|
+
var change: [String: Any] = ["key": key, "value": JSValues.toJS(value)]
|
|
139
|
+
if let previous { change["previous"] = JSValues.toJS(previous) }
|
|
140
|
+
notifyListeners("flagChange", data: change)
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
func connectionModeChanged(_ mode: String) {
|
|
144
|
+
notifyListeners("connectionModeChange", data: ["mode": mode])
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
private func readContext(_ call: CAPPluginCall) -> ContextChoice? {
|
|
148
|
+
guard let object = call.getObject("context") else {
|
|
149
|
+
call.reject("A context is needed.")
|
|
150
|
+
return nil
|
|
151
|
+
}
|
|
152
|
+
do {
|
|
153
|
+
let json = try Contexts.json(from: object)
|
|
154
|
+
return ContextChoice(context: try Contexts.parse(json), json: json)
|
|
155
|
+
} catch {
|
|
156
|
+
call.reject(error.localizedDescription, nil, error)
|
|
157
|
+
return nil
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
static func state(_ client: LaunchDarklyClient) -> [String: Any] {
|
|
162
|
+
var state: [String: Any] = ["initialized": client.isInitialized, "flags": JSValues.flags(client.allFlags)]
|
|
163
|
+
if let json = client.contextJSON,
|
|
164
|
+
let context = try? JSONSerialization.jsonObject(with: Data(json.utf8)) {
|
|
165
|
+
state["context"] = context
|
|
166
|
+
}
|
|
167
|
+
return state
|
|
168
|
+
}
|
|
169
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
<?xml version="1.0" encoding="UTF-8"?>
|
|
2
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
3
|
+
<plist version="1.0">
|
|
4
|
+
<dict>
|
|
5
|
+
<key>NSPrivacyAccessedAPITypes</key>
|
|
6
|
+
<array>
|
|
7
|
+
<dict>
|
|
8
|
+
<key>NSPrivacyAccessedAPIType</key>
|
|
9
|
+
<string>NSPrivacyAccessedAPICategoryUserDefaults</string>
|
|
10
|
+
<key>NSPrivacyAccessedAPITypeReasons</key>
|
|
11
|
+
<array>
|
|
12
|
+
<string>CA92.1</string>
|
|
13
|
+
</array>
|
|
14
|
+
</dict>
|
|
15
|
+
</array>
|
|
16
|
+
<key>NSPrivacyCollectedDataTypes</key>
|
|
17
|
+
<array/>
|
|
18
|
+
<key>NSPrivacyTrackingDomains</key>
|
|
19
|
+
<array/>
|
|
20
|
+
<key>NSPrivacyTracking</key>
|
|
21
|
+
<false/>
|
|
22
|
+
</dict>
|
|
23
|
+
</plist>
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
|
|
3
|
+
/// Application metadata sent with every request. It describes the app; it is not a context.
|
|
4
|
+
public struct ApplicationMetadata: Equatable {
|
|
5
|
+
public var id: String?
|
|
6
|
+
public var name: String?
|
|
7
|
+
public var version: String?
|
|
8
|
+
public var versionName: String?
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// How the client is started: from `plugins.LaunchDarkly` in capacitor.config, or from `initialize()`.
|
|
12
|
+
public struct StartOptions: Equatable {
|
|
13
|
+
public static let defaultWaitSeconds: TimeInterval = 5
|
|
14
|
+
|
|
15
|
+
public var mobileKey: String
|
|
16
|
+
public var streaming = true
|
|
17
|
+
public var evaluationReasons = false
|
|
18
|
+
public var offline = false
|
|
19
|
+
public var startWaitSeconds: TimeInterval = StartOptions.defaultWaitSeconds
|
|
20
|
+
public var application: ApplicationMetadata?
|
|
21
|
+
|
|
22
|
+
public init(mobileKey: String) {
|
|
23
|
+
self.mobileKey = mobileKey
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/// The options of `initialize()` or of the config block, everything but the key and the context.
|
|
27
|
+
public init(mobileKey: String, options config: [String: Any]?) {
|
|
28
|
+
self.mobileKey = mobileKey
|
|
29
|
+
let config = config ?? [:]
|
|
30
|
+
streaming = config["streaming"] as? Bool ?? true
|
|
31
|
+
evaluationReasons = config["evaluationReasons"] as? Bool ?? false
|
|
32
|
+
offline = config["offline"] as? Bool ?? false
|
|
33
|
+
startWaitSeconds = StartOptions.seconds(config["startWaitSeconds"])
|
|
34
|
+
if let app = config["application"] as? [String: Any] {
|
|
35
|
+
application = ApplicationMetadata(
|
|
36
|
+
id: nonBlank(app["id"]),
|
|
37
|
+
name: nonBlank(app["name"]),
|
|
38
|
+
version: nonBlank(app["version"]),
|
|
39
|
+
versionName: nonBlank(app["versionName"])
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/// A wait in seconds as given, or the default when it is not a usable number.
|
|
45
|
+
public static func seconds(_ value: Any?, default fallback: TimeInterval = defaultWaitSeconds) -> TimeInterval {
|
|
46
|
+
// A JavaScript boolean arrives as an NSNumber too; it is not a number of seconds.
|
|
47
|
+
guard let number = value as? NSNumber, CFGetTypeID(number) != CFBooleanGetTypeID() else { return fallback }
|
|
48
|
+
let seconds = number.doubleValue
|
|
49
|
+
return seconds.isFinite && seconds >= 0 ? seconds : fallback
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/// What the native start-up does when the app launches: start with `options` and `initialContext` unless a context was stored.
|
|
54
|
+
public struct AutoStart: Equatable {
|
|
55
|
+
public var options: StartOptions
|
|
56
|
+
public var initialContext: String
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
public enum PluginConfiguration {
|
|
60
|
+
/// The context of a native start-up that has neither a stored context nor an `initialContext`.
|
|
61
|
+
public static let defaultInitialContext = #"{"kind":"user","key":"anonymous_user","anonymous":true}"#
|
|
62
|
+
|
|
63
|
+
/// Reads the `plugins.LaunchDarkly` block of capacitor.config. Without a `mobileKey` there is no native start-up, and the
|
|
64
|
+
/// answer is nil: the app starts the client with `initialize()` instead.
|
|
65
|
+
public static func autoStart(_ config: [String: Any]?) -> AutoStart? {
|
|
66
|
+
guard let config, let mobileKey = nonBlank(config["mobileKey"]) else { return nil }
|
|
67
|
+
var initialContext = defaultInitialContext
|
|
68
|
+
if let context = config["initialContext"] as? [String: Any], let json = JSONText.from(context) {
|
|
69
|
+
initialContext = json
|
|
70
|
+
}
|
|
71
|
+
return AutoStart(options: StartOptions(mobileKey: mobileKey, options: config), initialContext: initialContext)
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/// A string value, or nil when it is absent, not a string, or blank.
|
|
76
|
+
func nonBlank(_ value: Any?) -> String? {
|
|
77
|
+
guard let string = value as? String, !string.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return nil }
|
|
78
|
+
return string
|
|
79
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import LaunchDarkly
|
|
3
|
+
|
|
4
|
+
/// Where the context of the last `identify()` is kept between launches.
|
|
5
|
+
public protocol ContextStore: AnyObject {
|
|
6
|
+
func read() -> String?
|
|
7
|
+
func write(_ json: String)
|
|
8
|
+
func clear()
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/// `ContextStore` in `UserDefaults`, under a key of the plugin's own.
|
|
12
|
+
public final class UserDefaultsContextStore: ContextStore {
|
|
13
|
+
public static let key = "CapacitorLaunchDarkly.context"
|
|
14
|
+
private let defaults: UserDefaults
|
|
15
|
+
|
|
16
|
+
public init(defaults: UserDefaults = .standard) {
|
|
17
|
+
self.defaults = defaults
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
public func read() -> String? { defaults.string(forKey: Self.key) }
|
|
21
|
+
public func write(_ json: String) { defaults.set(json, forKey: Self.key) }
|
|
22
|
+
public func clear() { defaults.removeObject(forKey: Self.key) }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/// A context and the JSON it was read from, which is what the plugin stores and answers in `getState()`.
|
|
26
|
+
public struct ContextChoice {
|
|
27
|
+
public let context: LDContext
|
|
28
|
+
public let json: String
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
public enum StartContext {
|
|
32
|
+
/// The context a native start-up begins with: the stored one when there is a valid one, so the app does not flip to the
|
|
33
|
+
/// initial (usually anonymous) context for a moment and the SDK uses its flag cache for that context; else
|
|
34
|
+
/// `initialContext`; else the default anonymous context.
|
|
35
|
+
public static func choose(stored: String?, initialContext: String) -> ContextChoice {
|
|
36
|
+
for json in [stored, initialContext, PluginConfiguration.defaultInitialContext] {
|
|
37
|
+
if let json, let context = Contexts.parseOrNil(json) {
|
|
38
|
+
return ContextChoice(context: context, json: json)
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
fatalError("the default context is not valid")
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import LaunchDarkly
|
|
3
|
+
@testable import LaunchDarklyPlugin
|
|
4
|
+
|
|
5
|
+
// LaunchDarkly bills client-side SDKs by unique context keys. The plugin keeps the count at the keys the app passes: no
|
|
6
|
+
// automatic environment contexts (ld_application, ld_device), and no key generated for an anonymous context.
|
|
7
|
+
final class BillingSafetyTests: XCTestCase {
|
|
8
|
+
func testAutomaticEnvironmentAttributesAreDisabled() {
|
|
9
|
+
XCTAssertFalse(ConfigFactory.make(StartOptions(mobileKey: "mob-test")).autoEnvAttributes)
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
func testTheNativeStartPathBuildsTheSameSafeConfig() throws {
|
|
13
|
+
let start = try XCTUnwrap(PluginConfiguration.autoStart(["mobileKey": "mob-test"]))
|
|
14
|
+
XCTAssertFalse(ConfigFactory.make(start.options).autoEnvAttributes)
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func testTheNativeStartPathBeginsWithTheSharedAnonymousKey() throws {
|
|
18
|
+
let start = try XCTUnwrap(PluginConfiguration.autoStart(["mobileKey": "mob-test"]))
|
|
19
|
+
let context = StartContext.choose(stored: nil, initialContext: start.initialContext).context
|
|
20
|
+
XCTAssertEqual(context.contextKeys(), ["user": "anonymous_user"])
|
|
21
|
+
XCTAssertEqual(context.getValue(Reference("anonymous")), .bool(true))
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
func testAnAnonymousContextKeepsItsKey() throws {
|
|
25
|
+
let context = try Contexts.parse(#"{"kind":"user","key":"anonymous_user","anonymous":true}"#)
|
|
26
|
+
XCTAssertEqual(context.fullyQualifiedKey(), "anonymous_user")
|
|
27
|
+
XCTAssertEqual(context.getValue(Reference("anonymous")), .bool(true))
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func testAnAnonymousKindInAMultiContextKeepsItsKey() throws {
|
|
31
|
+
let context = try Contexts.parse(#"{"kind":"multi","user":{"key":"anonymous_user","anonymous":true},"org":{"key":"o1"}}"#)
|
|
32
|
+
XCTAssertEqual(context.contextKeys(), ["user": "anonymous_user", "org": "o1"])
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func testAContextWithoutAKeyIsRejectedRatherThanGivenOne() {
|
|
36
|
+
XCTAssertThrowsError(try Contexts.parse(#"{"kind":"user","anonymous":true}"#))
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
func testOptionsArePassedOn() {
|
|
40
|
+
var options = StartOptions(mobileKey: "mob-test")
|
|
41
|
+
options.streaming = false
|
|
42
|
+
options.evaluationReasons = true
|
|
43
|
+
options.offline = true
|
|
44
|
+
options.application = ApplicationMetadata(id: "app")
|
|
45
|
+
let config = ConfigFactory.make(options)
|
|
46
|
+
XCTAssertEqual(config.mobileKey, "mob-test")
|
|
47
|
+
XCTAssertEqual(config.streamingMode, .polling)
|
|
48
|
+
XCTAssertTrue(config.evaluationReasons)
|
|
49
|
+
XCTAssertFalse(config.startOnline)
|
|
50
|
+
XCTAssertNotNil(config.applicationInfo)
|
|
51
|
+
XCTAssertFalse(config.autoEnvAttributes)
|
|
52
|
+
}
|
|
53
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import LaunchDarkly
|
|
3
|
+
@testable import LaunchDarklyPlugin
|
|
4
|
+
|
|
5
|
+
final class JSONValuesTests: XCTestCase {
|
|
6
|
+
func testReadsNameCustomAttributesNestedObjectsAndPrivateAttributes() throws {
|
|
7
|
+
let context = try Contexts.parse("""
|
|
8
|
+
{"kind":"user","key":"u1","name":"Ann","plan":"pro","seats":3,"beta":true,"tags":["a","b"],
|
|
9
|
+
"address":{"country":"IE","city":"Cork"},"_meta":{"privateAttributes":["email","/address/city"]}}
|
|
10
|
+
""")
|
|
11
|
+
XCTAssertEqual(context.getValue(Reference("name")), .string("Ann"))
|
|
12
|
+
XCTAssertEqual(context.getValue(Reference("plan")), .string("pro"))
|
|
13
|
+
XCTAssertEqual(context.getValue(Reference("seats")), .number(3))
|
|
14
|
+
XCTAssertEqual(context.getValue(Reference("beta")), .bool(true))
|
|
15
|
+
XCTAssertEqual(context.getValue(Reference("tags")), .array([.string("a"), .string("b")]))
|
|
16
|
+
XCTAssertEqual(context.getValue(Reference("/address/city")), .string("Cork"))
|
|
17
|
+
XCTAssertEqual(context.getValue(Reference("anonymous")), .bool(false))
|
|
18
|
+
// The SDK encodes a context for events with its private attributes redacted: the private city is gone, the rest stays.
|
|
19
|
+
let encoded = try XCTUnwrap(String(data: try JSONEncoder().encode(context), encoding: .utf8))
|
|
20
|
+
XCTAssertTrue(encoded.contains("redactedAttributes"), encoded)
|
|
21
|
+
XCTAssertFalse(encoded.contains("Cork"), encoded)
|
|
22
|
+
XCTAssertTrue(encoded.contains("IE"), encoded)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
func testReadsAMultiContext() throws {
|
|
26
|
+
let context = try Contexts.parse(#"{"kind":"multi","user":{"key":"u1"},"org":{"key":"o1","name":"Farm"}}"#)
|
|
27
|
+
XCTAssertTrue(context.isMulti())
|
|
28
|
+
XCTAssertEqual(context.contextKeys(), ["user": "u1", "org": "o1"])
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
func testAnInvalidKindIsRejected() {
|
|
32
|
+
XCTAssertThrowsError(try Contexts.parse(#"{"kind":"kind","key":"k"}"#))
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
func testTheJSONOfADictionaryRoundTrips() throws {
|
|
36
|
+
let json = try Contexts.json(from: ["kind": "user", "key": "u1", "anonymous": true, "seats": 3])
|
|
37
|
+
let context = try Contexts.parse(json)
|
|
38
|
+
XCTAssertEqual(context.getValue(Reference("anonymous")), .bool(true))
|
|
39
|
+
XCTAssertEqual(context.getValue(Reference("seats")), .number(3))
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
func testValuesOfEveryJSONTypeGoBothWays() {
|
|
43
|
+
XCTAssertEqual(JSValues.toLDValue(true), .bool(true))
|
|
44
|
+
XCTAssertEqual(JSValues.toLDValue(3), .number(3))
|
|
45
|
+
XCTAssertEqual(JSValues.toLDValue(2.5), .number(2.5))
|
|
46
|
+
XCTAssertEqual(JSValues.toLDValue("text"), .string("text"))
|
|
47
|
+
XCTAssertEqual(JSValues.toLDValue(["a": 1]), .object(["a": .number(1)]))
|
|
48
|
+
XCTAssertEqual(JSValues.toLDValue([1, "two"]), .array([.number(1), .string("two")]))
|
|
49
|
+
XCTAssertEqual(JSValues.toJS(.bool(true)) as? Bool, true)
|
|
50
|
+
XCTAssertEqual(JSValues.toJS(.string("text")) as? String, "text")
|
|
51
|
+
XCTAssertEqual((JSValues.toJS(.object(["a": .number(1)])) as? [String: Any])?["a"] as? Int, 1)
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
func testABooleanStaysABoolean() {
|
|
55
|
+
// NSNumber carries both; a true that became 1 would compare unequal to true in JavaScript.
|
|
56
|
+
XCTAssertEqual(JSValues.toLDValue(NSNumber(value: true)), .bool(true))
|
|
57
|
+
let data = try? JSONSerialization.data(withJSONObject: ["v": JSValues.toJS(.bool(false))])
|
|
58
|
+
XCTAssertEqual(data.flatMap { String(data: $0, encoding: .utf8) }, #"{"v":false}"#)
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
func testNullAndAbsentAreJSONNull() {
|
|
62
|
+
XCTAssertEqual(JSValues.toLDValue(nil), .null)
|
|
63
|
+
XCTAssertEqual(JSValues.toLDValue(NSNull()), .null)
|
|
64
|
+
XCTAssertTrue(JSValues.toJS(.null) is NSNull)
|
|
65
|
+
XCTAssertTrue(JSValues.toJS(nil) is NSNull)
|
|
66
|
+
}
|
|
67
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
import Capacitor
|
|
3
|
+
@testable import LaunchDarklyPlugin
|
|
4
|
+
|
|
5
|
+
// The JavaScript reaches the plugin by jsName and the method by its name; a rename on either side fails only at run time on a
|
|
6
|
+
// phone, so it is pinned here. jsName is also the name of the config block, plugins.LaunchDarkly.
|
|
7
|
+
final class LaunchDarklyPluginTests: XCTestCase {
|
|
8
|
+
private let methods = [
|
|
9
|
+
"initialize", "waitForInitialization", "getState", "identify", "variation", "allFlags", "setStreaming", "setOnline",
|
|
10
|
+
"flush", "close", "clearStoredContext"
|
|
11
|
+
]
|
|
12
|
+
|
|
13
|
+
func testIsRegisteredUnderTheNameTheJavaScriptUses() {
|
|
14
|
+
XCTAssertEqual(LaunchDarklyPlugin().jsName, "LaunchDarkly")
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
func testExposesEveryMethodAsAPromiseMethodAndNothingElse() {
|
|
18
|
+
let registered = LaunchDarklyPlugin().pluginMethods
|
|
19
|
+
XCTAssertEqual(registered.map { $0.name }, methods)
|
|
20
|
+
XCTAssertTrue(registered.allSatisfy { $0.returnType == CAPPluginReturnPromise })
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func testEveryMethodNameHasAnObjectiveCSelector() {
|
|
24
|
+
// Capacitor calls a method through the selector "<name>:"; a missing @objc compiles fine and fails on the phone.
|
|
25
|
+
for name in methods {
|
|
26
|
+
XCTAssertTrue(LaunchDarklyPlugin().responds(to: NSSelectorFromString("\(name):")), name)
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
func testShipsAPrivacyManifestDeclaringUserDefaults() throws {
|
|
31
|
+
let bundle = Bundle.module
|
|
32
|
+
let url = try XCTUnwrap(bundle.url(forResource: "PrivacyInfo", withExtension: "xcprivacy"))
|
|
33
|
+
let text = try String(contentsOf: url, encoding: .utf8)
|
|
34
|
+
XCTAssertTrue(text.contains("NSPrivacyAccessedAPICategoryUserDefaults"))
|
|
35
|
+
XCTAssertTrue(text.contains("CA92.1"))
|
|
36
|
+
}
|
|
37
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import LaunchDarklyPlugin
|
|
3
|
+
|
|
4
|
+
final class PluginConfigurationTests: XCTestCase {
|
|
5
|
+
func testNoConfigBlockMeansNoNativeStart() {
|
|
6
|
+
XCTAssertNil(PluginConfiguration.autoStart(nil))
|
|
7
|
+
XCTAssertNil(PluginConfiguration.autoStart([:]))
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
func testAMissingBlankOrNonStringMobileKeyMeansNoNativeStart() {
|
|
11
|
+
XCTAssertNil(PluginConfiguration.autoStart(["streaming": false]))
|
|
12
|
+
XCTAssertNil(PluginConfiguration.autoStart(["mobileKey": " "]))
|
|
13
|
+
XCTAssertNil(PluginConfiguration.autoStart(["mobileKey": 42]))
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
func testAMobileKeyAloneStartsWithTheDefaults() throws {
|
|
17
|
+
let start = try XCTUnwrap(PluginConfiguration.autoStart(["mobileKey": "mob-test"]))
|
|
18
|
+
XCTAssertEqual(start.options, StartOptions(mobileKey: "mob-test"))
|
|
19
|
+
XCTAssertTrue(start.options.streaming)
|
|
20
|
+
XCTAssertFalse(start.options.evaluationReasons)
|
|
21
|
+
XCTAssertFalse(start.options.offline)
|
|
22
|
+
XCTAssertEqual(start.options.startWaitSeconds, 5)
|
|
23
|
+
XCTAssertEqual(start.initialContext, PluginConfiguration.defaultInitialContext)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
func testReadsEveryOption() throws {
|
|
27
|
+
let start = try XCTUnwrap(PluginConfiguration.autoStart([
|
|
28
|
+
"mobileKey": "mob-test",
|
|
29
|
+
"streaming": false,
|
|
30
|
+
"evaluationReasons": true,
|
|
31
|
+
"offline": true,
|
|
32
|
+
"startWaitSeconds": 2.5,
|
|
33
|
+
"application": ["id": "app", "name": "App", "version": "1.2.3", "versionName": "1.2"],
|
|
34
|
+
"initialContext": ["kind": "device", "key": "shared", "anonymous": true]
|
|
35
|
+
]))
|
|
36
|
+
var expected = StartOptions(mobileKey: "mob-test")
|
|
37
|
+
expected.streaming = false
|
|
38
|
+
expected.evaluationReasons = true
|
|
39
|
+
expected.offline = true
|
|
40
|
+
expected.startWaitSeconds = 2.5
|
|
41
|
+
expected.application = ApplicationMetadata(id: "app", name: "App", version: "1.2.3", versionName: "1.2")
|
|
42
|
+
XCTAssertEqual(start.options, expected)
|
|
43
|
+
XCTAssertEqual(try Contexts.parse(start.initialContext).contextKeys()["device"], "shared")
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func testAWaitThatIsNotAUsableNumberFallsBackToTheDefault() {
|
|
47
|
+
XCTAssertEqual(StartOptions.seconds(-1), 5)
|
|
48
|
+
XCTAssertEqual(StartOptions.seconds(true), 5)
|
|
49
|
+
XCTAssertEqual(StartOptions.seconds("3"), 5)
|
|
50
|
+
XCTAssertEqual(StartOptions.seconds(3), 3)
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import XCTest
|
|
2
|
+
@testable import LaunchDarklyPlugin
|
|
3
|
+
|
|
4
|
+
final class StartContextTests: XCTestCase {
|
|
5
|
+
private let initial = #"{"kind":"user","key":"anonymous_user","anonymous":true}"#
|
|
6
|
+
private let stored = #"{"kind":"user","key":"user-42","name":"Ann"}"#
|
|
7
|
+
|
|
8
|
+
func testAStoredContextWinsOverTheInitialOne() {
|
|
9
|
+
let choice = StartContext.choose(stored: stored, initialContext: initial)
|
|
10
|
+
XCTAssertEqual(choice.context.fullyQualifiedKey(), "user-42")
|
|
11
|
+
XCTAssertEqual(choice.json, stored)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
func testWithoutAStoredContextTheInitialOneIsUsed() {
|
|
15
|
+
XCTAssertEqual(StartContext.choose(stored: nil, initialContext: initial).context.fullyQualifiedKey(), "anonymous_user")
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
func testAnInvalidStoredContextFallsBackToTheInitialOne() {
|
|
19
|
+
XCTAssertEqual(StartContext.choose(stored: #"{"kind":"user"}"#, initialContext: initial).json, initial)
|
|
20
|
+
XCTAssertEqual(StartContext.choose(stored: "not json", initialContext: initial).json, initial)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
func testAnInvalidInitialContextFallsBackToTheDefault() {
|
|
24
|
+
XCTAssertEqual(StartContext.choose(stored: nil, initialContext: #"{"kind":"multi"}"#).json, PluginConfiguration.defaultInitialContext)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
func testTheUserDefaultsStoreKeepsWritesAndClears() throws {
|
|
28
|
+
let defaults = try XCTUnwrap(UserDefaults(suiteName: "StartContextTests-\(UUID().uuidString)"))
|
|
29
|
+
let store = UserDefaultsContextStore(defaults: defaults)
|
|
30
|
+
XCTAssertNil(store.read())
|
|
31
|
+
store.write(stored)
|
|
32
|
+
XCTAssertEqual(UserDefaultsContextStore(defaults: defaults).read(), stored)
|
|
33
|
+
store.clear()
|
|
34
|
+
XCTAssertNil(store.read())
|
|
35
|
+
}
|
|
36
|
+
}
|