diff --git a/Package.resolved b/Package.resolved index f35333f..0e16185 100644 --- a/Package.resolved +++ b/Package.resolved @@ -5,17 +5,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/DataDog/dd-sdk-ios", "state" : { - "revision" : "a1bcf3d158d1da2f9206de97fd5c1f7a91fba597", - "version" : "2.13.0" + "revision" : "4b9d2c543dec767b181b18a6ba016ca1fa297027", + "version" : "3.7.0" } }, { - "identity" : "grpc-swift", + "identity" : "kscrash", "kind" : "remoteSourceControl", - "location" : "https://github.com/grpc/grpc-swift.git", + "location" : "https://github.com/kstenerud/KSCrash.git", "state" : { - "revision" : "6a90b7e77e29f9bda6c2b3a4165a40d6c02cfda1", - "version" : "1.23.0" + "revision" : "95a8895d75f3c22aa9ad9f2a15d2fbd97b0a55e2", + "version" : "2.5.1" } }, { @@ -23,44 +23,17 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/LogRocket/logrocket-ios-swift-package", "state" : { - "revision" : "96a4f83ceaa9840230330626dc283db527b5a7a4", - "version" : "1.33.6" + "revision" : "32e71d19212f53e43d381d25929672c84313f8d6", + "version" : "1.62.0" } }, { - "identity" : "opentelemetry-swift", + "identity" : "opentelemetry-swift-core", "kind" : "remoteSourceControl", - "location" : "https://github.com/open-telemetry/opentelemetry-swift.git", + "location" : "https://github.com/open-telemetry/opentelemetry-swift-core", "state" : { - "revision" : "869dc5acd61860a0ee150fa37e34e3a452ccf4b8", - "version" : "1.6.0" - } - }, - { - "identity" : "opentracing-objc", - "kind" : "remoteSourceControl", - "location" : "https://github.com/undefinedlabs/opentracing-objc", - "state" : { - "revision" : "18c1a35ca966236cee0c5a714a51a73ff33384c1", - "version" : "0.5.2" - } - }, - { - "identity" : "plcrashreporter", - "kind" : "remoteSourceControl", - "location" : "https://github.com/microsoft/plcrashreporter.git", - "state" : { - "revision" : "6752f71de206f6a53fa6a758c3660fd9a7fe7527", - "version" : "1.11.2" - } - }, - { - "identity" : "reachability.swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/ashleymills/Reachability.swift", - "state" : { - "revision" : "7cbd73f46a7dfaeca079e18df7324c6de6d1834a", - "version" : "5.2.3" + "revision" : "240c8d5e36c3c7b774ed961325369f0b1f2c965f", + "version" : "2.3.0" } }, { @@ -68,8 +41,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/getsentry/sentry-cocoa.git", "state" : { - "revision" : "08862789e1cbba7a9561bed69832a9306f339cd3", - "version" : "8.29.1" + "revision" : "7e77c22816c024d7385d7ab5fbf4b00130853a43", + "version" : "9.5.1" } }, { @@ -77,17 +50,8 @@ "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-atomics.git", "state" : { - "revision" : "cd142fd2f64be2100422d658e7411e39489da985", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-collections", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-collections.git", - "state" : { - "revision" : "ee97538f5b81ae89698fd95938896dec5217b148", - "version" : "1.1.1" + "revision" : "b601256eab081c0f92f059e12818ac1d4f178ff7", + "version" : "1.3.0" } }, { @@ -96,108 +60,18 @@ "location" : "https://github.com/apple/swift-docc-plugin", "state" : { "branch" : "main", - "revision" : "5fc3645728553e2410b578b408db77057e8b1517" + "revision" : "e977f65879f82b375a044c8837597f690c067da6" } }, { "identity" : "swift-docc-symbolkit", "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-docc-symbolkit", + "location" : "https://github.com/swiftlang/swift-docc-symbolkit", "state" : { "revision" : "b45d1f2ed151d057b54504d653e0da5552844e34", "version" : "1.0.0" } }, - { - "identity" : "swift-http-types", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-http-types", - "state" : { - "revision" : "1ddbea1ee34354a6a2532c60f98501c35ae8edfa", - "version" : "1.2.0" - } - }, - { - "identity" : "swift-log", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-log.git", - "state" : { - "revision" : "b3a637307772d20291d78a5b7a0d4204d9b1e981", - "version" : "1.6.0" - } - }, - { - "identity" : "swift-metrics", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-metrics.git", - "state" : { - "revision" : "e0165b53d49b413dd987526b641e05e246782685", - "version" : "2.5.0" - } - }, - { - "identity" : "swift-nio", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio.git", - "state" : { - "revision" : "e5a216ba89deba84356bad9d4c2eab99071c745b", - "version" : "2.67.0" - } - }, - { - "identity" : "swift-nio-extras", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-extras.git", - "state" : { - "revision" : "a3b640d7dc567225db7c94386a6e71aded1bfa63", - "version" : "1.22.0" - } - }, - { - "identity" : "swift-nio-http2", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-http2.git", - "state" : { - "revision" : "8d8eb609929aee75336a0a3d2417280786265868", - "version" : "1.32.0" - } - }, - { - "identity" : "swift-nio-ssl", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-ssl.git", - "state" : { - "revision" : "2b09805797f21c380f7dc9bedaab3157c5508efb", - "version" : "2.27.0" - } - }, - { - "identity" : "swift-nio-transport-services", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-nio-transport-services.git", - "state" : { - "revision" : "38ac8221dd20674682148d6451367f89c2652980", - "version" : "1.21.0" - } - }, - { - "identity" : "swift-protobuf", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-protobuf.git", - "state" : { - "revision" : "9f0c76544701845ad98716f3f6a774a892152bcb", - "version" : "1.26.0" - } - }, - { - "identity" : "swift-system", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-system.git", - "state" : { - "revision" : "f9266c85189c2751589a50ea5aec72799797e471", - "version" : "1.3.0" - } - }, { "identity" : "swiftybeaver", "kind" : "remoteSourceControl", @@ -206,15 +80,6 @@ "revision" : "12b5acf96d98f91d50de447369bd18df74600f1a", "version" : "1.9.6" } - }, - { - "identity" : "thrift-swift", - "kind" : "remoteSourceControl", - "location" : "https://github.com/undefinedlabs/Thrift-Swift", - "state" : { - "revision" : "18ff09e6b30e589ed38f90a1af23e193b8ecef8e", - "version" : "1.1.2" - } } ], "version" : 2 diff --git a/Package.swift b/Package.swift index 36641bc..55a80b8 100644 --- a/Package.swift +++ b/Package.swift @@ -1,24 +1,17 @@ -// swift-tools-version: 5.7 +// swift-tools-version: 6.0 // The swift-tools-version declares the minimum version of Swift required to build this package. import PackageDescription -let swiftSettings: [SwiftSetting] = [ -// Only for development checks -// SwiftSetting.unsafeFlags([ -// "-Xfrontend", "-strict-concurrency=complete", -// "-Xfrontend", "-warn-concurrency", -// "-Xfrontend", "-enable-actor-data-race-checks", -// ]) -] +let swiftSettings: [SwiftSetting]? = nil let package = Package( name: "ForestryLogger", platforms: [ - .iOS(.v11), + .iOS(.v15), .macOS(.v11), - .tvOS(.v11), - .watchOS(.v7) + .tvOS(.v15), + .watchOS(.v8) ], products: [ .library(name: "ForestryLoggerLibrary", targets: ["ForestryLoggerLibrary"]), @@ -30,10 +23,10 @@ let package = Package( .library(name: "ForestryOSLogSupport", targets: ["ForestryOSLogSupport"]) ], dependencies: [ - .package(url: "https://github.com/DataDog/dd-sdk-ios", .upToNextMajor(from: .init(2, 7, 1))), + .package(url: "https://github.com/DataDog/dd-sdk-ios", Version(2,7,1).. sending Any +} + /// A logger that stores its services in a an actor from which all logging is executed asynchronously. /// If no service is provided (e.g. for production), the logging library should be free to use without any performance detriments (with only one if check) @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) @@ -11,50 +15,158 @@ public struct ForestryLogger: Sendable { private let loggerActor: LoggerActor? /// Can be called with empty array. In such situation, the logger will not perform any activity - public init(services: [LoggerService]) { + public init(services: sending [LoggerService]) { self.loggerActor = .init(services: services) } @inlinable - public init(service: LoggerService) { + public init(service: sending LoggerService) { self.init(services: [service]) } +#if hasFeature(SendingArgsAndResults) /// Logs the message in services based on minimumLogLevel. /// The log may be executed from a different Thread than the one that called the function as the logging happens asynchronously. - public func log(_ message: @escaping @Sendable () -> Any, level: LogLevel, file: String, function: String, line: Int) { - guard let loggerActor else { return } + public func log(_ message: @escaping () -> sending Any, level: LogLevel, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + guard let loggerActor, loggerActor.hasAnyService(for: level) else { return } + + let box = SendableBox(message: message) + Task.detached(priority: .utility) { + await loggerActor.log(box.message(), level: level, file: file.description, function: function.description, line: line) + } + } + + @_disfavoredOverload + public func log(_ message: @escaping () -> sending Any, level: LogLevel, file: String = #file, function: String = #function, line: Int = #line) { + guard let loggerActor, loggerActor.hasAnyService(for: level) else { return } + + let box = SendableBox(message: message) + Task.detached(priority: .utility) { + await loggerActor.log(box.message(), level: level, file: file, function: function, line: line) + } + } + + // MARK: - Logging levels + + @inlinable + public func verbose(_ message: @escaping @autoclosure () -> sending Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .verbose, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable + public func verbose(_ message: @escaping @autoclosure () -> sending Any, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .verbose, file: file, function: function, line: line) + } + + @inlinable + public func debug(_ message: @escaping @autoclosure () -> sending Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .debug, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable + public func debug(_ message: @escaping @autoclosure () -> sending Any, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .debug, file: file, function: function, line: line) + } + + @inlinable + public func info(_ message: @escaping @autoclosure () -> sending Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .info, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable + public func info(_ message: @escaping @autoclosure () -> sending Any, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .info, file: file, function: function, line: line) + } + + @inlinable + public func warning(_ message: @escaping @autoclosure () -> sending Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .warning, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable + public func warning(_ message: @escaping @autoclosure () -> sending Any, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .warning, file: file, function: function, line: line) + } + + @inlinable + public func error(_ message: @escaping @autoclosure () -> sending Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .error, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable + public func error(_ message: @escaping @autoclosure () -> sending Any, file: String = #file, function: String = #function, line: Int = #line) { + log(message, level: .error, file: file, function: function, line: line) + } +#else + /// Logs the message in services based on minimumLogLevel. + /// The log may be executed from a different Thread than the one that called the function as the logging happens asynchronously. + public func log(_ message: @escaping @Sendable () -> Any, level: LogLevel, file: StaticString, function: StaticString, line: Int) { + guard let loggerActor, loggerActor.hasAnyService(for: level) else { return } Task.detached(priority: .utility) { [message] in - await loggerActor.log(message, level: level, file: file, function: function, line: line) + await loggerActor.log(message(), level: level, file: file, function: function, line: line) } } - + + @_disfavoredOverload + public func log(_ message: @escaping @Sendable () -> Any, level: LogLevel, file: String = #file, function: String = #function, line: Int = #line) { + guard let loggerActor, loggerActor.hasAnyService(for: level) else { return } + Task.detached(priority: .utility) { [message] in + await loggerActor.log(message(), level: level, file: file, function: function, line: line) + } + } + // MARK: - Logging levels - + @inlinable + public func verbose(_ message: @escaping @Sendable @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .verbose, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable public func verbose(_ message: @escaping @Sendable @autoclosure () -> Any, file: String = #file, function: String = #function, line: Int = #line) { log(message, level: .verbose, file: file, function: function, line: line) } - + @inlinable + public func debug(_ message: @escaping @Sendable @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .debug, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable public func debug(_ message: @escaping @Sendable @autoclosure () -> Any, file: String = #file, function: String = #function, line: Int = #line) { log(message, level: .debug, file: file, function: function, line: line) } - + @inlinable + public func info(_ message: @escaping @Sendable @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .info, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable public func info(_ message: @escaping @Sendable @autoclosure () -> Any, file: String = #file, function: String = #function, line: Int = #line) { log(message, level: .info, file: file, function: function, line: line) } - + @inlinable + public func warning(_ message: @escaping @Sendable @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .warning, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable public func warning(_ message: @escaping @Sendable @autoclosure () -> Any, file: String = #file, function: String = #function, line: Int = #line) { log(message, level: .warning, file: file, function: function, line: line) } - + @inlinable + public func error(_ message: @escaping @Sendable @autoclosure () -> Any, file: StaticString = #file, function: StaticString = #function, line: Int = #line) { + log(message, level: .error, file: file, function: function, line: line) + } + + @_disfavoredOverload @inlinable public func error(_ message: @escaping @Sendable @autoclosure () -> Any, file: String = #file, function: String = #function, line: Int = #line) { log(message, level: .error, file: file, function: function, line: line) } +#endif // MARK: - UserInfo @@ -94,38 +206,44 @@ public struct ForestryLogger: Sendable { } @available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -private extension ForestryLogger { +extension ForestryLogger { final actor LoggerActor { - private let services: [LoggerService] + private let registry: LogServiceRegistry + nonisolated private let endIndexByLevel: [LogLevel: Int] var userInfo: [LogUserInfoKey: String] = [:] init?(services: [LoggerService]) { guard !services.isEmpty else { return nil } - self.services = services + self.registry = LogServiceRegistry(services: services) + self.endIndexByLevel = registry.endIndexByLevel } - - func log(_ message: () -> Any, level: LogLevel, file: String, function: String, line: Int) { - let availableServices = services.filter { $0.minimalLogLevel <= level } + + func log(_ message: sending Any, level: LogLevel, file: String, function: String, line: Int) { + let availableServices = registry.services(for: level) guard !availableServices.isEmpty else { return } - let info = LogInfo(level: level, line: line, function: function, file: file, message: message(), icon: level.icon) + let info = LogInfo(level: level, line: line, function: function, file: file, message: message, icon: level.icon) availableServices.forEach { service in service.log(info: info) } } - + func updateUserInfo(for dictionary: [LogUserInfoKey: String]) { for (key, value) in dictionary { userInfo[key] = value } - services.forEach { $0.configureUserInfo(userInfo) } + registry.allServices.forEach { $0.configureUserInfo(userInfo) } } func removeUserInfo(for keys: [LogUserInfoKey]) { keys.forEach { userInfo[$0] = nil } - services.forEach { + registry.allServices.forEach { $0.removeUserInfo(keys) $0.configureUserInfo(userInfo) } } + + nonisolated func hasAnyService(for level: LogLevel) -> Bool { + endIndexByLevel[level, default: 0] > 0 + } } } diff --git a/Sources/ForestryLoggerLibrary/LogLevel.swift b/Sources/ForestryLoggerLibrary/LogLevel.swift index 74b1dca..38c71d7 100644 --- a/Sources/ForestryLoggerLibrary/LogLevel.swift +++ b/Sources/ForestryLoggerLibrary/LogLevel.swift @@ -5,7 +5,7 @@ /// A level of log that determines to which services should the message be logged to /// LogLevel also includes the icon that is used within a formatted message -public enum LogLevel: Int, Comparable, Sendable { +public enum LogLevel: Int, Comparable, Sendable, CaseIterable { case verbose case debug case info diff --git a/Sources/ForestryLoggerLibrary/LogServiceRegistry.swift b/Sources/ForestryLoggerLibrary/LogServiceRegistry.swift new file mode 100644 index 0000000..e15ffbf --- /dev/null +++ b/Sources/ForestryLoggerLibrary/LogServiceRegistry.swift @@ -0,0 +1,41 @@ +// +// Copyright 2023 © Cleevio s.r.o. All rights reserved. +// + +import Foundation + +/// A precomputed routing table for logger services. +/// +/// Services are sorted ascending by `minimalLogLevel` at initialization. +/// For every `LogLevel`, an exclusive upper-bound index into the sorted array +/// is stored so that `services(for:)` is O(1) and returns a non-allocating +/// `ArraySlice` of the services that should receive a log at that level. +struct LogServiceRegistry { + let sortedServices: [LoggerService] + let endIndexByLevel: [LogLevel: Int] + + var isEmpty: Bool { sortedServices.isEmpty } + var allServices: [LoggerService] { sortedServices } + + init(services: [LoggerService]) { + let sorted = services.sorted { $0.minimalLogLevel < $1.minimalLogLevel } + var indices: [LogLevel: Int] = [:] + let logLevelCases = LogLevel.allCases + indices.reserveCapacity(logLevelCases.count) + for level in logLevelCases { + indices[level] = sorted.firstIndex { $0.minimalLogLevel > level } ?? services.count + } + self.sortedServices = sorted + self.endIndexByLevel = indices + } + + /// Services whose `minimalLogLevel` is less than or equal to `level`. + func services(for level: LogLevel) -> ArraySlice { + let endIndex = endIndexByLevel[level, default: 0] + return sortedServices[.. Bool { + endIndexByLevel[level, default: 0] > 0 + } +} diff --git a/Sources/ForestryLoggerLibrary/LoggerService.swift b/Sources/ForestryLoggerLibrary/LoggerService.swift index 6488518..7f05a8f 100644 --- a/Sources/ForestryLoggerLibrary/LoggerService.swift +++ b/Sources/ForestryLoggerLibrary/LoggerService.swift @@ -6,7 +6,11 @@ import Foundation public protocol LoggerService { /// A minimal log level that this service should be used for. - var minimalLogLevel: LogLevel { get set } + /// + /// This value is read once when the service is passed to `ForestryLogger` + /// and is cached in a precomputed routing table for fast lookup. + /// Mutating it afterwards has no effect on log routing. + var minimalLogLevel: LogLevel { get } /// Logs the message in services based on minimumLogLevel. /// The log may be executed from a different Thread than the one that called the function as the logging happens asynchronously. diff --git a/Tests/ForestryLoggerLibraryTests/ForestryLoggerLibraryTests.swift b/Tests/ForestryLoggerLibraryTests/ForestryLoggerLibraryTests.swift index 1cf17b2..8b36742 100644 --- a/Tests/ForestryLoggerLibraryTests/ForestryLoggerLibraryTests.swift +++ b/Tests/ForestryLoggerLibraryTests/ForestryLoggerLibraryTests.swift @@ -2,201 +2,205 @@ // Copyright 2023 © Cleevio s.r.o. All rights reserved. // -import Foundation import ForestryLoggerLibrary -import XCTest - -@available(iOS 13.0, macOS 10.15, tvOS 13.0, watchOS 6.0, *) -final class MyLibraryTests: XCTestCase { - var mockLoggerService: LoggerServiceMock! - var logger: ForestryLogger! - - override func setUp() { - super.setUp() - - mockLoggerService = LoggerServiceMock() - logger = .init(service: mockLoggerService) +import Testing + +@Suite("ForestryLogger") +struct ForestryLoggerTests { + let mockLoggerService: LoggerServiceMock + let logger: ForestryLogger + + init() { + let mock = LoggerServiceMock() + self.mockLoggerService = mock + self.logger = .init(service: mock) } - func testLog() { + @Test("log is delivered with the provided message") + func log() async { let logMessage = "LogMessage" - - let expectation = XCTestExpectation(description: "LogClosure is called") - - mockLoggerService.logClosure = { log in - XCTAssertEqual(log.message as? String, logMessage) - expectation.fulfill() + + await confirmation("LogClosure is called") { confirmed in + mockLoggerService.logClosure = { log in + #expect(log.message as? String == logMessage) + confirmed() + } + + logger.info(logMessage) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.info(logMessage) - - wait(for: [expectation], timeout: 0.1) } - func testLogIsNotCalledWithLowMinimumLevel() async throws { + @Test("log is not delivered to services with a higher minimum level") + func logIsNotCalledWithLowMinimumLevel() async { let logMessage = "LogMessage" - - mockLoggerService.minimalLogLevel = .error - - mockLoggerService.logClosure = { log in - XCTFail("Logger should not be called") + + let mock = LoggerServiceMock() + mock.minimalLogLevel = .error + + await confirmation("Logger should not be called", expectedCount: 0) { confirmed in + mock.logClosure = { _ in confirmed() } + let logger = ForestryLogger(service: mock) + + logger.debug(logMessage) + logger.verbose(logMessage) + logger.info(logMessage) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.debug(logMessage) - logger.verbose(logMessage) - logger.info(logMessage) - - try await Task.sleep(nanoseconds: 100_000_000) } - func testUpdateUserInfo() { - let expectedValue = [ - LogUserInfoKey.userID: "11341", + @Test("updateUserInfo forwards the full dictionary") + func updateUserInfo() async { + let expectedValue: [LogUserInfoKey: String] = [ + .userID: "11341", .deviceID: "147482", - .custom(key: "PerfectKey"): "2422" + .custom(key: "PerfectKey"): "2422", ] - - let expectation = XCTestExpectation(description: "UpdateUserInfo is called") - - mockLoggerService.configureUserInfoClosure = { userInfo in - XCTAssertEqual(userInfo, expectedValue) - expectation.fulfill() + + await confirmation("UpdateUserInfo is called") { confirmed in + mockLoggerService.configureUserInfoClosure = { userInfo in + #expect(userInfo == expectedValue) + confirmed() + } + + logger.updateUserInfo(for: expectedValue) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.updateUserInfo(for: expectedValue) - - wait(for: [expectation], timeout: 0.1) } - func testUpdateUserInfoForEmptyDictionary() { + @Test("updateUserInfo with an empty dictionary still calls services") + func updateUserInfoForEmptyDictionary() async { let expectedValue: [LogUserInfoKey: String] = [:] - let expectation = XCTestExpectation(description: "UpdateUserInfo is called") - - mockLoggerService.configureUserInfoClosure = { userInfo in - XCTAssertEqual(userInfo, expectedValue) - expectation.fulfill() + + await confirmation("UpdateUserInfo is called") { confirmed in + mockLoggerService.configureUserInfoClosure = { userInfo in + #expect(userInfo == expectedValue) + confirmed() + } + + logger.updateUserInfo(for: expectedValue) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.updateUserInfo(for: expectedValue) - - wait(for: [expectation], timeout: 0.1) } - func testUpdateUserInfoSingleKey() { + @Test("updateUserInfo with a single key forwards a one-entry dictionary") + func updateUserInfoSingleKey() async { let expectedKey = LogUserInfoKey.userID let expectedValue = "11341" - - let expectation = XCTestExpectation(description: "UpdateUserInfo is called") - mockLoggerService.configureUserInfoClosure = { userInfo in - XCTAssertEqual(userInfo, [expectedKey: expectedValue]) - expectation.fulfill() + await confirmation("UpdateUserInfo is called") { confirmed in + mockLoggerService.configureUserInfoClosure = { userInfo in + #expect(userInfo == [expectedKey: expectedValue]) + confirmed() + } + + logger.updateUserInfo(for: expectedKey, with: expectedValue) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.updateUserInfo(for: expectedKey, with: expectedValue) - - wait(for: [expectation], timeout: 0.1) } - func testRemoveUserInfo() { - let expectedKeys = [LogUserInfoKey.deviceID, .email, .custom(key: "Perfect key")] - let expectation = XCTestExpectation(description: "RemoveUserInfo is called") - let userInfoExpectation = XCTestExpectation(description: "UserInfo is called") + @Test("removeUserInfo forwards the keys and reconfigures stored user info") + func removeUserInfo() async { + let expectedKeys: [LogUserInfoKey] = [.deviceID, .email, .custom(key: "Perfect key")] - mockLoggerService.removeUserInfoClosure = { keys in - XCTAssertEqual(keys, expectedKeys) - expectation.fulfill() - } - - mockLoggerService.configureUserInfoClosure = { userInfo in - XCTAssertEqual(userInfo, [:]) - userInfoExpectation.fulfill() + await confirmation("RemoveUserInfo and configureUserInfo are called", expectedCount: 2) { confirmed in + mockLoggerService.removeUserInfoClosure = { keys in + #expect(keys == expectedKeys) + confirmed() + } + mockLoggerService.configureUserInfoClosure = { userInfo in + #expect(userInfo == [:]) + confirmed() + } + + logger.removeUserInfo(for: expectedKeys) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.removeUserInfo(for: expectedKeys) - - wait(for: [expectation, userInfoExpectation], timeout: 0.1) } - func testRemoveUserInfoForEmptyKeys() { + @Test("removeUserInfo with empty keys still reconfigures stored user info") + func removeUserInfoForEmptyKeys() async { let expectedKeys: [LogUserInfoKey] = [] - let expectation = XCTestExpectation(description: "RemoveUserInfo is called") - let userInfoExpectation = XCTestExpectation(description: "UserInfo is called") - mockLoggerService.removeUserInfoClosure = { keys in - XCTAssertEqual(keys, expectedKeys) - expectation.fulfill() - } - - mockLoggerService.configureUserInfoClosure = { userInfo in - XCTAssertEqual(userInfo, [:]) - userInfoExpectation.fulfill() + await confirmation("RemoveUserInfo and configureUserInfo are called", expectedCount: 2) { confirmed in + mockLoggerService.removeUserInfoClosure = { keys in + #expect(keys == expectedKeys) + confirmed() + } + mockLoggerService.configureUserInfoClosure = { userInfo in + #expect(userInfo == [:]) + confirmed() + } + + logger.removeUserInfo(for: expectedKeys) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.removeUserInfo(for: expectedKeys) - - wait(for: [expectation, userInfoExpectation], timeout: 0.1) } - func testRemoveUserInfoForSingleKey() { + @Test("removeUserInfo with a single key forwards the key") + func removeUserInfoForSingleKey() async { let expectedKey = LogUserInfoKey.email - let expectation = XCTestExpectation(description: "RemoveUserInfo is called") - let userInfoExpectation = XCTestExpectation(description: "UserInfo is called") - mockLoggerService.removeUserInfoClosure = { keys in - XCTAssertEqual(keys, [expectedKey]) - expectation.fulfill() - } - - mockLoggerService.configureUserInfoClosure = { userInfo in - XCTAssertEqual(userInfo, [:]) - userInfoExpectation.fulfill() + await confirmation("RemoveUserInfo and configureUserInfo are called", expectedCount: 2) { confirmed in + mockLoggerService.removeUserInfoClosure = { keys in + #expect(keys == [expectedKey]) + confirmed() + } + mockLoggerService.configureUserInfoClosure = { userInfo in + #expect(userInfo == [:]) + confirmed() + } + + logger.removeUserInfo(for: expectedKey) + + try? await Task.sleep(nanoseconds: 100_000_000) } - - logger.removeUserInfo(for: expectedKey) - - wait(for: [expectation, userInfoExpectation], timeout: 0.1) } - func testRemoveUserInfoPreservesPreviouslySetUserInfo() { - let dictionary = [ - LogUserInfoKey.deviceID: "13131", + @Test("removeUserInfo preserves previously set keys") + func removeUserInfoPreservesPreviouslySetUserInfo() async { + let dictionary: [LogUserInfoKey: String] = [ + .deviceID: "13131", .email: "lukas.valenta@cleevio.com", - .custom(key: "Perfect key"): "42421" - + .custom(key: "Perfect key"): "42421", ] - - let userInfoExpectation = XCTestExpectation(description: "UserInfo is called") - let userInfoSecondExpectation = XCTestExpectation(description: "UserInfo is called") - - var userInfoHasBeenCalled = false - - mockLoggerService.configureUserInfoClosure = { userInfo in - if !userInfoHasBeenCalled { - XCTAssertEqual(userInfo, dictionary) - userInfoHasBeenCalled = true - userInfoExpectation.fulfill() - } else { - XCTAssertEqual(userInfo.count, 2) - XCTAssertEqual(userInfo[.deviceID], "13131") - XCTAssertEqual(userInfo[.custom(key: "Perfect key")], "42421") - userInfoSecondExpectation.fulfill() + + await confirmation("configureUserInfo is called twice", expectedCount: 2) { confirmed in + nonisolated(unsafe) var configureCallCount = 0 + mockLoggerService.configureUserInfoClosure = { userInfo in + if configureCallCount == 0 { + #expect(userInfo == dictionary) + } else { + #expect(userInfo.count == 2) + #expect(userInfo[.deviceID] == "13131") + #expect(userInfo[.custom(key: "Perfect key")] == "42421") + } + configureCallCount += 1 + confirmed() } + + logger.updateUserInfo(for: dictionary) + logger.removeUserInfo(for: .email) + + try? await Task.sleep(nanoseconds: 200_000_000) } - - logger.updateUserInfo(for: dictionary) - - logger.removeUserInfo(for: .email) - - wait(for: [userInfoExpectation, userInfoSecondExpectation], timeout: 0.1) } - // Here just to ensure logger does not crash - func testEmptyLogger() { - logger = .init(services: []) - + @Test("empty logger does not crash") + func emptyLogger() async { + let logger = ForestryLogger(services: []) + logger.info("Message") logger.updateUserInfo(for: .email, with: "kgkfd") logger.removeUserInfo(for: .email) + + try? await Task.sleep(nanoseconds: 50_000_000) } } diff --git a/Tests/ForestryLoggerLibraryTests/LogServiceRegistryTests.swift b/Tests/ForestryLoggerLibraryTests/LogServiceRegistryTests.swift new file mode 100644 index 0000000..f67bf11 --- /dev/null +++ b/Tests/ForestryLoggerLibraryTests/LogServiceRegistryTests.swift @@ -0,0 +1,144 @@ +// +// Copyright 2023 © Cleevio s.r.o. All rights reserved. +// + +@testable import ForestryLoggerLibrary +import Testing + +@Suite("LogServiceRegistry") +struct LogServiceRegistryTests { + + private func makeService(_ level: LogLevel) -> LoggerServiceMock { + let mock = LoggerServiceMock() + mock.minimalLogLevel = level + return mock + } + + // MARK: - Empty + + @Test("empty registry has no services at any level") + func emptyRegistryHasNoServicesAtAnyLevel() { + let registry = LogServiceRegistry(services: []) + + #expect(registry.isEmpty) + #expect(registry.allServices.isEmpty) + for level in LogLevel.allCases { + #expect(registry.services(for: level).isEmpty, "Expected no services for \(level)") + } + } + + // MARK: - Filtering semantics + + @Test("service qualifies for every level at or above its minimum") + func serviceQualifiesForEveryLevelAtOrAboveItsMinimum() { + let warningService = makeService(.warning) + let registry = LogServiceRegistry(services: [warningService]) + + #expect(registry.services(for: .verbose).isEmpty) + #expect(registry.services(for: .debug).isEmpty) + #expect(registry.services(for: .info).isEmpty) + #expect(registry.services(for: .warning).count == 1) + #expect(registry.services(for: .error).count == 1) + } + + @Test("each level returns exactly the qualifying services") + func eachLevelReturnsExactlyTheQualifyingServices() { + let registry = LogServiceRegistry(services: [ + makeService(.error), + makeService(.verbose), + makeService(.info), + ]) + + #expect(registry.services(for: .verbose).map { $0.minimalLogLevel } == [.verbose]) + #expect(registry.services(for: .debug).map { $0.minimalLogLevel } == [.verbose]) + #expect(registry.services(for: .info).map { $0.minimalLogLevel } == [.verbose, .info]) + #expect(registry.services(for: .warning).map { $0.minimalLogLevel } == [.verbose, .info]) + #expect(registry.services(for: .error).map { $0.minimalLogLevel } == [.verbose, .info, .error]) + } + + // MARK: - Sorting + + @Test("services are sorted ascending by minimalLogLevel") + func servicesAreSortedAscendingByMinimalLogLevel() { + let services = [ + makeService(.error), + makeService(.verbose), + makeService(.warning), + makeService(.debug), + makeService(.info), + ] + + let registry = LogServiceRegistry(services: services) + + #expect(registry.allServices.map { $0.minimalLogLevel } == [.verbose, .debug, .info, .warning, .error]) + } + + @Test("duplicate log levels are preserved") + func duplicateLogLevelsArePreserved() { + let registry = LogServiceRegistry(services: [ + makeService(.info), + makeService(.info), + makeService(.info), + ]) + + #expect(registry.allServices.count == 3) + #expect(registry.services(for: .debug).count == 0) + #expect(registry.services(for: .info).count == 3) + #expect(registry.services(for: .error).count == 3) + } + + // MARK: - All levels / boundaries + + @Test("service at verbose minimum is returned for every level") + func serviceAtVerboseMinimumIsReturnedForEveryLevel() { + let registry = LogServiceRegistry(services: [makeService(.verbose)]) + + for level in LogLevel.allCases { + #expect(registry.services(for: level).count == 1, "Expected verbose service at \(level)") + } + } + + @Test("service at error minimum is only returned for error") + func serviceAtErrorMinimumIsOnlyReturnedForError() { + let registry = LogServiceRegistry(services: [makeService(.error)]) + + #expect(registry.services(for: .verbose).isEmpty) + #expect(registry.services(for: .debug).isEmpty) + #expect(registry.services(for: .info).isEmpty) + #expect(registry.services(for: .warning).isEmpty) + #expect(registry.services(for: .error).count == 1) + } + + // MARK: - Lookup performs no filtering + + @Test("hasAnyService reports presence per level") + func hasAnyServiceReportsPresencePerLevel() { + let registry = LogServiceRegistry(services: [makeService(.info)]) + + #expect(!registry.hasAnyService(for: .verbose)) + #expect(!registry.hasAnyService(for: .debug)) + #expect(registry.hasAnyService(for: .info)) + #expect(registry.hasAnyService(for: .warning)) + #expect(registry.hasAnyService(for: .error)) + } + + @Test("hasAnyService is false for an empty registry") + func hasAnyServiceIsFalseForEmptyRegistry() { + let registry = LogServiceRegistry(services: []) + + for level in LogLevel.allCases { + #expect(!registry.hasAnyService(for: level)) + } + } + + @Test("returned slice references all services storage") + func returnedSliceReferencesAllServicesStorage() { + let services = [makeService(.verbose), makeService(.info), makeService(.error)] + let registry = LogServiceRegistry(services: services) + + let slice = registry.services(for: .error) + #expect(slice.count == registry.allServices.count) + #expect(slice.startIndex == registry.allServices.startIndex) + #expect(slice.endIndex == registry.allServices.endIndex) + } +} diff --git a/Tests/ForestryLoggerLibraryTests/LoggerServiceMock.swift b/Tests/ForestryLoggerLibraryTests/LoggerServiceMock.swift index 0d29ffe..9b8db8f 100644 --- a/Tests/ForestryLoggerLibraryTests/LoggerServiceMock.swift +++ b/Tests/ForestryLoggerLibraryTests/LoggerServiceMock.swift @@ -5,7 +5,7 @@ import Foundation import ForestryLoggerLibrary -final class LoggerServiceMock: LoggerService { +final class LoggerServiceMock: LoggerService, @unchecked Sendable { var minimalLogLevel: LogLevel = .info var logClosure: ((LogInfo) -> Void)? var configureUserInfoClosure: (([LogUserInfoKey: String]) -> Void)?