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.
Files changed (40) hide show
  1. package/CapacitorLaunchdarkly.podspec +20 -0
  2. package/LICENSE +21 -0
  3. package/Package.swift +32 -0
  4. package/README.md +747 -2
  5. package/android/build.gradle +68 -0
  6. package/android/src/main/AndroidManifest.xml +2 -0
  7. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/Json.kt +67 -0
  8. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyClient.kt +250 -0
  9. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LaunchDarklyPlugin.kt +148 -0
  10. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/LdConfigFactory.kt +34 -0
  11. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StartOptions.kt +76 -0
  12. package/android/src/main/kotlin/io/github/maximbelov/launchdarkly/StoredContext.kt +47 -0
  13. package/android/src/main/res/.gitkeep +0 -0
  14. package/dist/docs.json +1174 -0
  15. package/dist/esm/definitions.d.ts +520 -0
  16. package/dist/esm/definitions.js +2 -0
  17. package/dist/esm/definitions.js.map +1 -0
  18. package/dist/esm/index.d.ts +4 -0
  19. package/dist/esm/index.js +7 -0
  20. package/dist/esm/index.js.map +1 -0
  21. package/dist/esm/web.d.ts +46 -0
  22. package/dist/esm/web.js +204 -0
  23. package/dist/esm/web.js.map +1 -0
  24. package/dist/plugin.cjs.js +221 -0
  25. package/dist/plugin.cjs.js.map +1 -0
  26. package/dist/plugin.js +224 -0
  27. package/dist/plugin.js.map +1 -0
  28. package/ios/Sources/LaunchDarklyPlugin/ConfigFactory.swift +29 -0
  29. package/ios/Sources/LaunchDarklyPlugin/JSONValues.swift +68 -0
  30. package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyClient.swift +165 -0
  31. package/ios/Sources/LaunchDarklyPlugin/LaunchDarklyPlugin.swift +169 -0
  32. package/ios/Sources/LaunchDarklyPlugin/PrivacyInfo.xcprivacy +23 -0
  33. package/ios/Sources/LaunchDarklyPlugin/StartOptions.swift +79 -0
  34. package/ios/Sources/LaunchDarklyPlugin/StoredContext.swift +43 -0
  35. package/ios/Tests/LaunchDarklyPluginTests/BillingSafetyTests.swift +53 -0
  36. package/ios/Tests/LaunchDarklyPluginTests/JSONValuesTests.swift +67 -0
  37. package/ios/Tests/LaunchDarklyPluginTests/LaunchDarklyPluginTests.swift +37 -0
  38. package/ios/Tests/LaunchDarklyPluginTests/PluginConfigurationTests.swift +52 -0
  39. package/ios/Tests/LaunchDarklyPluginTests/StartContextTests.swift +36 -0
  40. 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
+ }