Skip to content

Commit b928345

Browse files
authoredNov 6, 2024··
feat: Add cache usage option for identify calls (#408)
The cache handling option allows users to control the flag store transitions while identification network requests asynchronously resolve.
1 parent 2fbca8f commit b928345

File tree

5 files changed

+187
-80
lines changed

5 files changed

+187
-80
lines changed
 

‎LaunchDarkly.xcodeproj/project.pbxproj

+11-10
Original file line numberDiff line numberDiff line change
@@ -284,10 +284,11 @@
284284
A3BA7D022BD192240000DB28 /* LDClientHookSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7D012BD192240000DB28 /* LDClientHookSpec.swift */; };
285285
A3BA7D042BD2BD620000DB28 /* TestContext.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3BA7D032BD2BD620000DB28 /* TestContext.swift */; };
286286
A3C6F7622B7FA803005B3B61 /* SheddingQueueSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */; };
287-
A3C6F7642B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; };
288-
A3C6F7652B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; };
289-
A3C6F7662B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; };
290-
A3C6F7672B84EF0C005B3B61 /* IdentifyResult.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */; };
287+
A3C6F7642B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; };
288+
A3C6F7652B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; };
289+
A3C6F7662B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; };
290+
A3C6F7672B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */; };
291+
A3F4A4812CC2F640006EF480 /* CwlPreconditionTesting in Frameworks */ = {isa = PBXBuildFile; productRef = A3F4A4802CC2F640006EF480 /* CwlPreconditionTesting */; };
291292
A3FFE1132B7D4BA2009EF93F /* LDValueDecoderSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */; };
292293
B40B419C249ADA6B00CD0726 /* DiagnosticCacheSpec.swift in Sources */ = {isa = PBXBuildFile; fileRef = B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */; };
293294
B4265EB124E7390C001CFD2C /* TestUtil.swift in Sources */ = {isa = PBXBuildFile; fileRef = B4265EB024E7390C001CFD2C /* TestUtil.swift */; };
@@ -511,7 +512,7 @@
511512
A3BA7D012BD192240000DB28 /* LDClientHookSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDClientHookSpec.swift; sourceTree = "<group>"; };
512513
A3BA7D032BD2BD620000DB28 /* TestContext.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestContext.swift; sourceTree = "<group>"; };
513514
A3C6F7612B7FA803005B3B61 /* SheddingQueueSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SheddingQueueSpec.swift; sourceTree = "<group>"; };
514-
A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyResult.swift; sourceTree = "<group>"; };
515+
A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IdentifyTypes.swift; sourceTree = "<group>"; };
515516
A3FFE1122B7D4BA2009EF93F /* LDValueDecoderSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = LDValueDecoderSpec.swift; sourceTree = "<group>"; };
516517
B40B419B249ADA6B00CD0726 /* DiagnosticCacheSpec.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DiagnosticCacheSpec.swift; sourceTree = "<group>"; };
517518
B4265EB024E7390C001CFD2C /* TestUtil.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestUtil.swift; sourceTree = "<group>"; };
@@ -726,7 +727,7 @@
726727
8354EFDE1F26380700C05156 /* Event.swift */,
727728
83EBCB9D20D9A0A1003A7142 /* FeatureFlag */,
728729
8354EFDD1F26380700C05156 /* LDConfig.swift */,
729-
A3C6F7632B84EF0C005B3B61 /* IdentifyResult.swift */,
730+
A3C6F7632B84EF0C005B3B61 /* IdentifyTypes.swift */,
730731
);
731732
path = Models;
732733
sourceTree = "<group>";
@@ -1346,7 +1347,7 @@
13461347
B4C9D43B2489E20A004A9B03 /* DiagnosticReporter.swift in Sources */,
13471348
C443A40523145FBF00145710 /* ConnectionInformation.swift in Sources */,
13481349
B468E71324B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */,
1349-
A3C6F7672B84EF0C005B3B61 /* IdentifyResult.swift in Sources */,
1350+
A3C6F7672B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */,
13501351
8354AC732243166900CDE602 /* FeatureFlagCache.swift in Sources */,
13511352
8311885B2113AE1D00D77CB5 /* Throttler.swift in Sources */,
13521353
8311884E2113ADE500D77CB5 /* Event.swift in Sources */,
@@ -1375,7 +1376,7 @@
13751376
B468E71224B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */,
13761377
A36EDFCF2853C50B00D91B05 /* ObjcLDContext.swift in Sources */,
13771378
831EF34320655E730001C643 /* LDCommon.swift in Sources */,
1378-
A3C6F7662B84EF0C005B3B61 /* IdentifyResult.swift in Sources */,
1379+
A3C6F7662B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */,
13791380
831EF34420655E730001C643 /* LDConfig.swift in Sources */,
13801381
A31088212837DC0400184942 /* LDContext.swift in Sources */,
13811382
831EF34520655E730001C643 /* LDClient.swift in Sources */,
@@ -1491,7 +1492,7 @@
14911492
83B6C4B61F4DE7630055351C /* LDCommon.swift in Sources */,
14921493
B4C9D4382489E20A004A9B03 /* DiagnosticReporter.swift in Sources */,
14931494
8347BB0C21F147E100E56BCD /* LDTimer.swift in Sources */,
1494-
A3C6F7642B84EF0C005B3B61 /* IdentifyResult.swift in Sources */,
1495+
A3C6F7642B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */,
14951496
B468E71024B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */,
14961497
8354AC702243166900CDE602 /* FeatureFlagCache.swift in Sources */,
14971498
A36EDFC82853883400D91B05 /* ObjcLDReference.swift in Sources */,
@@ -1617,7 +1618,7 @@
16171618
83D9EC922062DEAB004D7FA6 /* Data.swift in Sources */,
16181619
8347BB0D21F147E100E56BCD /* LDTimer.swift in Sources */,
16191620
8354AC712243166900CDE602 /* FeatureFlagCache.swift in Sources */,
1620-
A3C6F7652B84EF0C005B3B61 /* IdentifyResult.swift in Sources */,
1621+
A3C6F7652B84EF0C005B3B61 /* IdentifyTypes.swift in Sources */,
16211622
C443A40323145FB700145710 /* ConnectionInformation.swift in Sources */,
16221623
B4C9D4392489E20A004A9B03 /* DiagnosticReporter.swift in Sources */,
16231624
B468E71124B3C3AC00E0C883 /* ObjcLDEvaluationDetail.swift in Sources */,

‎LaunchDarkly/LaunchDarkly/LDClient.swift

+60-27
Original file line numberDiff line numberDiff line change
@@ -292,7 +292,7 @@ public class LDClient {
292292
*/
293293
@available(*, deprecated, message: "Use LDClient.identify(context: completion:) with non-optional completion parameter")
294294
public func identify(context: LDContext, completion: (() -> Void)? = nil) {
295-
_identify(context: context, sheddable: false) { _ in
295+
_identify(context: context, sheddable: false, useCache: .yes) { _ in
296296
if let completion = completion {
297297
completion()
298298
}
@@ -315,19 +315,33 @@ public class LDClient {
315315
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
316316
*/
317317
public func identify(context: LDContext, completion: @escaping (_ result: IdentifyResult) -> Void) {
318-
_identify(context: context, sheddable: true, completion: completion)
318+
_identify(context: context, sheddable: true, useCache: .yes, completion: completion)
319+
}
320+
321+
/**
322+
Sets the LDContext into the LDClient inline with the behavior detailed on `LDClient.identify(context: completion:)`. Additionally,
323+
this method allows specifying how the flag cache should be handled when transitioning between contexts through the `useCache` parameter.
324+
325+
To learn more about these cache transitions, refer to the `IdentifyCacheUsage` documentation.
326+
327+
- parameter context: The LDContext set with the desired context.
328+
- parameter useCache: How to handle flag caches during identify transition.
329+
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
330+
*/
331+
public func identify(context: LDContext, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
332+
_identify(context: context, sheddable: true, useCache: useCache, completion: completion)
319333
}
320334

321335
// Temporary helper method to allow code sharing between the sheddable and unsheddable identify methods. In the next major release, we will remove the deprecated identify method and inline
322336
// this implementation in the other one.
323-
private func _identify(context: LDContext, sheddable: Bool, completion: @escaping (_ result: IdentifyResult) -> Void) {
337+
private func _identify(context: LDContext, sheddable: Bool, useCache: IdentifyCacheUsage, completion: @escaping (_ result: IdentifyResult) -> Void) {
324338
let work: TaskHandler = { taskCompletion in
325339
let dispatch = DispatchGroup()
326340

327341
LDClient.instancesQueue.sync(flags: .barrier) {
328342
LDClient.instances?.forEach { _, instance in
329343
dispatch.enter()
330-
instance.internalIdentify(newContext: context, completion: dispatch.leave)
344+
instance.internalIdentify(newContext: context, useCache: useCache, completion: dispatch.leave)
331345
}
332346
}
333347

@@ -354,6 +368,21 @@ public class LDClient {
354368
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
355369
*/
356370
public func identify(context: LDContext, timeout: TimeInterval, completion: @escaping ((_ result: IdentifyResult) -> Void)) {
371+
identify(context: context, timeout: timeout, useCache: .yes, completion: completion)
372+
}
373+
374+
/**
375+
Sets the LDContext into the LDClient inline with the behavior detailed on `LDClient.identify(context: timeout: completion:)`. Additionally,
376+
this method allows specifying how the flag cache should be handled when transitioning between contexts through the `useCache` parameter.
377+
378+
To learn more about these cache transitions, refer to the `IdentifyCacheUsage` documentation.
379+
380+
- parameter context: The LDContext set with the desired context.
381+
- parameter timeout: The upper time limit before the `completion` callback will be invoked.
382+
- parameter useCache: How to handle flag caches during identify transition.
383+
- parameter completion: Closure called when the embedded `setOnlineIdentify` call completes, subject to throttling delays.
384+
*/
385+
public func identify(context: LDContext, timeout: TimeInterval, useCache: IdentifyCacheUsage, completion: @escaping ((_ result: IdentifyResult) -> Void)) {
357386
if timeout > LDClient.longTimeoutInterval {
358387
os_log("%s LDClient.identify was called with a timeout greater than %f seconds. We recommend a timeout of less than %f seconds.", log: config.logger, type: .info, self.typeName(and: #function), LDClient.longTimeoutInterval, LDClient.longTimeoutInterval)
359388
}
@@ -367,15 +396,15 @@ public class LDClient {
367396
completion(.timeout)
368397
}
369398

370-
identify(context: context) { result in
399+
identify(context: context, useCache: useCache) { result in
371400
guard !cancel else { return }
372401

373402
cancel = true
374403
completion(result)
375404
}
376405
}
377406

378-
func internalIdentify(newContext: LDContext, completion: (() -> Void)? = nil) {
407+
func internalIdentify(newContext: LDContext, useCache: IdentifyCacheUsage, completion: (() -> Void)? = nil) {
379408
var updatedContext = newContext
380409
if config.autoEnvAttributes {
381410
updatedContext = AutoEnvContextModifier(environmentReporter: environmentReporter, logger: config.logger).modifyContext(updatedContext)
@@ -394,27 +423,31 @@ public class LDClient {
394423
self.internalSetOnline(false)
395424

396425
let cachedData = self.flagCache.getCachedData(cacheKey: self.context.fullyQualifiedHashedKey(), contextHash: self.context.contextHash())
397-
let cachedContextFlags = cachedData.items ?? [:]
398-
let oldItems = flagStore.storedItems.featureFlags
399-
400-
// Here we prime the store with the last known values from the
401-
// cache.
402-
//
403-
// Once the flag sync. process finishes, the new payload is
404-
// compared to this, and if they are different, change listeners
405-
// will be notified; otherwise, they aren't.
406-
//
407-
// This is problematic since the flag values really did change. So
408-
// we should trigger the change listener when we set these cache
409-
// values.
410-
//
411-
// However, if there are no cached values, we don't want to inform
412-
// customers that we set their store to nothing. In that case, we
413-
// will not trigger the change listener and instead relay on the
414-
// payload comparsion to do that when the request has completed.
415-
flagStore.replaceStore(newStoredItems: cachedContextFlags)
416-
if !cachedContextFlags.featureFlags.isEmpty {
417-
flagChangeNotifier.notifyObservers(oldFlags: oldItems, newFlags: flagStore.storedItems.featureFlags)
426+
427+
if useCache != .no {
428+
let oldItems = flagStore.storedItems
429+
let fallback = useCache == .yes ? [:] : oldItems
430+
let cachedContextFlags = cachedData.items ?? fallback
431+
432+
// Here we prime the store with the last known values from the
433+
// cache.
434+
//
435+
// Once the flag sync. process finishes, the new payload is
436+
// compared to this, and if they are different, change listeners
437+
// will be notified; otherwise, they aren't.
438+
//
439+
// This is problematic since the flag values really did change. So
440+
// we should trigger the change listener when we set these cache
441+
// values.
442+
//
443+
// However, if there are no cached values, we don't want to inform
444+
// customers that we set their store to nothing. In that case, we
445+
// will not trigger the change listener and instead relay on the
446+
// payload comparsion to do that when the request has completed.
447+
flagStore.replaceStore(newStoredItems: cachedContextFlags)
448+
if !cachedContextFlags.featureFlags.isEmpty {
449+
flagChangeNotifier.notifyObservers(oldFlags: oldItems.featureFlags, newFlags: flagStore.storedItems.featureFlags)
450+
}
418451
}
419452

420453
self.service.context = self.context

‎LaunchDarkly/LaunchDarkly/Models/IdentifyResult.swift

-34
This file was deleted.
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
import Foundation
2+
3+
/**
4+
Denotes the result of an identify request made through the `LDClient.identify(context: completion:)` method.
5+
*/
6+
public enum IdentifyResult {
7+
/**
8+
The identify request has completed successfully.
9+
*/
10+
case complete
11+
/**
12+
The identify request has received an unrecoverable failure.
13+
*/
14+
case error
15+
/**
16+
The identify request has been replaced with a subsequent request. Read `LDClient.identify(context: completion:)` for more details.
17+
*/
18+
case shed
19+
/**
20+
The identify request exceeded some time out parameter. Read `LDClient.identify(context: timeout: completion)` for more details.
21+
*/
22+
case timeout
23+
24+
init(from: TaskResult) {
25+
switch from {
26+
case .complete:
27+
self = .complete
28+
case .error:
29+
self = .error
30+
case .shed:
31+
self = .shed
32+
}
33+
}
34+
}
35+
36+
/**
37+
When a new `LDContext` is being identified, the SDK has a few choices it can make on how to handle intermediate flag evaluations
38+
until fresh values have been retrieved from the LaunchDarkly APIs.
39+
*/
40+
public enum IdentifyCacheUsage {
41+
/**
42+
`no` will not load any flag values from the cache. Instead it will maintain the current in memory state from the previously identified context.
43+
44+
This method ensures the greatest continuity of experience until the identify network communication resolves.
45+
*/
46+
case no
47+
48+
/**
49+
`yes` will clear the in memory state of any previously known flag values. The SDK will attempt to load cached flag values for the newly identified
50+
context. If no cache is found, the state remains empty until the network request resolves.
51+
*/
52+
case yes
53+
54+
/**
55+
`ifAvailable` will attempt to load cached flag values for the newly identified context. If cached values are found, the in memory state is fully
56+
replaced with those values.
57+
58+
If no cached values are found, the existing in memory state is retained until the network request resolves.
59+
*/
60+
case ifAvailable
61+
}

‎LaunchDarkly/LaunchDarklyTests/LDClientSpec.swift

+55-9
Original file line numberDiff line numberDiff line change
@@ -158,7 +158,7 @@ final class LDClientSpec: QuickSpec {
158158
withTimeout ? testContext.start(timeOut: 10.0) : testContext.start()
159159

160160
testContext.context = LDContext.stub()
161-
testContext.subject.internalIdentify(newContext: testContext.context)
161+
testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes)
162162
}
163163
it("saves the config") {
164164
expect(testContext.subject.config) == testContext.config
@@ -442,7 +442,7 @@ final class LDClientSpec: QuickSpec {
442442
testContext.featureFlagCachingMock.reset()
443443

444444
let newContext = LDContext.stub()
445-
testContext.subject.internalIdentify(newContext: newContext)
445+
testContext.subject.internalIdentify(newContext: newContext, useCache: .yes)
446446

447447
expect(testContext.subject.context) == newContext
448448
expect(testContext.subject.service.context) == newContext
@@ -464,7 +464,7 @@ final class LDClientSpec: QuickSpec {
464464
testContext.featureFlagCachingMock.reset()
465465

466466
let newContext = LDContext.stub()
467-
testContext.subject.internalIdentify(newContext: newContext)
467+
testContext.subject.internalIdentify(newContext: newContext, useCache: .yes)
468468

469469
expect(testContext.subject.context) == newContext
470470
expect(testContext.subject.service.context) == newContext
@@ -487,7 +487,7 @@ final class LDClientSpec: QuickSpec {
487487
testContext.start()
488488
testContext.featureFlagCachingMock.reset()
489489

490-
testContext.subject.internalIdentify(newContext: newContext)
490+
testContext.subject.internalIdentify(newContext: newContext, useCache: .yes)
491491

492492
expect(testContext.subject.context) == newContext
493493
expect(testContext.flagStoreMock.replaceStoreCallCount) == 1
@@ -501,7 +501,7 @@ final class LDClientSpec: QuickSpec {
501501
testContext.featureFlagCachingMock.reset()
502502

503503
let newContext = LDContext.stub()
504-
testContext.subject.internalIdentify(newContext: newContext)
504+
testContext.subject.internalIdentify(newContext: newContext, useCache: .yes)
505505

506506
expect(newContext.contextKeys().count) < testContext.subject.service.context.contextKeys().count
507507

@@ -516,10 +516,10 @@ final class LDClientSpec: QuickSpec {
516516
testContext.start()
517517
testContext.featureFlagCachingMock.reset()
518518

519-
testContext.subject.internalIdentify(newContext: testContext.context)
520-
testContext.subject.internalIdentify(newContext: testContext.context)
521-
testContext.subject.internalIdentify(newContext: testContext.context)
522-
testContext.subject.internalIdentify(newContext: testContext.context)
519+
testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes)
520+
testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes)
521+
testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes)
522+
testContext.subject.internalIdentify(newContext: testContext.context, useCache: .yes)
523523

524524
expect(testContext.flagStoreMock.replaceStoreCallCount) == 0
525525
expect(testContext.makeFlagSynchronizerService?.context) == testContext.context
@@ -529,6 +529,52 @@ final class LDClientSpec: QuickSpec {
529529
expect(testContext.subject.flagSynchronizer.isOnline) == true
530530
expect(testContext.eventReporterMock.recordReceivedEvent?.kind == .identify).to(beTrue())
531531
}
532+
533+
it("no cache requires no store interaction") {
534+
let testContext = TestContext(startOnline: true)
535+
testContext.start()
536+
testContext.featureFlagCachingMock.reset()
537+
538+
testContext.subject.internalIdentify(newContext: testContext.context, useCache: .no)
539+
540+
expect(testContext.flagStoreMock.replaceStoreCallCount) == 0
541+
expect(testContext.makeFlagSynchronizerService?.context) == testContext.context
542+
543+
expect(testContext.subject.isOnline) == true
544+
expect(testContext.subject.eventReporter.isOnline) == true
545+
expect(testContext.subject.flagSynchronizer.isOnline) == true
546+
expect(testContext.eventReporterMock.recordReceivedEvent?.kind == .identify).to(beTrue())
547+
}
548+
549+
it("ifAvailable requires no store information on cache miss") {
550+
let testContext = TestContext(startOnline: true)
551+
testContext.start()
552+
testContext.featureFlagCachingMock.reset()
553+
554+
testContext.subject.internalIdentify(newContext: testContext.context, useCache: .ifAvailable)
555+
556+
expect(testContext.flagStoreMock.replaceStoreCallCount) == 0
557+
expect(testContext.makeFlagSynchronizerService?.context) == testContext.context
558+
559+
expect(testContext.subject.isOnline) == true
560+
expect(testContext.subject.eventReporter.isOnline) == true
561+
expect(testContext.subject.flagSynchronizer.isOnline) == true
562+
expect(testContext.eventReporterMock.recordReceivedEvent?.kind == .identify).to(beTrue())
563+
}
564+
565+
it("ifAvailable updates store when cache is present") {
566+
let stubFlags = FlagMaintainingMock.stubStoredItems()
567+
let newContext = LDContext.stub()
568+
let testContext = TestContext().withCached(contextKey: newContext.fullyQualifiedHashedKey(), flags: stubFlags.featureFlags)
569+
testContext.start()
570+
testContext.featureFlagCachingMock.reset()
571+
572+
testContext.subject.internalIdentify(newContext: newContext, useCache: .ifAvailable)
573+
574+
expect(testContext.subject.context) == newContext
575+
expect(testContext.flagStoreMock.replaceStoreCallCount) == 1
576+
expect(testContext.flagStoreMock.replaceStoreReceivedNewFlags) == stubFlags
577+
}
532578
}
533579
}
534580

0 commit comments

Comments
 (0)
Please sign in to comment.