diff --git a/Package.swift b/Package.swift index 7b258b6a..c7e11667 100644 --- a/Package.swift +++ b/Package.swift @@ -12,6 +12,8 @@ let package = Package( .library(name: "CreditKit", targets: ["CreditKit"]), .library(name: "LifecycleKit", targets: ["LifecycleKit"]), .library(name: "LifecycleKitUI", targets: ["LifecycleKitUI"]), + .library(name: "Flagger", targets: ["Flagger"]), + .library(name: "FlaggerUI", targets: ["FlaggerUI"]), .library(name: "JournalKit", targets: ["JournalKit"]), .library(name: "PeriscopeCore", targets: ["PeriscopeCore"]), .library(name: "PeriscopeUI", targets: ["PeriscopeUI"]), @@ -67,6 +69,17 @@ let package = Package( name: "JournalKit", path: "Shared/JournalKit/Sources", ), + .target( + name: "Flagger", + path: "Shared/Flagger/Sources", + ), + .target( + name: "FlaggerUI", + dependencies: [ + .target(name: "Flagger"), + ], + path: "Shared/FlaggerUI/Sources", + ), .target( name: "PeriscopeCore", dependencies: [ diff --git a/Project.swift b/Project.swift index be89a47c..6360cc9a 100644 --- a/Project.swift +++ b/Project.swift @@ -390,6 +390,19 @@ let project = Project( productDependency: "JournalKit", sources: ["Shared/JournalKit/Tests/**"], ), + unitTests( + name: "FlaggerTests", + bundleIdSuffix: "flagger", + productDependency: "Flagger", + sources: ["Shared/Flagger/Tests/**"], + ), + unitTests( + name: "FlaggerUITests", + bundleIdSuffix: "flaggerui", + productDependency: "FlaggerUI", + sources: ["Shared/FlaggerUI/Tests/**"], + extraPackageProducts: ["Flagger"], + ), unitTests( name: "PeriscopeCoreTests", bundleIdSuffix: "periscopecore", @@ -556,6 +569,14 @@ let project = Project( extraPackageProducts: ["SnapshotKitTesting"], environmentVariables: snapshotEnvironment, ), + unitTests( + name: "FlaggerUISnapshotTests", + bundleIdSuffix: "flaggerui.snapshot", + productDependency: "FlaggerUI", + sources: ["Shared/FlaggerUI/SnapshotTests/**"], + extraPackageProducts: ["SnapshotKitTesting"], + environmentVariables: snapshotEnvironment, + ), .target( name: "BroadwayCatalog", destinations: destinations, @@ -630,6 +651,8 @@ let project = Project( "LifecycleKitTests", "LifecycleKitUITests", "JournalKitTests", + "FlaggerTests", + "FlaggerUITests", "PeriscopeCoreTests", "PeriscopeUITests", "PeriscopeToolsTests", @@ -654,6 +677,8 @@ let project = Project( "LifecycleKitTests", "LifecycleKitUITests", "JournalKitTests", + "FlaggerTests", + "FlaggerUITests", "PeriscopeCoreTests", "PeriscopeUITests", "PeriscopeToolsTests", @@ -678,6 +703,8 @@ let project = Project( testScheme(name: "LifecycleKitTests"), testScheme(name: "LifecycleKitUITests"), testScheme(name: "JournalKitTests"), + testScheme(name: "FlaggerTests"), + testScheme(name: "FlaggerUITests"), testScheme(name: "PeriscopeCoreTests"), testScheme(name: "PeriscopeUITests"), testScheme(name: "PeriscopeToolsTests"), @@ -706,6 +733,7 @@ let project = Project( "FlyoverSnapshotTests", "PeriscopeToolsSnapshotTests", "InspectorSnapshotTests", + "FlaggerUISnapshotTests", ]), testAction: .targets( [ @@ -713,6 +741,7 @@ let project = Project( "FlyoverSnapshotTests", "PeriscopeToolsSnapshotTests", "InspectorSnapshotTests", + "FlaggerUISnapshotTests", ], arguments: .arguments(environmentVariables: snapshotEnvironment), ), diff --git a/Shared/Flagger/AGENTS.md b/Shared/Flagger/AGENTS.md new file mode 100644 index 00000000..7bdb4acc --- /dev/null +++ b/Shared/Flagger/AGENTS.md @@ -0,0 +1,23 @@ +# Flagger – Module Shape + +Flagger is the SwiftUI-free feature-flag engine: typed group/source +registration, behavior policies, cached reads, failure streams, and +default-eliding SwiftData persistence. See [`README.md`](README.md). + +This complements the root [`AGENTS.md`](../../AGENTS.md). + +## Scope & dependencies + +- Foundation, os, and SwiftData only; app flag groups and sources stay in consumers. +- One Flagger instance owns one scope and one physical store; create it once at the composition root and inject it down. + +## Invariants + +- Persist only overrides; writing the current default deletes the row. +- Launch and first-access policies freeze effective values for the instance lifetime; only `LiveUpdating` has public typed mutation and value-stream APIs. +- Sources, group types, and stable flag IDs are unique within a Flagger. +- Synchronous reads touch only `OSAllocatedUnfairLock` state; mutations replace its cache from a complete, versioned snapshot fetched by `FlaggerPersistence`. + +## Testing + +Swift Testing in [`Tests/`](Tests) uses fresh in-memory or temporary-URL stores. diff --git a/Shared/Flagger/README.md b/Shared/Flagger/README.md new file mode 100644 index 00000000..53a1bd14 --- /dev/null +++ b/Shared/Flagger/README.md @@ -0,0 +1,78 @@ +# Flagger + +Flagger is a scoped feature-flag engine backed by SwiftData. Modules own groups +of typed flags, expose those groups through a source, and the composition root +registers sources without enumerating their flags. Every flag has a default; +only JSON overrides different from that default are stored. + +## Declare a group + +```swift +public struct MapFlags: FeatureFlagGroup { + public static let id = FeatureFlagGroupID("map") + public static let name = "Map" + + public let newRenderer = Flag( + "new-renderer", + name: "New renderer", + default: true + ) + + public init() {} +} + +public extension FeatureFlagGroups { + var map: MapFlags { self[MapFlags.self] } +} +``` + +The explicit flag ID is the persisted identity and must survive Swift property +renames. Group properties are discovered once when Flagger opens; the typed +definitions are erased only for persistence and editor metadata. + +## Expose and register module sources + +```swift +public enum WhereUIFlagSource: FlagSource { + public static let id = FlagSourceID("where-ui") + public static let name = "Where UI" + public static let groups = FeatureFlagGroupRegistry { + MapFlags.self + } +} + +let sources = FlagSourceRegistry { + WhereCoreFlagSource.self + WhereUIFlagSource.self +} +let flagger = try await Flagger.open( + sources: sources, + storage: .onDisk(name: "WhereFlags") +) +``` + +Each Flagger instance owns one scope and physical store. Use distinct instances +for app-wide, logged-in, demo, or other worlds; inject an existing +`ModelContainer` or explicit URL when the host owns store placement. + +## Behaviors and access + +- `ReadOnceOnLaunch` resolves when Flagger opens. +- `ReadOnceOnFirstAccess` resolves on its first read. +- `LiveUpdating` may be changed and observed while Flagger is alive. + +Reads are synchronous from lock-protected state loaded at open. SwiftData opens +and writes remain asynchronous and actor-isolated. Because flags change rarely, +each mutation reloads the complete override store and atomically replaces the +cache with the newest versioned snapshot: + +```swift +let enabled = try flagger.value(for: MapFlags().newRenderer) +try await flagger.set(false, for: MapFlags().newRenderer) +for await enabled in flagger.values(for: MapFlags().newRenderer) { /* … */ } +``` + +`value(for:)` throws decoding failures. `valueOrDefault(for:)` returns the +declared default and emits the failure through `failures()`. A failed frozen +flag stays on its default for that lifetime; repairing its override applies to +the next applicable lifetime. diff --git a/Shared/Flagger/Sources/FeatureFlagBehavior.swift b/Shared/Flagger/Sources/FeatureFlagBehavior.swift new file mode 100644 index 00000000..dc9a0d26 --- /dev/null +++ b/Shared/Flagger/Sources/FeatureFlagBehavior.swift @@ -0,0 +1,25 @@ +/// A type-level policy controlling when a flag's effective value may change. +public protocol FeatureFlagBehavior: Sendable { + static var kind: FeatureFlagBehaviorKind { get } +} + +/// Resolves when its Flagger opens and stays fixed for that instance's lifetime. +public enum ReadOnceOnLaunch: FeatureFlagBehavior { + public static let kind = FeatureFlagBehaviorKind.readOnceOnLaunch +} + +/// Resolves on its first read and stays fixed for that Flagger instance's lifetime. +public enum ReadOnceOnFirstAccess: FeatureFlagBehavior { + public static let kind = FeatureFlagBehaviorKind.readOnceOnFirstAccess +} + +/// Resolves from the current override and may be updated while Flagger is alive. +public enum LiveUpdating: FeatureFlagBehavior { + public static let kind = FeatureFlagBehaviorKind.liveUpdating +} + +public enum FeatureFlagBehaviorKind: String, Codable, Sendable { + case readOnceOnLaunch + case readOnceOnFirstAccess + case liveUpdating +} diff --git a/Shared/Flagger/Sources/FeatureFlagGroup.swift b/Shared/Flagger/Sources/FeatureFlagGroup.swift new file mode 100644 index 00000000..744c5182 --- /dev/null +++ b/Shared/Flagger/Sources/FeatureFlagGroup.swift @@ -0,0 +1,42 @@ +/// A named collection of feature-flag definitions owned by one module source. +public protocol FeatureFlagGroup: Sendable { + init() + static var id: FeatureFlagGroupID { get } + static var name: String { get } + static var detail: String? { get } +} + +extension FeatureFlagGroup { + public static var detail: String? { + nil + } +} + +/// Stable identity for a feature-flag group. +public struct FeatureFlagGroupID: Hashable, Codable, Sendable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + precondition(rawValue.isEmpty == false, "A feature-flag group ID must not be empty.") + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.init(rawValue: rawValue) + } +} + +public struct FeatureFlagGroupMetadata: Identifiable, Hashable, Sendable { + public let id: FeatureFlagGroupID + public let name: String + public let detail: String? +} + +/// The environment-style namespace modules extend with named group accessors. +public struct FeatureFlagGroups: Sendable { + public init() {} + + public subscript(_: Group.Type) -> Group { + Group() + } +} diff --git a/Shared/Flagger/Sources/FeatureFlagGroupRegistry.swift b/Shared/Flagger/Sources/FeatureFlagGroupRegistry.swift new file mode 100644 index 00000000..073eea15 --- /dev/null +++ b/Shared/Flagger/Sources/FeatureFlagGroupRegistry.swift @@ -0,0 +1,47 @@ +/// An immutable, result-builder-created list of group types exposed by a source. +public struct FeatureFlagGroupRegistry: Sendable { + let types: [any FeatureFlagGroup.Type] + + public init(@FeatureFlagGroupRegistryBuilder _ content: () -> [any FeatureFlagGroup.Type]) { + types = content() + } +} + +@resultBuilder +public enum FeatureFlagGroupRegistryBuilder { + public static func buildExpression( + _ expression: (some FeatureFlagGroup).Type, + ) -> [any FeatureFlagGroup.Type] { + [expression] + } + + public static func buildBlock( + _ components: [any FeatureFlagGroup.Type]..., + ) -> [any FeatureFlagGroup.Type] { + components.flatMap(\.self) + } + + public static func buildOptional( + _ component: [any FeatureFlagGroup.Type]?, + ) -> [any FeatureFlagGroup.Type] { + component ?? [] + } + + public static func buildEither( + first component: [any FeatureFlagGroup.Type], + ) -> [any FeatureFlagGroup.Type] { + component + } + + public static func buildEither( + second component: [any FeatureFlagGroup.Type], + ) -> [any FeatureFlagGroup.Type] { + component + } + + public static func buildArray( + _ components: [[any FeatureFlagGroup.Type]], + ) -> [any FeatureFlagGroup.Type] { + components.flatMap(\.self) + } +} diff --git a/Shared/Flagger/Sources/Flag.swift b/Shared/Flagger/Sources/Flag.swift new file mode 100644 index 00000000..08fdb478 --- /dev/null +++ b/Shared/Flagger/Sources/Flag.swift @@ -0,0 +1,50 @@ +import Foundation + +/// A typed feature-flag definition stored as a property on a ``FeatureFlagGroup``. +public struct Flag: Sendable { + public let id: FlagID + public let name: String + public let detail: String? + public let defaultValue: Value + + public init( + _ id: FlagID, + name: String, + detail: String? = nil, + default defaultValue: Value, + ) { + self.id = id + self.name = name + self.detail = detail + self.defaultValue = defaultValue + } + + public init( + _ id: String, + name: String, + detail: String? = nil, + default defaultValue: Value, + ) { + self.init(FlagID(rawValue: id), name: name, detail: detail, default: defaultValue) + } +} + +protocol AnyFeatureFlag: Sendable { + func definition( + source: FeatureFlagSourceMetadata, + group: FeatureFlagGroupMetadata, + ) throws -> FlagDefinition +} + +extension Flag: AnyFeatureFlag { + func definition( + source: FeatureFlagSourceMetadata, + group: FeatureFlagGroupMetadata, + ) throws -> FlagDefinition { + try FlagDefinition( + flag: self, + source: source, + group: group, + ) + } +} diff --git a/Shared/Flagger/Sources/FlagDefinition.swift b/Shared/Flagger/Sources/FlagDefinition.swift new file mode 100644 index 00000000..e9711994 --- /dev/null +++ b/Shared/Flagger/Sources/FlagDefinition.swift @@ -0,0 +1,35 @@ +import Foundation + +struct FlagDefinition { + let id: FlagID + let name: String + let detail: String? + let behavior: FeatureFlagBehaviorKind + let defaultValue: JSONValue + let source: FeatureFlagSourceMetadata + let group: FeatureFlagGroupMetadata + let decode: @Sendable (JSONValue) throws -> any Sendable + + init( + flag: Flag, + source: FeatureFlagSourceMetadata, + group: FeatureFlagGroupMetadata, + ) throws where Value: Codable & Sendable, Behavior: FeatureFlagBehavior { + id = flag.id + name = flag.name + detail = flag.detail + behavior = Behavior.kind + defaultValue = try Self.json(flag.defaultValue) + self.source = source + self.group = group + decode = { json in try Self.value(Value.self, from: json) } + } + + static func json(_ value: some Encodable) throws -> JSONValue { + try JSONDecoder().decode(JSONValue.self, from: JSONEncoder.flagger.encode(value)) + } + + static func value(_ type: Value.Type, from json: JSONValue) throws -> Value { + try JSONDecoder().decode(type, from: JSONEncoder.flagger.encode(json)) + } +} diff --git a/Shared/Flagger/Sources/FlagID.swift b/Shared/Flagger/Sources/FlagID.swift new file mode 100644 index 00000000..1435b68f --- /dev/null +++ b/Shared/Flagger/Sources/FlagID.swift @@ -0,0 +1,13 @@ +/// Stable persisted identity for a feature flag. +public struct FlagID: Hashable, Codable, Sendable, RawRepresentable, CustomStringConvertible { + public let rawValue: String + + public init(rawValue: String) { + precondition(rawValue.isEmpty == false, "A flag ID must not be empty.") + self.rawValue = rawValue + } + + public var description: String { + rawValue + } +} diff --git a/Shared/Flagger/Sources/FlagOverride.swift b/Shared/Flagger/Sources/FlagOverride.swift new file mode 100644 index 00000000..ad9b0d8c --- /dev/null +++ b/Shared/Flagger/Sources/FlagOverride.swift @@ -0,0 +1,13 @@ +import Foundation +import SwiftData + +@Model +final class FlagOverride { + @Attribute(.unique) var key: String + var value: Data + + init(key: String, value: Data) { + self.key = key + self.value = value + } +} diff --git a/Shared/Flagger/Sources/FlagSnapshot.swift b/Shared/Flagger/Sources/FlagSnapshot.swift new file mode 100644 index 00000000..08b0fbfc --- /dev/null +++ b/Shared/Flagger/Sources/FlagSnapshot.swift @@ -0,0 +1,22 @@ +/// Editor-facing state for one registered flag. +public struct FlagSnapshot: Identifiable, Equatable, Sendable { + public let id: FlagID + public let name: String + public let detail: String? + public let source: FeatureFlagSourceMetadata + public let group: FeatureFlagGroupMetadata + public let behavior: FeatureFlagBehaviorKind + public let defaultValue: JSONValue + public let storedValue: JSONValue? + public let effectiveValue: JSONValue + public let isFrozen: Bool + public let failure: FlaggerFailure? + + public var isDefault: Bool { + storedValue == nil + } + + public var hasPendingChange: Bool { + isFrozen && (storedValue ?? defaultValue) != effectiveValue + } +} diff --git a/Shared/Flagger/Sources/FlagSource.swift b/Shared/Flagger/Sources/FlagSource.swift new file mode 100644 index 00000000..4ad4e7c5 --- /dev/null +++ b/Shared/Flagger/Sources/FlagSource.swift @@ -0,0 +1,24 @@ +/// A module-owned collection of explicitly registered feature-flag groups. +public protocol FlagSource: Sendable { + static var id: FlagSourceID { get } + static var name: String { get } + static var groups: FeatureFlagGroupRegistry { get } +} + +public struct FlagSourceID: Hashable, Codable, Sendable, RawRepresentable { + public let rawValue: String + + public init(rawValue: String) { + precondition(rawValue.isEmpty == false, "A flag source ID must not be empty.") + self.rawValue = rawValue + } + + public init(_ rawValue: String) { + self.init(rawValue: rawValue) + } +} + +public struct FeatureFlagSourceMetadata: Identifiable, Hashable, Sendable { + public let id: FlagSourceID + public let name: String +} diff --git a/Shared/Flagger/Sources/FlagSourceRegistry.swift b/Shared/Flagger/Sources/FlagSourceRegistry.swift new file mode 100644 index 00000000..3ceb6206 --- /dev/null +++ b/Shared/Flagger/Sources/FlagSourceRegistry.swift @@ -0,0 +1,47 @@ +/// The composition root's explicit list of module-level flag sources. +public struct FlagSourceRegistry: Sendable { + let types: [any FlagSource.Type] + + public init(@FlagSourceRegistryBuilder _ content: () -> [any FlagSource.Type]) { + types = content() + } +} + +@resultBuilder +public enum FlagSourceRegistryBuilder { + public static func buildExpression( + _ expression: (some FlagSource).Type, + ) -> [any FlagSource.Type] { + [expression] + } + + public static func buildBlock( + _ components: [any FlagSource.Type]..., + ) -> [any FlagSource.Type] { + components.flatMap(\.self) + } + + public static func buildOptional( + _ component: [any FlagSource.Type]?, + ) -> [any FlagSource.Type] { + component ?? [] + } + + public static func buildEither( + first component: [any FlagSource.Type], + ) -> [any FlagSource.Type] { + component + } + + public static func buildEither( + second component: [any FlagSource.Type], + ) -> [any FlagSource.Type] { + component + } + + public static func buildArray( + _ components: [[any FlagSource.Type]], + ) -> [any FlagSource.Type] { + components.flatMap(\.self) + } +} diff --git a/Shared/Flagger/Sources/Flagger.swift b/Shared/Flagger/Sources/Flagger.swift new file mode 100644 index 00000000..82981437 --- /dev/null +++ b/Shared/Flagger/Sources/Flagger.swift @@ -0,0 +1,363 @@ +import Foundation +import os +import SwiftData + +/// A scoped feature-flag container with synchronous cached reads and actor-isolated persistence. +public final class Flagger: Sendable { + public enum Storage: Sendable { + case inMemory + case onDisk(name: String) + case atURL(URL) + case container(ModelContainer) + } + + private struct Resolution { + let value: JSONValue + let failure: FlaggerFailure? + } + + private struct State { + let definitions: [FlagID: FlagDefinition] + let orderedDefinitions: [FlagDefinition] + var storedValues: [FlagID: JSONValue] + var resolutions: [FlagID: Resolution] + var appliedStoreRevision: UInt64 + var changeObservers: [UUID: AsyncStream.Continuation] = [:] + var failureObservers: [UUID: AsyncStream.Continuation] = [:] + } + + private let state: OSAllocatedUnfairLock + private let persistence: FlaggerPersistence + + private init( + definitions: [FlagDefinition], + storedSnapshot: FlaggerPersistence.StoreSnapshot, + persistence: FlaggerPersistence, + ) { + let indexed = Dictionary(uniqueKeysWithValues: definitions.map { ($0.id, $0) }) + var resolutions: [FlagID: Resolution] = [:] + for definition in definitions where definition.behavior == .readOnceOnLaunch { + resolutions[definition.id] = Self.resolve( + definition, + storedSnapshot.values[definition.id], + ) + } + state = OSAllocatedUnfairLock(initialState: State( + definitions: indexed, + orderedDefinitions: definitions, + storedValues: storedSnapshot.values, + resolutions: resolutions, + appliedStoreRevision: storedSnapshot.revision, + )) + self.persistence = persistence + } + + public static func open( + sources: FlagSourceRegistry, + storage: Storage, + ) async throws -> Flagger { + let definitions = try definitions(from: sources) + let container = try await makeContainer(storage: storage) + let persistence = FlaggerPersistence(modelContainer: container) + var storedSnapshot = try await persistence.load() + let redundantOverrideIDs = Set(definitions.compactMap { definition in + storedSnapshot.values[definition.id] == definition.defaultValue ? definition.id : nil + }) + if redundantOverrideIDs.isEmpty == false { + storedSnapshot = try await persistence.remove(redundantOverrideIDs) + } + return Flagger( + definitions: definitions, + storedSnapshot: storedSnapshot, + persistence: persistence, + ) + } + + public func value( + for flag: Flag, + ) throws -> Value { + let resolution = try resolution(for: flag.id) + if let failure = resolution.failure { throw failure } + return try FlagDefinition.value(Value.self, from: resolution.value) + } + + public func valueOrDefault( + for flag: Flag, + ) -> Value { + do { + return try value(for: flag) + } catch { + let failure = error as? FlaggerFailure + ?? FlaggerFailure(flagID: flag.id, operation: .read, error: error) + emit(failure) + return flag.defaultValue + } + } + + public func set( + _ value: Value, + for flag: Flag, + ) async throws { + let json = try FlagDefinition.json(value) + try await persist(json, for: flag.id, operation: .write) + } + + public func reset( + _ flag: Flag, + ) async throws { + try await persist(nil, for: flag.id, operation: .reset) + } + + public func values( + for flag: Flag, + ) -> AsyncStream { + let changes = changes() + let (stream, continuation) = AsyncStream.makeStream( + of: Value.self, + bufferingPolicy: .bufferingNewest(1), + ) + continuation.yield(valueOrDefault(for: flag)) + let task = Task { [weak self] in + for await changedID in changes where changedID == flag.id { + guard let self else { return } + continuation.yield(self.valueOrDefault(for: flag)) + } + continuation.finish() + } + continuation.onTermination = { _ in task.cancel() } + return stream + } + + public func changes() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: FlagID.self, + bufferingPolicy: .bufferingNewest(32), + ) + state.withLock { $0.changeObservers[id] = continuation } + continuation.onTermination = { [weak self] _ in + self?.state.withLock { $0.changeObservers[id] = nil } + } + return stream + } + + public func failures() -> AsyncStream { + let id = UUID() + let (stream, continuation) = AsyncStream.makeStream( + of: FlaggerFailure.self, + bufferingPolicy: .bufferingNewest(16), + ) + state.withLock { $0.failureObservers[id] = continuation } + continuation.onTermination = { [weak self] _ in + self?.state.withLock { $0.failureObservers[id] = nil } + } + return stream + } + + public func snapshots() -> [FlagSnapshot] { + state.withLock { state in + state.orderedDefinitions.map { definition in + let stored = state.storedValues[definition.id] + let frozenResolution = state.resolutions[definition.id] + let displayedResolution = frozenResolution ?? Self.resolve(definition, stored) + return FlagSnapshot( + id: definition.id, + name: definition.name, + detail: definition.detail, + source: definition.source, + group: definition.group, + behavior: definition.behavior, + defaultValue: definition.defaultValue, + storedValue: stored, + effectiveValue: displayedResolution.value, + isFrozen: frozenResolution != nil, + failure: displayedResolution.failure, + ) + } + } + } + + @_spi(FlaggerUI) + public func setOverride(_ value: JSONValue, for id: FlagID) async throws { + try await persist(value, for: id, operation: .write) + } + + @_spi(FlaggerUI) + public func resetOverride(for id: FlagID) async throws { + _ = try registeredDefinition(for: id) + try await persist(nil, for: id, operation: .reset) + } + + private func resolution(for id: FlagID) throws -> Resolution { + let (result, emittedChange) = try state.withLock { state -> (Resolution, Bool) in + guard let definition = state.definitions[id] else { + throw FlaggerError.unregisteredFlag(id) + } + if let existing = state.resolutions[id] { return (existing, false) } + let resolved = Self.resolve(definition, state.storedValues[id]) + if definition.behavior == .readOnceOnFirstAccess { + state.resolutions[id] = resolved + return (resolved, true) + } + return (resolved, false) + } + if emittedChange { notifyChange(id) } + return result + } + + private func persist( + _ requestedValue: JSONValue?, + for id: FlagID, + operation: FlaggerFailure.Operation, + ) async throws { + do { + let definition = try registeredDefinition(for: id) + if let requestedValue { + _ = try definition.decode(requestedValue) + } + let value = requestedValue == definition.defaultValue ? nil : requestedValue + let storeSnapshot = try await persistence.persist(value, for: id) + let changedIDs = state.withLock { state -> [FlagID] in + guard storeSnapshot.revision > state.appliedStoreRevision else { return [] } + let oldValues = state.storedValues + state.appliedStoreRevision = storeSnapshot.revision + state.storedValues = storeSnapshot.values + + var changedIDs: [FlagID] = [] + for definition in state.orderedDefinitions + where oldValues[definition.id] != storeSnapshot.values[definition.id] + { + changedIDs.append(definition.id) + if definition.behavior == .liveUpdating { + state.resolutions[definition.id] = nil + } + } + return changedIDs + } + for changedID in changedIDs { + notifyChange(changedID) + } + } catch { + let failure = FlaggerFailure(flagID: id, operation: operation, error: error) + emit(failure) + throw failure + } + } + + private func registeredDefinition(for id: FlagID) throws -> FlagDefinition { + try state.withLock { state in + guard let definition = state.definitions[id] else { + throw FlaggerError.unregisteredFlag(id) + } + return definition + } + } + + private static func resolve( + _ definition: FlagDefinition, + _ storedValue: JSONValue?, + ) -> Resolution { + let candidate = storedValue ?? definition.defaultValue + do { + _ = try definition.decode(candidate) + return Resolution(value: candidate, failure: nil) + } catch { + return Resolution( + value: definition.defaultValue, + failure: FlaggerFailure(flagID: definition.id, operation: .read, error: error), + ) + } + } + + private func notifyChange(_ id: FlagID) { + let observers = state.withLock { Array($0.changeObservers.values) } + for observer in observers { + observer.yield(id) + } + } + + private func emit(_ failure: FlaggerFailure) { + let observers = state.withLock { Array($0.failureObservers.values) } + for observer in observers { + observer.yield(failure) + } + } + + private static func definitions( + from sources: FlagSourceRegistry, + ) throws -> [FlagDefinition] { + var sourceIDs: Set = [] + var groupTypes: Set = [] + var groupIDs: Set = [] + var flagIDs: Set = [] + var definitions: [FlagDefinition] = [] + + for sourceType in sources.types { + let source = FeatureFlagSourceMetadata(id: sourceType.id, name: sourceType.name) + precondition( + sourceIDs.insert(source.id).inserted, + "Flag source IDs must be unique.", + ) + for groupType in sourceType.groups.types { + precondition( + groupTypes.insert(ObjectIdentifier(groupType)).inserted, + "A flag group type may be registered only once.", + ) + let group = FeatureFlagGroupMetadata( + id: groupType.id, + name: groupType.name, + detail: groupType.detail, + ) + let qualifiedGroupID = "\(source.id.rawValue).\(group.id.rawValue)" + precondition( + groupIDs.insert(qualifiedGroupID).inserted, + "Flag group IDs must be unique within a source.", + ) + for child in Mirror(reflecting: groupType.init()).children { + guard let flag = child.value as? any AnyFeatureFlag else { continue } + let definition = try flag.definition( + source: source, + group: group, + ) + precondition( + flagIDs.insert(definition.id).inserted, + "Flag IDs must be unique within a Flagger.", + ) + definitions.append(definition) + } + } + } + return definitions.sorted { + ($0.source.name, $0.group.name, $0.name) + < ($1.source.name, $1.group.name, $1.name) + } + } + + @concurrent + private static func makeContainer(storage: Storage) async throws -> ModelContainer { + switch storage { + case .inMemory: + let schema = Schema([FlagOverride.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + return try ModelContainer(for: schema, configurations: [configuration]) + case let .onDisk(name): + precondition( + name.isEmpty == false, + "An on-disk Flagger store name must not be empty.", + ) + let schema = Schema([FlagOverride.self]) + let configuration = ModelConfiguration(name, schema: schema) + return try ModelContainer(for: schema, configurations: [configuration]) + case let .atURL(url): + let schema = Schema([FlagOverride.self]) + let configuration = ModelConfiguration(schema: schema, url: url) + return try ModelContainer(for: schema, configurations: [configuration]) + case let .container(container): + return container + } + } +} + +public enum FlaggerError: Error, Equatable { + case unregisteredFlag(FlagID) +} diff --git a/Shared/Flagger/Sources/FlaggerFailure.swift b/Shared/Flagger/Sources/FlaggerFailure.swift new file mode 100644 index 00000000..dff5054c --- /dev/null +++ b/Shared/Flagger/Sources/FlaggerFailure.swift @@ -0,0 +1,18 @@ +/// An observable failure from resolving or persisting a feature flag. +public struct FlaggerFailure: Error, Equatable, Sendable { + public enum Operation: String, Sendable { + case read + case write + case reset + } + + public let flagID: FlagID? + public let operation: Operation + public let message: String + + init(flagID: FlagID?, operation: Operation, error: any Error) { + self.flagID = flagID + self.operation = operation + message = String(describing: error) + } +} diff --git a/Shared/Flagger/Sources/FlaggerPersistence.swift b/Shared/Flagger/Sources/FlaggerPersistence.swift new file mode 100644 index 00000000..06963f0e --- /dev/null +++ b/Shared/Flagger/Sources/FlaggerPersistence.swift @@ -0,0 +1,67 @@ +import Foundation +import SwiftData + +@ModelActor +actor FlaggerPersistence { + struct StoreSnapshot { + let revision: UInt64 + let values: [FlagID: JSONValue] + } + + private var revision: UInt64 = 0 + + func load() throws -> StoreSnapshot { + try snapshot() + } + + func remove(_ ids: Set) throws -> StoreSnapshot { + guard ids.isEmpty == false else { return try snapshot() } + let rows = try modelContext.fetch(FetchDescriptor()) + for row in rows where ids.contains(FlagID(rawValue: row.key)) { + modelContext.delete(row) + } + return try saveAndSnapshot() + } + + func persist(_ value: JSONValue?, for id: FlagID) throws -> StoreSnapshot { + let key = id.rawValue + var descriptor = FetchDescriptor(predicate: #Predicate { $0.key == key }) + descriptor.fetchLimit = 1 + let existing = try modelContext.fetch(descriptor).first + + if let value { + let data = try JSONEncoder.flagger.encode(value) + if let existing { + existing.value = data + } else { + modelContext.insert(FlagOverride(key: key, value: data)) + } + } else if let existing { + modelContext.delete(existing) + } + + return try saveAndSnapshot() + } + + private func snapshot() throws -> StoreSnapshot { + let rows = try modelContext.fetch(FetchDescriptor()) + let values = try Dictionary(uniqueKeysWithValues: rows.map { row in + try ( + FlagID(rawValue: row.key), + JSONDecoder().decode(JSONValue.self, from: row.value) + ) + }) + return StoreSnapshot(revision: revision, values: values) + } + + private func saveAndSnapshot() throws -> StoreSnapshot { + do { + try modelContext.save() + } catch { + modelContext.rollback() + throw error + } + revision += 1 + return try snapshot() + } +} diff --git a/Shared/Flagger/Sources/JSONValue.swift b/Shared/Flagger/Sources/JSONValue.swift new file mode 100644 index 00000000..200b962a --- /dev/null +++ b/Shared/Flagger/Sources/JSONValue.swift @@ -0,0 +1,59 @@ +import Foundation + +/// A Sendable, editable representation of any JSON value. +public enum JSONValue: Codable, Equatable, Sendable { + case null + case boolean(Bool) + case number(Decimal) + case string(String) + case array([JSONValue]) + case object([String: JSONValue]) + + public init(from decoder: any Decoder) throws { + let container = try decoder.singleValueContainer() + if container.decodeNil() { + self = .null + } else if let value = try? container.decode(Bool.self) { + self = .boolean(value) + } else if let value = try? container.decode(Decimal.self) { + self = .number(value) + } else if let value = try? container.decode(String.self) { + self = .string(value) + } else if let value = try? container.decode([JSONValue].self) { + self = .array(value) + } else { + self = try .object(container.decode([String: JSONValue].self)) + } + } + + public func encode(to encoder: any Encoder) throws { + var container = encoder.singleValueContainer() + switch self { + case .null: try container.encodeNil() + case let .boolean(value): try container.encode(value) + case let .number(value): try container.encode(value) + case let .string(value): try container.encode(value) + case let .array(value): try container.encode(value) + case let .object(value): try container.encode(value) + } + } + + public var formatted: String { + get throws { + let data = try JSONEncoder.flagger.encode(self) + return String(decoding: data, as: UTF8.self) + } + } + + public init(formatted string: String) throws { + self = try JSONDecoder().decode(Self.self, from: Data(string.utf8)) + } +} + +extension JSONEncoder { + static var flagger: JSONEncoder { + let encoder = JSONEncoder() + encoder.outputFormatting = [.prettyPrinted, .sortedKeys, .withoutEscapingSlashes] + return encoder + } +} diff --git a/Shared/Flagger/Tests/FeatureFlagBehaviorTests.swift b/Shared/Flagger/Tests/FeatureFlagBehaviorTests.swift new file mode 100644 index 00000000..3a3f82c3 --- /dev/null +++ b/Shared/Flagger/Tests/FeatureFlagBehaviorTests.swift @@ -0,0 +1,11 @@ +@testable import Flagger +import Testing + +struct FeatureFlagBehaviorTests { + @Test + func markerTypesExposeTheirResolutionKinds() { + #expect(ReadOnceOnLaunch.kind == .readOnceOnLaunch) + #expect(ReadOnceOnFirstAccess.kind == .readOnceOnFirstAccess) + #expect(LiveUpdating.kind == .liveUpdating) + } +} diff --git a/Shared/Flagger/Tests/FeatureFlagGroupRegistryTests.swift b/Shared/Flagger/Tests/FeatureFlagGroupRegistryTests.swift new file mode 100644 index 00000000..fecb2792 --- /dev/null +++ b/Shared/Flagger/Tests/FeatureFlagGroupRegistryTests.swift @@ -0,0 +1,13 @@ +@testable import Flagger +import Testing + +struct FeatureFlagGroupRegistryTests { + @Test + func builderRegistersConcreteGroupMetadataAndFactory() { + let type = TestFlagSource.groups.types.first + + #expect(type?.id == TestFlags.id) + #expect(type?.name == TestFlags.name) + #expect(type?.init() is TestFlags) + } +} diff --git a/Shared/Flagger/Tests/FeatureFlagGroupTests.swift b/Shared/Flagger/Tests/FeatureFlagGroupTests.swift new file mode 100644 index 00000000..83ee33a9 --- /dev/null +++ b/Shared/Flagger/Tests/FeatureFlagGroupTests.swift @@ -0,0 +1,12 @@ +@testable import Flagger +import Testing + +struct FeatureFlagGroupTests { + @Test + func namespaceConstructsTheRequestedConcreteGroup() { + let group = FeatureFlagGroups()[TestFlags.self] + + #expect(group.liveBoolean.id == TestFlags().liveBoolean.id) + #expect(TestFlags.detail == nil) + } +} diff --git a/Shared/Flagger/Tests/FlagDefinitionTests.swift b/Shared/Flagger/Tests/FlagDefinitionTests.swift new file mode 100644 index 00000000..c6520361 --- /dev/null +++ b/Shared/Flagger/Tests/FlagDefinitionTests.swift @@ -0,0 +1,15 @@ +@testable import Flagger +import Testing + +struct FlagDefinitionTests { + @Test + func erasesAndRestoresTheConcreteValueType() throws { + let definition = try TestFlags().liveBoolean.definition( + source: FeatureFlagSourceMetadata(id: TestFlagSource.id, name: TestFlagSource.name), + group: FeatureFlagGroupMetadata(id: TestFlags.id, name: TestFlags.name, detail: nil), + ) + + #expect(definition.defaultValue == .boolean(false)) + #expect(try definition.decode(.boolean(true)) as? Bool == true) + } +} diff --git a/Shared/Flagger/Tests/FlagIDTests.swift b/Shared/Flagger/Tests/FlagIDTests.swift new file mode 100644 index 00000000..f57e5b06 --- /dev/null +++ b/Shared/Flagger/Tests/FlagIDTests.swift @@ -0,0 +1,11 @@ +@testable import Flagger +import Testing + +struct FlagIDTests { + @Test + func descriptionUsesTheStableRawValue() { + let id = FlagID(rawValue: "module.flag") + + #expect(id.description == "module.flag") + } +} diff --git a/Shared/Flagger/Tests/FlagOverrideTests.swift b/Shared/Flagger/Tests/FlagOverrideTests.swift new file mode 100644 index 00000000..cf44581a --- /dev/null +++ b/Shared/Flagger/Tests/FlagOverrideTests.swift @@ -0,0 +1,14 @@ +@testable import Flagger +import Foundation +import Testing + +struct FlagOverrideTests { + @Test + func storesItsPersistentKeyAndJSONData() { + let data = Data("true".utf8) + let override = FlagOverride(key: "module.flag", value: data) + + #expect(override.key == "module.flag") + #expect(override.value == data) + } +} diff --git a/Shared/Flagger/Tests/FlagSnapshotTests.swift b/Shared/Flagger/Tests/FlagSnapshotTests.swift new file mode 100644 index 00000000..d4023d48 --- /dev/null +++ b/Shared/Flagger/Tests/FlagSnapshotTests.swift @@ -0,0 +1,32 @@ +@testable import Flagger +import Testing + +struct FlagSnapshotTests { + @Test + func frozenOverrideReportsAPendingChange() { + let snapshot = makeSnapshot(storedValue: .boolean(true), isFrozen: true) + + #expect(snapshot.isDefault == false) + #expect(snapshot.hasPendingChange) + } + + private func makeSnapshot(storedValue: JSONValue?, isFrozen: Bool) -> FlagSnapshot { + FlagSnapshot( + id: FlagID(rawValue: "flag"), + name: "Flag", + detail: nil, + source: FeatureFlagSourceMetadata(id: FlagSourceID("source"), name: "Source"), + group: FeatureFlagGroupMetadata( + id: FeatureFlagGroupID("group"), + name: "Group", + detail: nil, + ), + behavior: .readOnceOnLaunch, + defaultValue: .boolean(false), + storedValue: storedValue, + effectiveValue: .boolean(false), + isFrozen: isFrozen, + failure: nil, + ) + } +} diff --git a/Shared/Flagger/Tests/FlagSourceRegistryTests.swift b/Shared/Flagger/Tests/FlagSourceRegistryTests.swift new file mode 100644 index 00000000..b55abb5b --- /dev/null +++ b/Shared/Flagger/Tests/FlagSourceRegistryTests.swift @@ -0,0 +1,12 @@ +@testable import Flagger +import Testing + +struct FlagSourceRegistryTests { + @Test + func builderRegistersSourceMetadataAndGroups() { + let type = testSources.types.first + + #expect(type?.id == TestFlagSource.id) + #expect(type?.groups.types.count == 1) + } +} diff --git a/Shared/Flagger/Tests/FlagSourceTests.swift b/Shared/Flagger/Tests/FlagSourceTests.swift new file mode 100644 index 00000000..3933a4cf --- /dev/null +++ b/Shared/Flagger/Tests/FlagSourceTests.swift @@ -0,0 +1,12 @@ +@testable import Flagger +import Testing + +struct FlagSourceTests { + @Test + func metadataKeepsTypedSourceIdentity() { + let metadata = FeatureFlagSourceMetadata(id: TestFlagSource.id, name: TestFlagSource.name) + + #expect(metadata.id == TestFlagSource.id) + #expect(metadata.name == TestFlagSource.name) + } +} diff --git a/Shared/Flagger/Tests/FlagTests.swift b/Shared/Flagger/Tests/FlagTests.swift new file mode 100644 index 00000000..258e74ec --- /dev/null +++ b/Shared/Flagger/Tests/FlagTests.swift @@ -0,0 +1,19 @@ +@testable import Flagger +import Testing + +struct FlagTests { + @Test + func definitionKeepsStableIdentityAndDefault() { + let flag = Flag( + "test.flag", + name: "Test Flag", + detail: "Detail", + default: true, + ) + + #expect(flag.id == FlagID(rawValue: "test.flag")) + #expect(flag.name == "Test Flag") + #expect(flag.detail == "Detail") + #expect(flag.defaultValue) + } +} diff --git a/Shared/Flagger/Tests/FlaggerFailureTests.swift b/Shared/Flagger/Tests/FlaggerFailureTests.swift new file mode 100644 index 00000000..16c2462e --- /dev/null +++ b/Shared/Flagger/Tests/FlaggerFailureTests.swift @@ -0,0 +1,18 @@ +@testable import Flagger +import Testing + +struct FlaggerFailureTests { + private enum FixtureError: Error { + case failed + } + + @Test + func capturesTheAffectedFlagAndOperation() { + let id = FlagID(rawValue: "flag") + let failure = FlaggerFailure(flagID: id, operation: .write, error: FixtureError.failed) + + #expect(failure.flagID == id) + #expect(failure.operation == .write) + #expect(failure.message.contains("failed")) + } +} diff --git a/Shared/Flagger/Tests/FlaggerPersistenceTests.swift b/Shared/Flagger/Tests/FlaggerPersistenceTests.swift new file mode 100644 index 00000000..0c7ce8cf --- /dev/null +++ b/Shared/Flagger/Tests/FlaggerPersistenceTests.swift @@ -0,0 +1,30 @@ +@testable import Flagger +import SwiftData +import Testing + +struct FlaggerPersistenceTests { + @Test + func mutationsReturnCompleteVersionedStoreSnapshots() async throws { + let schema = Schema([FlagOverride.self]) + let configuration = ModelConfiguration(schema: schema, isStoredInMemoryOnly: true) + let container = try ModelContainer(for: schema, configurations: [configuration]) + let persistence = FlaggerPersistence(modelContainer: container) + let firstID = FlagID(rawValue: "first") + let secondID = FlagID(rawValue: "second") + + let first = try await persistence.persist(.boolean(true), for: firstID) + #expect(first.revision == 1) + #expect(first.values == [firstID: .boolean(true)]) + + let second = try await persistence.persist(.string("value"), for: secondID) + #expect(second.revision == 2) + #expect(second.values == [ + firstID: .boolean(true), + secondID: .string("value"), + ]) + + let removed = try await persistence.remove([firstID]) + #expect(removed.revision == 3) + #expect(removed.values == [secondID: .string("value")]) + } +} diff --git a/Shared/Flagger/Tests/FlaggerTestSupport.swift b/Shared/Flagger/Tests/FlaggerTestSupport.swift new file mode 100644 index 00000000..766652e3 --- /dev/null +++ b/Shared/Flagger/Tests/FlaggerTestSupport.swift @@ -0,0 +1,64 @@ +@_spi(FlaggerUI) @testable import Flagger +import Foundation + +struct TestFlags: FeatureFlagGroup { + static let id = FeatureFlagGroupID("test") + static let name = "Test Flags" + + let liveBoolean = Flag( + "live-boolean", + name: "Live Boolean", + default: false, + ) + let liveString = Flag( + "live-string", + name: "Live String", + default: "default", + ) + let launchString = Flag( + "launch-string", + name: "Launch String", + default: "default", + ) + let firstNumber = Flag( + "first-number", + name: "First Number", + default: 1, + ) +} + +enum TestFlagSource: FlagSource { + static let id = FlagSourceID("tests") + static let name = "Tests" + static let groups = FeatureFlagGroupRegistry { TestFlags.self } +} + +struct StringFlags: FeatureFlagGroup { + static let id = FeatureFlagGroupID("test") + static let name = "Test Flags" + + let liveBoolean = Flag( + "live-boolean", + name: "Live Boolean", + default: "false", + ) +} + +enum StringFlagSource: FlagSource { + static let id = FlagSourceID("tests") + static let name = "Tests" + static let groups = FeatureFlagGroupRegistry { StringFlags.self } +} + +let testSources = FlagSourceRegistry { TestFlagSource.self } +let stringSources = FlagSourceRegistry { StringFlagSource.self } + +func makeFlagger() async throws -> Flagger { + try await Flagger.open(sources: testSources, storage: .inMemory) +} + +func temporaryStoreURL() -> URL { + FileManager.default.temporaryDirectory + .appending(path: UUID().uuidString) + .appendingPathExtension("store") +} diff --git a/Shared/Flagger/Tests/FlaggerTests.swift b/Shared/Flagger/Tests/FlaggerTests.swift new file mode 100644 index 00000000..d137461c --- /dev/null +++ b/Shared/Flagger/Tests/FlaggerTests.swift @@ -0,0 +1,132 @@ +@_spi(FlaggerUI) @testable import Flagger +import Testing + +struct FlaggerTests { + @Test + func liveFlagPersistsOnlyAnOverride() async throws { + let flagger = try await makeFlagger() + let flags = TestFlags() + + #expect(flagger.valueOrDefault(for: flags.liveBoolean) == false) + try await flagger.set(true, for: flags.liveBoolean) + #expect(flagger.valueOrDefault(for: flags.liveBoolean) == true) + #expect(flagger.snapshots().first { $0.id == flags.liveBoolean.id }? + .storedValue == .boolean(true)) + + try await flagger.set(false, for: flags.liveBoolean) + #expect(flagger.snapshots().first { $0.id == flags.liveBoolean.id }?.storedValue == nil) + } + + @Test + func concurrentWritesToDifferentFlagsKeepBothCachedValuesCurrent() async throws { + let flagger = try await makeFlagger() + let flags = TestFlags() + + for index in 0 ..< 100 { + let boolean = index.isMultiple(of: 2) == false + let string = String(index) + async let booleanWrite: Void = flagger.set(boolean, for: flags.liveBoolean) + async let stringWrite: Void = flagger.set(string, for: flags.liveString) + try await (booleanWrite, stringWrite) + + #expect(try flagger.value(for: flags.liveBoolean) == boolean) + #expect(try flagger.value(for: flags.liveString) == string) + } + } + + @Test + func typedWriteRejectsAFlagWhoseTypeDoesNotMatchTheRegisteredID() async throws { + let flagger = try await makeFlagger() + let registeredFlag = TestFlags().liveBoolean + let mismatchedFlag = Flag( + registeredFlag.id, + name: "Mismatched", + default: "default", + ) + + await #expect(throws: FlaggerFailure.self) { + try await flagger.set("invalid", for: mismatchedFlag) + } + #expect(try flagger.value(for: registeredFlag) == false) + #expect(flagger.snapshots().first { $0.id == registeredFlag.id }?.storedValue == nil) + } + + @Test + func firstReadValueFreezes() async throws { + let flagger = try await makeFlagger() + let flag = TestFlags().firstNumber + + #expect(try flagger.value(for: flag) == 1) + try await flagger.setOverride(.number(2), for: flag.id) + + #expect(try flagger.value(for: flag) == 1) + let snapshot = flagger.snapshots().first { $0.id == flag.id } + #expect(snapshot?.storedValue == .number(2)) + #expect(snapshot?.effectiveValue == .number(1)) + #expect(snapshot?.isFrozen == true) + } + + @Test + func launchValueIsFrozenAtOpen() async throws { + let flagger = try await makeFlagger() + let flag = TestFlags().launchString + try await flagger.setOverride(.string("next launch"), for: flag.id) + + #expect(try flagger.value(for: flag) == "default") + #expect(flagger.snapshots().first { $0.id == flag.id }?.hasPendingChange == true) + } + + @Test + func liveValuesEmitCurrentAndUpdatedValues() async throws { + let flagger = try await makeFlagger() + let flag = TestFlags().liveBoolean + let stream = flagger.values(for: flag) + var iterator = stream.makeAsyncIterator() + + #expect(await iterator.next() == false) + try await flagger.set(true, for: flag) + #expect(await iterator.next() == true) + } + + @Test + func sourceAndGroupMetadataReachSnapshots() async throws { + let flagger = try await makeFlagger() + let snapshot = try #require(flagger.snapshots().first) + + #expect(snapshot.source.id == TestFlagSource.id) + #expect(snapshot.group.id == TestFlags.id) + } + + @Test + func onDiskOverrideSurvivesReopen() async throws { + let url = temporaryStoreURL() + let flag = TestFlags().liveBoolean + do { + let first = try await Flagger.open(sources: testSources, storage: .atURL(url)) + try await first.set(true, for: flag) + } + + let reopened = try await Flagger.open(sources: testSources, storage: .atURL(url)) + #expect(try reopened.value(for: flag) == true) + } + + @Test + func invalidStoredTypeThrowsOrFallsBackAndEmitsFailure() async throws { + let url = temporaryStoreURL() + do { + let writer = try await Flagger.open(sources: stringSources, storage: .atURL(url)) + try await writer.set("yes", for: StringFlags().liveBoolean) + } + + let flagger = try await Flagger.open(sources: testSources, storage: .atURL(url)) + let flag = TestFlags().liveBoolean + #expect(throws: FlaggerFailure.self) { try flagger.value(for: flag) } + + let failures = flagger.failures() + var iterator = failures.makeAsyncIterator() + #expect(flagger.valueOrDefault(for: flag) == false) + let failure = await iterator.next() + #expect(failure?.flagID == flag.id) + #expect(flagger.snapshots().first { $0.id == flag.id }?.failure != nil) + } +} diff --git a/Shared/Flagger/Tests/JSONValueTests.swift b/Shared/Flagger/Tests/JSONValueTests.swift new file mode 100644 index 00000000..f5024e93 --- /dev/null +++ b/Shared/Flagger/Tests/JSONValueTests.swift @@ -0,0 +1,23 @@ +@testable import Flagger +import Testing + +struct JSONValueTests { + @Test + func roundTripsNestedJSON() throws { + let value = JSONValue.object([ + "enabled": .boolean(true), + "rollout": .number(0.25), + "groups": .array([.string("staff"), .null]), + ]) + + #expect(try JSONValue(formatted: value.formatted) == value) + } + + @Test + func preservesLargeIntegerPrecision() throws { + let integer = Int64.max + let json = try FlagDefinition.json(integer) + + #expect(try FlagDefinition.value(Int64.self, from: json) == integer) + } +} diff --git a/Shared/FlaggerUI/AGENTS.md b/Shared/FlaggerUI/AGENTS.md new file mode 100644 index 00000000..b3767d4e --- /dev/null +++ b/Shared/FlaggerUI/AGENTS.md @@ -0,0 +1,20 @@ +# FlaggerUI – Module Shape + +FlaggerUI turns a Flagger into a type-injected observable model, environment- +style group accessors, and a hierarchical editor. See [`README.md`](README.md). + +This complements the root [`AGENTS.md`](../../AGENTS.md). + +## Scope & dependencies + +- SwiftUI and Flagger only; persistence, policies, sources, and group definitions remain in Flagger or consumers. +- Inject one `FlaggerModel` with `.environment(model)` at the scope root. + +## Invariants + +- `FlagGroupAccessor` only accepts key paths into its concrete group, and typed live mutation only accepts `LiveUpdating` flags. +- The editor reports effective, stored, default, invalid, frozen, and pending-next-lifetime state separately. + +## Testing + +Swift Testing in [`Tests/`](Tests) covers model/accessor behavior; image references live in [`SnapshotTests/`](SnapshotTests). diff --git a/Shared/FlaggerUI/README.md b/Shared/FlaggerUI/README.md new file mode 100644 index 00000000..bccf92d2 --- /dev/null +++ b/Shared/FlaggerUI/README.md @@ -0,0 +1,41 @@ +# FlaggerUI + +FlaggerUI supplies the observable environment model and searchable editor for a +Flagger scope. + +```swift +@State private var flaggerModel = FlaggerModel(flagger) + +RootView() + .environment(flaggerModel) +``` + +Modules add environment-style accessors to `FeatureFlagGroups`; FlaggerModel +and `FlagGroupAccessor` turn those key paths into typed values: + +```swift +public extension FeatureFlagGroups { + var map: MapFlags { self[MapFlags.self] } +} + +struct MapView: View { + @Environment(FlaggerModel.self) private var flagger + + var body: some View { + if flagger.map.newRenderer { + NewMapView() + } + } +} +``` + +Live writes are explicit and asynchronous: + +```swift +try await flagger.map.set(false, for: \.newRenderer) +``` + +`FlaggerEditorView` uses the injected model. It groups flags by source and +group, searches metadata, toggles Booleans, validates other Codable values as +JSON, resets defaults, marks invalid overrides, and labels frozen edits as +applying next lifetime. diff --git a/Shared/FlaggerUI/SnapshotTests/FlaggerEditorSnapshotSupport.swift b/Shared/FlaggerUI/SnapshotTests/FlaggerEditorSnapshotSupport.swift new file mode 100644 index 00000000..805d3670 --- /dev/null +++ b/Shared/FlaggerUI/SnapshotTests/FlaggerEditorSnapshotSupport.swift @@ -0,0 +1,25 @@ +import Flagger + +struct SnapshotFlags: FeatureFlagGroup { + static let id = FeatureFlagGroupID("editor") + static let name = "Editor" + + let boolean = Flag( + "boolean", + name: "Boolean experiment", + detail: "A live Boolean flag", + default: true, + ) + let configuration = Flag<[String: Int], ReadOnceOnLaunch>( + "configuration", + name: "JSON configuration", + detail: "Applies when this scope is created", + default: ["maximum": 10], + ) +} + +enum SnapshotFlagSource: FlagSource { + static let id = FlagSourceID("snapshot-module") + static let name = "Snapshot Module" + static let groups = FeatureFlagGroupRegistry { SnapshotFlags.self } +} diff --git a/Shared/FlaggerUI/SnapshotTests/FlaggerEditorSnapshotTests.swift b/Shared/FlaggerUI/SnapshotTests/FlaggerEditorSnapshotTests.swift new file mode 100644 index 00000000..8e8bb26b --- /dev/null +++ b/Shared/FlaggerUI/SnapshotTests/FlaggerEditorSnapshotTests.swift @@ -0,0 +1,37 @@ +import Flagger +@testable import FlaggerUI +import SnapshotKitTesting +import SwiftUI +import Testing + +@MainActor +struct FlaggerEditorSnapshotTests { + @Test + func editorStates() async throws { + let flagger = try await Flagger.open( + sources: FlagSourceRegistry { SnapshotFlagSource.self }, + storage: .inMemory, + ) + let model = FlaggerModel(flagger) + let configurations = SnapshotConfiguration.combinations( + devices: [.iPhone], + colorSchemes: [.light, .dark], + ) + + await assertSnapshots( + of: FlaggerEditorView().environment(model), + named: "Default", + configurations: configurations, + ) + + await model.setOverride( + .object(["maximum": .number(20)]), + for: SnapshotFlags().configuration.id, + ) + await assertSnapshots( + of: FlaggerEditorView().environment(model), + named: "PendingNextLifetime", + configurations: configurations, + ) + } +} diff --git a/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.Default_iPhone.png b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.Default_iPhone.png new file mode 100644 index 00000000..00983a99 --- /dev/null +++ b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.Default_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:6dd66c4e507f670f5592fdb175a518bf760f26bfb5ff8605139f07122534bd28 +size 183461 diff --git a/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.Default_iPhone_dark.png b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.Default_iPhone_dark.png new file mode 100644 index 00000000..c4694fa7 --- /dev/null +++ b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.Default_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ddf1d4ae17627b6c0cdac60cc15a419812387dbfa6552c00b858fcfbefa9da34 +size 151409 diff --git a/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.PendingNextLifetime_iPhone.png b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.PendingNextLifetime_iPhone.png new file mode 100644 index 00000000..6aaa2367 --- /dev/null +++ b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.PendingNextLifetime_iPhone.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:ed6e68597dbd33295dba25063f24b2648c4d24089f9243bdb611a2b596f92fed +size 185641 diff --git a/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.PendingNextLifetime_iPhone_dark.png b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.PendingNextLifetime_iPhone_dark.png new file mode 100644 index 00000000..9cd63eb0 --- /dev/null +++ b/Shared/FlaggerUI/SnapshotTests/__Snapshots__/FlaggerEditorSnapshotTests/editorStates.PendingNextLifetime_iPhone_dark.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c026164bfc045aa1abe934801b8d5224f33bcfd6f64660ce657634368e253b6f +size 153674 diff --git a/Shared/FlaggerUI/Sources/FlagGroupAccessor.swift b/Shared/FlaggerUI/Sources/FlagGroupAccessor.swift new file mode 100644 index 00000000..bdacfc68 --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlagGroupAccessor.swift @@ -0,0 +1,39 @@ +import Flagger + +/// A type-safe, dynamically accessed view of one registered feature-flag group. +@MainActor +@dynamicMemberLookup +public struct FlagGroupAccessor { + private let group: Group + private let model: FlaggerModel + + init(group: Group, model: FlaggerModel) { + self.group = group + self.model = model + } + + public subscript( + dynamicMember keyPath: KeyPath>, + ) -> Value where Value: Codable & Sendable { + model.value(for: group[keyPath: keyPath]) + } + + public func value( + for keyPath: KeyPath>, + ) throws -> Value { + try model.throwingValue(for: group[keyPath: keyPath]) + } + + public func set( + _ value: Value, + for keyPath: KeyPath>, + ) async throws { + try await model.set(value, for: group[keyPath: keyPath]) + } + + public func reset( + _ keyPath: KeyPath>, + ) async throws { + try await model.reset(group[keyPath: keyPath]) + } +} diff --git a/Shared/FlaggerUI/Sources/FlagGroupSection.swift b/Shared/FlaggerUI/Sources/FlagGroupSection.swift new file mode 100644 index 00000000..c426aabd --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlagGroupSection.swift @@ -0,0 +1,7 @@ +import Flagger + +struct FlagGroupSection: Identifiable { + let id: FeatureFlagGroupID + let name: String + let flags: [FlagSnapshot] +} diff --git a/Shared/FlaggerUI/Sources/FlagSourceSection.swift b/Shared/FlaggerUI/Sources/FlagSourceSection.swift new file mode 100644 index 00000000..381af8f2 --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlagSourceSection.swift @@ -0,0 +1,7 @@ +import Flagger + +struct FlagSourceSection: Identifiable { + let id: FlagSourceID + let name: String + let groups: [FlagGroupSection] +} diff --git a/Shared/FlaggerUI/Sources/FlaggerEditorList.swift b/Shared/FlaggerUI/Sources/FlaggerEditorList.swift new file mode 100644 index 00000000..5db23797 --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlaggerEditorList.swift @@ -0,0 +1,44 @@ +import SwiftUI + +struct FlaggerEditorList: View { + @Bindable var model: FlaggerModel + + var body: some View { + List { + ForEach(model.filteredSources) { source in + Section(source.name) { + ForEach(source.groups) { group in + Text(group.name) + .font(.headline) + .accessibilityAddTraits(.isHeader) + ForEach(group.flags) { flag in + FlaggerEditorRow(flag: flag, model: model) + } + } + } + } + } + .searchable(text: $model.searchText, prompt: "Source, group, name, or ID") + .overlay { + if model.filteredFlags.isEmpty { + ContentUnavailableView.search(text: model.searchText) + } + } + .alert( + "Couldn’t Update Flag", + isPresented: $model.isPresentingError, + presenting: model.error, + ) { _ in + Button("OK", role: .cancel, action: model.dismissError) + } message: { error in + Text(error.localizedDescription) + } + } +} + +extension FlaggerModel { + fileprivate var isPresentingError: Bool { + get { error != nil } + set { if newValue == false { dismissError() } } + } +} diff --git a/Shared/FlaggerUI/Sources/FlaggerEditorRow.swift b/Shared/FlaggerUI/Sources/FlaggerEditorRow.swift new file mode 100644 index 00000000..b16c6d13 --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlaggerEditorRow.swift @@ -0,0 +1,52 @@ +import Flagger +import SwiftUI + +struct FlaggerEditorRow: View { + let flag: FlagSnapshot + let model: FlaggerModel + + var body: some View { + NavigationLink { + FlaggerValueEditor(flag: flag, model: model) + } label: { + VStack(alignment: .leading, spacing: 4) { + HStack { + Text(flag.name) + Spacer() + if flag.failure != nil { + Label("Invalid", systemImage: "exclamationmark.triangle.fill") + .labelStyle(.iconOnly) + .foregroundStyle(.red) + } else if flag.hasPendingChange { + Text("Next lifetime") + .font(.caption) + .foregroundStyle(.secondary) + } else if flag.isDefault == false { + Text("Override") + .font(.caption) + .foregroundStyle(.secondary) + } + } + Text(flag.id.rawValue) + .font(.caption.monospaced()) + .foregroundStyle(.secondary) + HStack { + Text(flag.behavior.label) + if flag.isFrozen { Text("Frozen") } + } + .font(.caption) + .foregroundStyle(.secondary) + } + } + } +} + +extension FeatureFlagBehaviorKind { + var label: String { + switch self { + case .readOnceOnLaunch: "Launch" + case .readOnceOnFirstAccess: "First read" + case .liveUpdating: "Live" + } + } +} diff --git a/Shared/FlaggerUI/Sources/FlaggerEditorView.swift b/Shared/FlaggerUI/Sources/FlaggerEditorView.swift new file mode 100644 index 00000000..9f61d32c --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlaggerEditorView.swift @@ -0,0 +1,16 @@ +import SwiftUI + +/// Searchable editor for the FlaggerModel injected into the view environment. +public struct FlaggerEditorView: View { + @Environment(FlaggerModel.self) private var model + + public init() {} + + public var body: some View { + @Bindable var model = model + NavigationStack { + FlaggerEditorList(model: model) + .navigationTitle("Feature Flags") + } + } +} diff --git a/Shared/FlaggerUI/Sources/FlaggerModel.swift b/Shared/FlaggerUI/Sources/FlaggerModel.swift new file mode 100644 index 00000000..edaefb3e --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlaggerModel.swift @@ -0,0 +1,137 @@ +@_spi(FlaggerUI) import Flagger +import Observation + +/// Observable UI projection and environment entry point for a scoped Flagger. +@MainActor +@Observable +@dynamicMemberLookup +public final class FlaggerModel { + public private(set) var flags: [FlagSnapshot] + public private(set) var latestFailure: FlaggerFailure? + public private(set) var error: (any Error)? + public var searchText = "" + + public var filteredFlags: [FlagSnapshot] { + guard searchText.isEmpty == false else { return flags } + return flags.filter { + $0.name.localizedStandardContains(searchText) + || $0.id.rawValue.localizedStandardContains(searchText) + || $0.group.name.localizedStandardContains(searchText) + || $0.source.name.localizedStandardContains(searchText) + || ($0.detail?.localizedStandardContains(searchText) ?? false) + } + } + + var filteredSources: [FlagSourceSection] { + let sourceBuckets = Dictionary(grouping: filteredFlags, by: \.source) + return sourceBuckets.map { source, sourceFlags in + let groupBuckets = Dictionary(grouping: sourceFlags, by: \.group) + let groups = groupBuckets.map { group, groupFlags in + FlagGroupSection(id: group.id, name: group.name, flags: groupFlags) + }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + return FlagSourceSection(id: source.id, name: source.name, groups: groups) + }.sorted { $0.name.localizedStandardCompare($1.name) == .orderedAscending } + } + + private let flagger: Flagger + @ObservationIgnored private var observationTasks: [Task] = [] + + public init(_ flagger: Flagger) { + let changes = flagger.changes() + let failures = flagger.failures() + self.flagger = flagger + flags = flagger.snapshots() + observationTasks = [ + Task { [weak self, flagger] in + for await _ in changes { + guard let self else { return } + self.refresh() + } + }, + Task { [weak self, flagger] in + for await failure in failures { + guard let self else { return } + self.latestFailure = failure + } + }, + ] + } + + deinit { + for task in observationTasks { + task.cancel() + } + } + + public subscript( + dynamicMember keyPath: KeyPath, + ) -> FlagGroupAccessor { + let group = FeatureFlagGroups()[keyPath: keyPath] + return FlagGroupAccessor(group: group, model: self) + } + + public func dismissError() { + error = nil + } + + func value( + for flag: Flag, + ) -> Value { + _ = flags + return flagger.valueOrDefault(for: flag) + } + + func throwingValue( + for flag: Flag, + ) throws -> Value { + _ = flags + return try flagger.value(for: flag) + } + + func set( + _ value: Value, + for flag: Flag, + ) async throws { + do { + try await flagger.set(value, for: flag) + refresh() + error = nil + } catch { + self.error = error + throw error + } + } + + func reset(_ flag: Flag) async throws { + do { + try await flagger.reset(flag) + refresh() + error = nil + } catch { + self.error = error + throw error + } + } + + func setOverride(_ value: JSONValue, for id: FlagID) async { + await perform { try await flagger.setOverride(value, for: id) } + } + + func resetOverride(for id: FlagID) async { + await perform { try await flagger.resetOverride(for: id) } + } + + private func perform(_ operation: () async throws -> Void) async { + do { + try await operation() + refresh() + error = nil + } catch { + self.error = error + } + } + + private func refresh() { + flags = flagger.snapshots() + } +} diff --git a/Shared/FlaggerUI/Sources/FlaggerValueEditor.swift b/Shared/FlaggerUI/Sources/FlaggerValueEditor.swift new file mode 100644 index 00000000..94936f84 --- /dev/null +++ b/Shared/FlaggerUI/Sources/FlaggerValueEditor.swift @@ -0,0 +1,89 @@ +import Flagger +import SwiftUI + +struct FlaggerValueEditor: View { + let flag: FlagSnapshot + let model: FlaggerModel + @State private var draft: String + @State private var validationMessage: String? + + init(flag: FlagSnapshot, model: FlaggerModel) { + self.flag = flag + self.model = model + _draft = + State(initialValue: (try? (flag.storedValue ?? flag.effectiveValue).formatted) ?? + "null") + } + + var body: some View { + Form { + Section("Value") { + if case let .boolean(value) = editableValue { + Button(value ? "Disable" : "Enable", action: toggleBoolean) + } else { + TextField("JSON value", text: $draft, axis: .vertical) + .font(.body.monospaced()) + .lineLimit(5 ... 16) + if let validationMessage { + Text(validationMessage) + .foregroundStyle(.red) + } + Button("Apply JSON", action: applyJSON) + } + Button("Reset to Default", role: .destructive, action: reset) + .disabled(currentFlag.isDefault) + } + + if currentFlag.isFrozen { + Section { + Text("Changes apply the next time this flag’s lifetime begins.") + } + } + + if let failure = currentFlag.failure { + Section("Invalid Override") { + Text(failure.message) + .foregroundStyle(.red) + } + } + + Section("Definition") { + LabeledContent("ID", value: flag.id.rawValue) + LabeledContent("Source", value: flag.source.name) + LabeledContent("Group", value: flag.group.name) + LabeledContent("Behavior", value: flag.behavior.label) + LabeledContent("State", value: currentFlag.isFrozen ? "Frozen" : "Current") + if let detail = flag.detail { Text(detail) } + } + } + .navigationTitle(flag.name) + .navigationBarTitleDisplayMode(.inline) + } + + private var currentFlag: FlagSnapshot { + model.flags.first { $0.id == flag.id } ?? flag + } + + private var editableValue: JSONValue { + currentFlag.storedValue ?? currentFlag.effectiveValue + } + + private func toggleBoolean() { + guard case let .boolean(value) = editableValue else { return } + Task { await model.setOverride(.boolean(value == false), for: flag.id) } + } + + private func applyJSON() { + do { + let value = try JSONValue(formatted: draft) + validationMessage = nil + Task { await model.setOverride(value, for: flag.id) } + } catch { + validationMessage = error.localizedDescription + } + } + + private func reset() { + Task { await model.resetOverride(for: flag.id) } + } +} diff --git a/Shared/FlaggerUI/Tests/FlagGroupAccessorTests.swift b/Shared/FlaggerUI/Tests/FlagGroupAccessorTests.swift new file mode 100644 index 00000000..a983fe52 --- /dev/null +++ b/Shared/FlaggerUI/Tests/FlagGroupAccessorTests.swift @@ -0,0 +1,22 @@ +import Flagger +@testable import FlaggerUI +import Testing + +@MainActor +struct FlagGroupAccessorTests { + @Test + func environmentStyleGroupAccessReadsAndWritesTypedValue() async throws { + let model = try await makeFlaggerModel() + + #expect(model.ui.enabled == false) + try await model.ui.set(true, for: \.enabled) + #expect(model.ui.enabled == true) + } + + @Test + func throwingGroupReadIsAvailable() async throws { + let model = try await makeFlaggerModel() + + #expect(try model.ui.value(for: \.launchStyle) == "standard") + } +} diff --git a/Shared/FlaggerUI/Tests/FlaggerModelTestSupport.swift b/Shared/FlaggerUI/Tests/FlaggerModelTestSupport.swift new file mode 100644 index 00000000..37854d57 --- /dev/null +++ b/Shared/FlaggerUI/Tests/FlaggerModelTestSupport.swift @@ -0,0 +1,66 @@ +import Flagger +@testable import FlaggerUI + +private struct WaitTimeoutError: Error {} + +struct FlaggerModelFixture { + let flagger: Flagger + let model: FlaggerModel +} + +struct UIFlags: FeatureFlagGroup { + static let id = FeatureFlagGroupID("ui") + static let name = "UI Flags" + + let enabled = Flag( + "enabled", + name: "Enabled", + detail: "Experimental renderer", + default: false, + ) + let launchStyle = Flag( + "launch-style", + name: "Launch Style", + default: "standard", + ) +} + +extension FeatureFlagGroups { + var ui: UIFlags { + self[UIFlags.self] + } +} + +enum UIFlagSource: FlagSource { + static let id = FlagSourceID("ui-tests") + static let name = "UI Tests" + static let groups = FeatureFlagGroupRegistry { UIFlags.self } +} + +@MainActor +func makeFlaggerModelFixture() async throws -> FlaggerModelFixture { + let flagger = try await Flagger.open( + sources: FlagSourceRegistry { UIFlagSource.self }, + storage: .inMemory, + ) + return FlaggerModelFixture(flagger: flagger, model: FlaggerModel(flagger)) +} + +@MainActor +func makeFlaggerModel() async throws -> FlaggerModel { + try await makeFlaggerModelFixture().model +} + +@MainActor +func waitUntil( + timeout: Duration = .seconds(2), + _ condition: () -> Bool, +) async throws { + let deadline = ContinuousClock.now.advanced(by: timeout) + while condition() == false { + if ContinuousClock.now >= deadline { + throw WaitTimeoutError() + } + try await Task.sleep(for: .milliseconds(1)) + } +} diff --git a/Shared/FlaggerUI/Tests/FlaggerModelTests.swift b/Shared/FlaggerUI/Tests/FlaggerModelTests.swift new file mode 100644 index 00000000..111c11e9 --- /dev/null +++ b/Shared/FlaggerUI/Tests/FlaggerModelTests.swift @@ -0,0 +1,29 @@ +import Flagger +@testable import FlaggerUI +import Testing + +@MainActor +struct FlaggerModelTests { + @Test + func observesAnExternalUpdateImmediatelyAfterInitialization() async throws { + let fixture = try await makeFlaggerModelFixture() + let flag = UIFlags().enabled + + try await fixture.flagger.set(true, for: flag) + try await waitUntil { fixture.model.ui.enabled } + + #expect(fixture.model.ui.enabled) + } + + @Test + func filtersBySourceGroupNameIDAndDescription() async throws { + let model = try await makeFlaggerModel() + + model.searchText = "renderer" + #expect(model.filteredFlags.map(\.id) == [UIFlags().enabled.id]) + model.searchText = "UI Tests" + #expect(model.filteredFlags.count == 2) + model.searchText = "missing" + #expect(model.filteredFlags.isEmpty) + } +}