diff --git a/CHANGELOG.md b/CHANGELOG.md index 78aeff7..3138e6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,20 @@ # Changelog +# TODO: +Testing required before release: + +- Confirm `Compatibility Module Test Entries` displays each reusable `TestCase` separately. +- Confirm the new entries execute successfully and preserve readable module, section, and test names. +- Confirm the serialized debug tests restore `Compatibility.settings` even when an expectation throws. +- Run SwiftPM and supported-platform validation before tagging the release. + +## v1.18.3 2026-07-28 +Added the reusable `Compatibility Testing Library` product and `ModuleTestEntry` adapter so each module `TestCase` appears as an individually named Swift Testing result. +Unified `TestCase.execute()` and live test execution through one lifecycle implementation with explicit parallel and serialized execution modes. +Added source-aware test failures, labeled debug-format context, and source-context debugging conveniences while preserving existing debug-format call sites. +Made debug tests run exclusively and restore process-global debug settings with `defer`, including when an expectation throws. +Expanded contributor guidance for short, staged, maintainer-reviewed coding workflows. + ## v1.18.2 2026-07-23 Fixed Swift Package Index build errors and warnings across SwiftUI and WebAssembly targets. Replaced conditional SwiftUI `Group` wrappers with direct `@ViewBuilder` results and concrete text-selection types. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 2a74d3d..94c6617 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -10,6 +10,19 @@ PROMPT for updating Module packages: Review this Swift package for adoption of the Module APIs introduced in github.com/kudit/Compatibility v1.16.0 or later. Inspect the package’s existing architecture and preserve its public behavior and platform compatibility. Add or update its Compatibility dependency if necessary. Apply an appropriate Module conformance, including its version, direct Compatibility dependency, module dependencies, immediately available moduleInfo, ordered TestCase sections, and opt-in open-source repository metadata when applicable. Register the package from its highest-level module or document how an application should register it through Application.track(including:). Add complete inline DocC comments to the relevant public APIs so generated documentation can discover them. Do not create a .docc catalog, separate documentation articles, or another documentation folder. Preserve existing comments unless they are missing, unclear, or inaccurate. Put reusable tests in the module's TestCase collections so they run both in the in-app test UI and through the Swift Testing bridge; retain target-specific tests only where infrastructure requires them. Follow this package’s existing CONTRIBUTING.md, changelog, versioning, formatting, availability, and compatibility conventions. Avoid unrelated reformatting and whitespace-only changes. Before changing version numbers, compare the current changelog version with the latest committed Git version. If the active working-tree changelog is already ahead of Git, do not choose another version; synchronize that active version across every package manifest, Xcode project, public source constant, test fixture or suite heading, README or documentation display, and other hard-coded version surface. Please check that all deprecations (that can) have appropriate renamed clauses for easy fixits. +## Collaborative coding workflow + +When working interactively with a maintainer, generally (this shouldn't be meant to override thread instructions but are here as a default): +- Work in small, reviewable stages rather than delivering a large implementation all at once. +- Present one immediate decision or action at a time and pause for maintainer feedback unless instructed to do a batch. +- Explain design choices briefly and answer questions before continuing implementation. +- Preserve and review the maintainer's local edits before adding further changes. +- Let the maintainer build, edit, commit, and push between stages when practical. +- After each pushed maintainer change, review the latest commit before proposing or applying the next change. +- Keep pull requests in draft until the implementation is compiled, exercised by real tests, and fully reviewed. +- Avoid unrelated cleanup, broad reformatting, and speculative changes that make the diff harder to reason about. + + ## Version and changelog rules - Keep changelog entries in `## vX.X.X YYYY-MM-DD` format, with short line-separated notes under the current version. diff --git a/Development/Compatibility.xcodeproj/project.pbxproj b/Development/Compatibility.xcodeproj/project.pbxproj index 850eac0..16887bc 100644 --- a/Development/Compatibility.xcodeproj/project.pbxproj +++ b/Development/Compatibility.xcodeproj/project.pbxproj @@ -14,6 +14,7 @@ B5209EE32C431CF800FBA30B /* CompatibilityDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5209EE22C431CF800FBA30B /* CompatibilityDemoView.swift */; }; B5209EE42C431CF800FBA30B /* CompatibilityDemoView.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5209EE22C431CF800FBA30B /* CompatibilityDemoView.swift */; }; B52C8E0F2C38CA76008EBD2D /* MyApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E5FC3A2C3860EC004F2009 /* MyApp.swift */; }; + B52DEB233019BA54003291D0 /* Compatibility Testing Library in Frameworks */ = {isa = PBXBuildFile; productRef = B52DEB223019BA54003291D0 /* Compatibility Testing Library */; }; B569253B2E8715550045FFC6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5E5FC822C3863B9004F2009 /* Assets.xcassets */; }; B579D4A52C46FF1A009A037A /* Compatibility Library in Frameworks */ = {isa = PBXBuildFile; productRef = B579D4A42C46FF1A009A037A /* Compatibility Library */; }; B58B5C452C38F98800689837 /* (null) in Sources */ = {isa = PBXBuildFile; }; @@ -91,6 +92,7 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( + B52DEB233019BA54003291D0 /* Compatibility Testing Library in Frameworks */, B594CFB72DB0BACA001E8658 /* Compatibility Library in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -227,6 +229,7 @@ name = CompatibilityTests; packageProductDependencies = ( B594CFB62DB0BACA001E8658 /* Compatibility Library */, + B52DEB223019BA54003291D0 /* Compatibility Testing Library */, ); productName = CompatibilityTests; productReference = B594CFA92DB0B838001E8658 /* CompatibilityTests.xctest */; @@ -488,7 +491,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 12.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 1.18.2; + MARKETING_VERSION = 1.18.3; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -559,7 +562,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 12.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 1.18.2; + MARKETING_VERSION = 1.18.3; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = ""; @@ -814,6 +817,10 @@ package = B52C8E0D2C3886E6008EBD2D /* XCLocalSwiftPackageReference ".." */; productName = "Compatibility Library"; }; + B52DEB223019BA54003291D0 /* Compatibility Testing Library */ = { + isa = XCSwiftPackageProductDependency; + productName = "Compatibility Testing Library"; + }; B579D4A42C46FF1A009A037A /* Compatibility Library */ = { isa = XCSwiftPackageProductDependency; package = B52C8E0D2C3886E6008EBD2D /* XCLocalSwiftPackageReference ".." */; diff --git a/Development/CompatibilityTests/CompatibilityTests.swift b/Development/CompatibilityTests/CompatibilityTests.swift index b991bb8..a15bf8b 100644 --- a/Development/CompatibilityTests/CompatibilityTests.swift +++ b/Development/CompatibilityTests/CompatibilityTests.swift @@ -446,25 +446,8 @@ struct CompatibilityTests { } } - /// Runs every public module section through the same TestCase values used by the live UI. - @Test( - "Compatibility Module Tests", - arguments: await MainActor.run { Compatibility.tests.keys.elements } - ) - @MainActor - @available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) - func moduleTests(section: String) async throws { - // Compatibility.tests is the authoritative package-wide test collection. - let tests = Compatibility.tests[section] ?? [] - try await withThrowingTaskGroup(of: Void.self) { group in - for test in tests { - // Each case is independently isolated by TestCase, so long-running rows can overlap. - group.addTask { - try await test.execute() - } - } - try await group.waitForAll() - } - } + // Reusable module tests now live in ModuleTestEntryTests.swift. That adapter creates one + // Swift Testing argument per TestCase, so keeping the former section-based bridge here would + // execute the same Compatibility tests twice and hide individual test names beneath a section. } #endif diff --git a/Development/CompatibilityTests/ModuleTestEntryTests.swift b/Development/CompatibilityTests/ModuleTestEntryTests.swift new file mode 100644 index 0000000..8f097eb --- /dev/null +++ b/Development/CompatibilityTests/ModuleTestEntryTests.swift @@ -0,0 +1,31 @@ +// +// ModuleTestEntryTests.swift +// CompatibilityTests +// +// Exercises the reusable CompatibilityTesting adapter through Swift Testing. +// + +#if compiler(>=5.9) && canImport(Compatibility) && canImport(CompatibilityTesting) && canImport(Testing) +import Compatibility +import CompatibilityTesting +import Testing + +@Suite("Compatibility Module Test Entries") +struct ModuleTestEntryTests { + /// Presents every reusable Compatibility `TestCase` as its own named Swift Testing argument. + @Test( + "Compatibility Module Test", + arguments: await MainActor.run { + ModuleTestEntry.entries( + for: Compatibility.self, + tests: Compatibility.tests + ) + } + ) + @MainActor + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + func moduleTest(entry: ModuleTestEntry) async throws { + try await entry.execute() + } +} +#endif diff --git a/Package.swift b/Package.swift index de0d7b2..e722e6b 100644 --- a/Package.swift +++ b/Package.swift @@ -5,7 +5,7 @@ // This file is automatically generated. // Do not edit it by hand because the contents will be replaced. -let version = "1.18.2" +let version = "1.18.3" let packageLibraryName = "Compatibility" #if canImport(PackageDescription) @@ -31,7 +31,8 @@ var targets = [ dependencies: [ // .product(name: "Compatibility Library", package: "compatibility"), // apparently needs to be lowercase. Also note this is "Compatibility Library" not "Compatibility" ], - path: "Sources" + path: "Sources", + exclude: ["CompatibilityTesting"] // If resources need to be included in the module, include here // ,resources: [ // unfortuantely cannot be conditionally compiled based on Swift version since the tool seems to be run on latest version. // Resource.process("Resources"), @@ -129,6 +130,10 @@ targets += [ // on macOS still discover them, so maintainers can build the CLI and run tests. #if !SwiftPlaygrounds && !canImport(PlaygroundSupport) products += [ + .library( + name: "Compatibility Testing Library", + targets: ["CompatibilityTesting"] + ), .executable( name: "compatibilityCLI", targets: ["compatibilityCLI"] @@ -136,6 +141,11 @@ products += [ ] targets += [ + .target( + name: "CompatibilityTesting", + dependencies: [.init(stringLiteral: packageLibraryName)], + path: "Sources/CompatibilityTesting" + ), .executableTarget( name: "compatibilityCLI", dependencies: [.init(stringLiteral: packageLibraryName)], @@ -143,7 +153,10 @@ targets += [ ), .testTarget( name: "\(packageLibraryName)Tests", - dependencies: [.init(stringLiteral: packageLibraryName)],// have to use init since normally would be assignable by string literal but we're not using a string literal + dependencies: [ + .init(stringLiteral: packageLibraryName), + "CompatibilityTesting", + ],// have to use init since normally would be assignable by string literal but we're not using a string literal path: "Development/CompatibilityTests", // The Xcode project consumes this test plan directly, while SwiftPM has no // declaration for it and otherwise warns that the file is unhandled. diff --git a/Sources/Compatibility.swift b/Sources/Compatibility.swift index 90cef8c..2afbbb1 100644 --- a/Sources/Compatibility.swift +++ b/Sources/Compatibility.swift @@ -8,7 +8,7 @@ public enum Compatibility: Module { /// The version of the Compatibility Library since cannot get directly from Package.swift. - public static let version: Version = "1.18.2" + public static let version: Version = "1.18.3" /// Public source repository for Compatibility so support reports can direct developers to its source and issue history. /// diff --git a/Sources/CompatibilityTesting/ModuleTestEntry.swift b/Sources/CompatibilityTesting/ModuleTestEntry.swift new file mode 100644 index 0000000..41fc907 --- /dev/null +++ b/Sources/CompatibilityTesting/ModuleTestEntry.swift @@ -0,0 +1,89 @@ +#if compiler(>=5.9) && canImport(Testing) +import Compatibility +import Testing + +/// One reusable Compatibility `TestCase` presented as an individual Swift Testing argument. +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +public struct ModuleTestEntry: Sendable, Identifiable { + public let moduleIdentifier: String + public let moduleName: String + public let section: String + public let testTitle: String + public let index: Int + + private let testCase: TestCase + + public var id: String { + "\(moduleIdentifier)/\(section)/\(index)" + } + + @MainActor + init(module: Module.Type, section: String, index: Int, testCase: TestCase) { + self.moduleIdentifier = module.moduleIdentifier + self.moduleName = module.moduleName + self.section = section + self.testTitle = testCase.title + self.index = index + self.testCase = testCase + } + + /// Executes the original shared test and propagates its detailed error into Swift Testing and Xcode. + @MainActor + public func execute() async throws { + try await testCase.execute() + } +} + +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +extension ModuleTestEntry: CustomTestStringConvertible { + public var testDescription: String { + "\(moduleName) › \(section) › \(testTitle)" + } +} + +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +extension ModuleTestEntry: CustomTestArgumentEncodable { + public func encodeTestArgument(to encoder: some Encoder) throws { + var container = encoder.singleValueContainer() + try container.encode(id) + } +} + +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +public extension ModuleTestEntry { + /// Flattens an explicitly supplied module test catalog into individually named test arguments. + /// + /// The caller supplies the concrete module's `tests` value so Swift does not fall back to a + /// protocol-extension default when a downstream package has an overly restrictive availability + /// annotation. Dependency traversal remains the responsibility of Compatibility's existing + /// `Build` registration graph rather than being duplicated in the testing adapter. + @MainActor + static func entries( + for module: Module.Type, + tests: OrderedDictionary + ) -> [ModuleTestEntry] { + tests.flatMap { section, tests in + tests.enumerated().map { index, testCase in + ModuleTestEntry( + module: module, + section: section, + index: index, + testCase: testCase + ) + } + } + } + + /// Flattens each supplied module's protocol-visible catalog. + /// + /// This convenience remains useful once conforming modules expose `tests` at the same + /// availability as the `Module` requirement. Call ``entries(for:tests:)`` while migrating an + /// older conformer whose test catalog has a stricter availability annotation. + @MainActor + static func entries(including modules: Module.Type...) -> [ModuleTestEntry] { + modules.flatMap { module in + entries(for: module, tests: module.tests) + } + } +} +#endif diff --git a/Sources/Core/Build.swift b/Sources/Core/Build.swift index d856595..5233edd 100644 --- a/Sources/Core/Build.swift +++ b/Sources/Core/Build.swift @@ -523,7 +523,7 @@ public extension Build.Environment { case .designedForiPad: return .purple case .macCatalyst: - if #available(iOS 15.0, macCatalyst 15.0, tvOS 15.0, macOS 12.0, watchOS 8.0, *) { + if #available(iOS 15, macCatalyst 15, tvOS 15, macOS 12, watchOS 8, *) { return .teal } else { return .purple diff --git a/Sources/Core/CloudStatus.swift b/Sources/Core/CloudStatus.swift index efd4766..8cafa6c 100644 --- a/Sources/Core/CloudStatus.swift +++ b/Sources/Core/CloudStatus.swift @@ -24,7 +24,7 @@ public enum CloudStatus: CustomStringConvertible, Sendable, CaseIterable, Symbol } #if compiler(>=5.9) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension CloudStatus { /// Shared enum behavior tests available to both the in-app test UI and Swift Testing bridge. @MainActor diff --git a/Sources/Core/Debug.swift b/Sources/Core/Debug.swift index c1ac45d..2d7a780 100644 --- a/Sources/Core/Debug.swift +++ b/Sources/Core/Debug.swift @@ -1,6 +1,43 @@ +// TODO: Needs a real file header documentation/comment. +// Here since all releated to Debug code to simplify conditional code gates. +#if hasFeature(Embedded) +public typealias DebugMessage = String +#else +public typealias DebugMessage = Any +#endif + +/// Named values supplied to a custom debug formatter. +public struct DebugFormatContext: Sendable { + public let message: String + public let level: DebugLevel + public let isMainThread: Bool + public let emojiSupported: Bool + public let includeContext: Bool + public let includeTimestamp: Bool + public let source: SourceContext + + public init( + message: String, + level: DebugLevel, + isMainThread: Bool, + emojiSupported: Bool, + includeContext: Bool, + includeTimestamp: Bool, + source: SourceContext + ) { + self.message = message + self.level = level + self.isMainThread = isMainThread + self.emojiSupported = emojiSupported + self.includeContext = includeContext + self.includeTimestamp = includeTimestamp + self.source = source + } +} + +public typealias DebugFormatter = (DebugFormatContext) -> String -// Here since all releated to Debug code. public struct CompatibilityConfiguration: PropertyIterable { /// Override to change the which debug levels are output. This level and higher (more important) will be output. public var debugLevelCurrent: DebugLevel = Build.isDebug ? .DEBUG : .WARNING @@ -51,6 +88,58 @@ public struct CompatibilityConfiguration: PropertyIterable { return "\(timestamp)\(message)" } } + + /// Preferred labeled alternative to the legacy positional `debugFormat` closure. + /// Assigning either property updates the same underlying formatter. + public var debugFormatter: DebugFormatter { + get { + let legacyFormatter = debugFormat + return { context in + legacyFormatter( + context.message, + context.level, + context.isMainThread, + context.emojiSupported, + context.includeContext, + context.includeTimestamp, + context.source.file, + context.source.function, + context.source.line, + context.source.column + ) + } + } + set { + debugFormat = { + message, + level, + isMainThread, + emojiSupported, + includeContext, + includeTimestamp, + file, + function, + line, + column in + newValue( + DebugFormatContext( + message: message, + level: level, + isMainThread: isMainThread, + emojiSupported: emojiSupported, + includeContext: includeContext, + includeTimestamp: includeTimestamp, + source: SourceContext( + file: file, + function: function, + line: line, + column: column + ) + ) + ) + } + } + } /// Function to handle how the debug messages are logged. Can change to have the messages logged to a file or a string. Default is to print to the console. public var debugLog = { (message: String) in @@ -114,11 +203,11 @@ public struct CustomError: Error, Sendable { } @discardableResult func debug() -> String { -#if !hasFeature(Embedded) - return Compatibility.debug(description, level: level ?? DebugLevel.defaultLevel, file: file, function: function, line: line, column: column) -#else - return Compatibility.debug(description, isMainThread: true, level: level ?? DebugLevel.defaultLevel, file: file, function: function, line: line, column: column) -#endif + Compatibility.debug( + description, + level: level ?? DebugLevel.defaultLevel, + source: SourceContext(file: file, function: function, line: line, column: column) + ) } } extension CustomError: CustomStringConvertible { @@ -239,9 +328,9 @@ public enum DebugLevel: Comparable, CustomStringConvertible, CaseIterable, Senda } /// Generates context string -#if !DEBUG @available(*, deprecated, message: "Use Compatibility.settings.debugFormat with the desired formatting options instead.") public func debugContext(isMainThread: Bool, file: String, function: String, line: Int, column: Int) -> String { + // TODO: Convert this to the debugFormatter callsite for clarity Compatibility.settings.debugFormat( "", .OFF, @@ -251,12 +340,11 @@ public func debugContext(isMainThread: Bool, file: String, function: String, lin Compatibility.settings.debugIncludeTimestamp, file, function, line, column) } -#endif // MARK: - Debug public extension Compatibility { /** - Ku: Debug helper for printing info to screen including file and line info of call site. Also can provide a log level for use in loggers or for globally turning on/off logging. (Modify DebugLevel.currentLevel to set level to output. When launching app, probably can set this to DebugLevel.OFF + Debug helper for printing info to screen including file and line info of call site. Also can provide a log level for use in loggers or for globally turning on/off logging. (Modify DebugLevel.currentLevel to set level to output. When launching app, set this to DebugLevel.OFF for release builds. - Parameter message: The message to report. - Parameter level: The logging level to use. @@ -265,19 +353,30 @@ public extension Compatibility { - Parameter line: For bubbling down the #line number from a call site. - Parameter column: For bubbling down the #column number from a call site. (Not used currently but here for completeness). */ -#if !hasFeature(Embedded) @discardableResult - static func debug(_ message: Any, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { -#if canImport(Foundation) - let isMainThread = Thread.isMainThread // capture before we switch to main thread for printing -#else + static func debug(_ message: DebugMessage, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { +#if hasFeature(Embedded) || !canImport(Foundation) let isMainThread = true -#endif +#else + let isMainThread = Thread.isMainThread // capture before we switch to main thread for printing let message = String(describing: message) // convert to sendable item to avoid any thread issues. - +#endif return debug(message, isMainThread: isMainThread, level: level, file: file, function: function, line: line, column: column) } -#endif + + /// Logs a message using an already-captured source location. + @discardableResult + static func debug(_ message: DebugMessage, level: DebugLevel = .defaultLevel, source: SourceContext) -> String { + debug( + message, + level: level, + file: source.file, + function: source.function, + line: source.line, + column: source.column + ) + } + /// Put most of the business logic here for compatibility with WASM. isMainThread: is required to differentiate but can be removed in global definition @discardableResult static func debug(_ message: String, isMainThread: Bool, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { @@ -304,8 +403,8 @@ public extension Compatibility { } //DebugLevel.currentLevel = .ERROR /** - Ku: Debug helper for printing info to screen including file and line info of call site. Also can provide a log level for use in loggers or for globally turning on/off logging. (Modify DebugLevel.currentLevel to set level to output. When launching app, probably can set this to DebugLevel.OFF - + Debug helper for printing info to screen including file and line info of call site. Also can provide a log level for use in loggers or for globally turning on/off logging. (Modify DebugLevel.currentLevel to set level to output. When launching app, set this to DebugLevel.OFF for release builds. + - Parameter message: The message to report. - Parameter level: The logging level to use. - Parameter file: For bubbling down the #file name from a call site. @@ -313,18 +412,16 @@ public extension Compatibility { - Parameter line: For bubbling down the #line number from a call site. - Parameter column: For bubbling down the #column number from a call site. (Not used currently but here for completeness). */ -#if !hasFeature(Embedded) @discardableResult -public func debug(_ message: Any, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { +public func debug(_ message: DebugMessage, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { return Compatibility.debug(message, level: level, file: file, function: function, line: line, column: column) } -#else + +/// Logs a message using an already-captured source location. @discardableResult -public func debug(_ message: String, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { - // go directly to alternate version since dynamic casting is unavailable in WASM - return Compatibility.debug(message, isMainThread: true, level: level, file: file, function: function, line: line, column: column) +public func debug(_ message: DebugMessage, level: DebugLevel = .defaultLevel, source: SourceContext) -> String { + return Compatibility.debug(message, level: level, source: source) } -#endif // MARK: Debug(error) // This is to provide debugging at calltime when creating errors. @@ -339,11 +436,7 @@ public extension Error { - Parameter column: For bubbling down the #column number from a call site. (Not used currently but here for completeness). */ func debug(level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> Self { -#if !hasFeature(Embedded) Compatibility.debug(self.localizedDescription, level: level, file: file, function: function, line: line, column: column) -#else - Compatibility.debug(self.localizedDescription, isMainThread: true, level: level, file: file, function: function, line: line, column: column) -#endif return self } #if !canImport(Foundation) @@ -353,16 +446,34 @@ public extension Error { #endif } +public extension TestFailure { + /// Logs this failure at its original source location and returns it for throwing. + @discardableResult + func debug(level: DebugLevel = .ERROR) -> Self { + Compatibility.debug(message, level: level, source: source) + return self + } +} + // Testing and main-actor isolation are supported on current full-runtime WASM builds. #if compiler(>=5.9) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension DebugLevel { @MainActor internal static let testDebugConfig: TestClosure = { - // NOTE: This might happen concurrently with other tests so could cause issues with output... - // preserve original settings + // These tests temporarily replace process-global debug settings. Capture the complete + // configuration before making any changes so the surrounding application or test suite + // observes exactly the same settings after this test finishes. let previousSettings = Compatibility.settings + + // `defer` runs whether the test succeeds or throws. This is important because an + // expectation failure exits the closure immediately; a normal assignment at the bottom + // would be skipped and could leave later tests using this temporary logger or formatter. + defer { + Compatibility.settings = previousSettings + } + DebugLevel.defaultLevel = .WARNING // testing override default level DebugLevel.currentLevel = .NOTICE // testing override current level @@ -404,10 +515,9 @@ Normal output: \(defaultOutput) let blankText = debug("TestCase return output", level: .DEBUG) // less than the current level so should be silent try expect(blankText == "", "expected empty string but found \(blankText)") - - // reset settings for other tests - Compatibility.settings = previousSettings - // output messages that happened concurrently + + // `previousSettings` is restored automatically by the `defer` above. + // Output captured while the temporary logger was active remains intentionally suppressed. // Compatibility.settings.debugLog(concurrentOutput) // debug("TEST OUTPUT", level: .ERROR) } @@ -438,8 +548,11 @@ Normal output: \(defaultOutput) @MainActor static let tests = [ - TestCase("debug configuration tests", testDebugConfig), - TestCase("debug tests", testDebug), + // Both tests mutate process-global debug state (`Compatibility.settings` or the + // logger used by `debugSuppress`). Serialized mode prevents them from overlapping + // each other or any parallel reusable test while those temporary changes are active. + TestCase("debug configuration tests", executionMode: .serialized, testDebugConfig), + TestCase("debug tests", executionMode: .serialized, testDebug), ] } #endif diff --git a/Sources/Core/FileManager.swift b/Sources/Core/FileManager.swift index 5b66710..88ec6c4 100644 --- a/Sources/Core/FileManager.swift +++ b/Sources/Core/FileManager.swift @@ -42,7 +42,7 @@ public extension FileManager { } #if compiler(>=5.9) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) extension FileManager { /// Shared file-manager tests used by both the in-app runner and Swift Testing. @MainActor diff --git a/Sources/Core/Module.swift b/Sources/Core/Module.swift index fd753e5..72f4347 100644 --- a/Sources/Core/Module.swift +++ b/Sources/Core/Module.swift @@ -35,7 +35,7 @@ public protocol Module { /// The default is empty, so production-only modules do not need to declare tests. TestCase UI still /// presents the module identity and an empty state, making installed-module diagnostics complete. @MainActor - @available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) static var tests: OrderedDictionary { get } #endif @@ -122,7 +122,7 @@ public extension Module { #if compiler(>=5.9) /// Modules expose no tests unless the conformer provides ordered test sections. @MainActor - @available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) static var tests: OrderedDictionary { return [:] } @@ -273,7 +273,7 @@ private enum DependentModuleTestFixture: Module { } /// Shared Module tests used by both the in-app All Tests UI and the Swift Testing bridge. -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @MainActor private func testModuleMetadataAndDefaults() async throws { // Verify the default name remains derived from the conforming type so modules do not need boilerplate. @@ -324,13 +324,13 @@ private func testModuleMetadataAndDefaults() async throws { } /// Preserve the module test's actor boundary on every concurrency-capable target, including WebAssembly. -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) private let moduleMetadataTest: TestClosure = { @MainActor in try await testModuleMetadataAndDefaults() } /// The collection remains main-actor isolated on every supported platform, including WebAssembly. -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @MainActor internal let moduleTests: [TestCase] = [ TestCase("Module metadata and defaults", moduleMetadataTest), diff --git a/Sources/Core/Test.swift b/Sources/Core/Test.swift index b0973ad..6304cd3 100644 --- a/Sources/Core/Test.swift +++ b/Sources/Core/Test.swift @@ -6,7 +6,7 @@ public typealias TestClosure = @Sendable () async throws -> Void /// Passing one value is useful when an asynchronous helper needs to retain and forward a caller's /// location. Existing APIs continue exposing individual source arguments for source compatibility, /// while new APIs can accept `SourceContext` when carrying the complete location is clearer. -public struct SourceContext: Sendable { +public struct SourceContext: Sendable, CustomStringConvertible { public let file: String public let function: String public let line: Int @@ -24,7 +24,34 @@ public struct SourceContext: Sendable { self.line = line self.column = column } + + public var description: String { + "\(file.lastPathComponent):\(line):\(column) in \(function)" + } +} + +/// An expectation failure that retains the original source location. +public struct TestFailure: Error, Sendable, CustomStringConvertible { + public let message: String + public let source: SourceContext + + public init(_ message: String, source: SourceContext = SourceContext()) { + self.message = message + self.source = source + } + + public var description: String { + "\(message) [\(source)]" + } +} + +#if canImport(Foundation) +extension TestFailure: LocalizedError { + public var errorDescription: String? { + description + } } +#endif // This could be anything, not necessary a struct or class, so if we need this, have a list of tests rather than a Testable object //// don't make this public to avoid compiling test stuff into framework, however, do make public so apps can add in their own tests. @@ -41,26 +68,10 @@ public struct SourceContext: Sendable { /// live applications, previews, older systems, and test runners that do not provide Swift Testing. public func expect(_ condition: Bool, _ debugString: String? = nil, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) throws { guard condition else { - // set breakpoint on this line if we want to debug/inspect errors (note that this slows enough to mess with time stamp checks so disable once we know everything is working). - if let debugString { - throw CustomError(debugString) - } else { -#if canImport(Foundation) - let isMainThread = Thread.isMainThread -#else - let isMainThread = true -#endif - let context = Compatibility.settings.debugFormat( - "", - DebugLevel.OFF, - isMainThread, - Compatibility.settings.debugEmojiSupported, - true, - true, - file, function, line, column) - - throw CustomError(context) - } + let message = debugString ?? "Expectation failed" + let source = SourceContext(file: file, function: function, line: line, column: column) + debug(message, level: .ERROR, file: file, function: function, line: line, column: column) + throw TestFailure(message, source: source) } } @@ -120,6 +131,128 @@ public func debugSuppress(_ block: () async throws -> Void) async rethrows { // Testing is only supported with Swift 5.9+ #if compiler(>=5.9) + +/// Controls whether a reusable test may overlap other reusable tests. +public enum TestExecutionMode: Sendable, Equatable { + case parallel + case serialized +} + +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +private actor TestExecutionGate { + static let shared = TestExecutionGate() + + private var activeParallelCount = 0 + private var serializedRunning = false + private var parallelWaiters: [CheckedContinuation] = [] + private var serializedWaiters: [CheckedContinuation] = [] + + func acquire(_ mode: TestExecutionMode) async { + switch mode { + case .parallel: + if !serializedRunning && serializedWaiters.isEmpty { + activeParallelCount += 1 + return + } + await withCheckedContinuation { continuation in + parallelWaiters.append(continuation) + } + + case .serialized: + if !serializedRunning && activeParallelCount == 0 { + serializedRunning = true + return + } + await withCheckedContinuation { continuation in + serializedWaiters.append(continuation) + } + } + } + + func release(_ mode: TestExecutionMode) { + switch mode { + case .parallel: + activeParallelCount -= 1 + if activeParallelCount == 0 { + resumeWaitingTests() + } + + case .serialized: + serializedRunning = false + resumeWaitingTests() + } + } + + private func resumeWaitingTests() { + if !serializedWaiters.isEmpty { + serializedRunning = true + serializedWaiters.removeFirst().resume() + return + } + + let waiters = parallelWaiters + parallelWaiters.removeAll() + activeParallelCount += waiters.count + for waiter in waiters { + waiter.resume() + } + } +} + +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +private struct TestExecution: Sendable { + let title: String + let source: SourceContext + let setUp: TestClosure? + let test: TestClosure + let tearDown: TestClosure? + let mode: TestExecutionMode + + func perform() async throws { + await TestExecutionGate.shared.acquire(mode) + do { + try await performLifecycle() + await TestExecutionGate.shared.release(mode) + } catch { + await TestExecutionGate.shared.release(mode) + throw error + } + } + + private func performLifecycle() async throws { + var primaryError: (any Error)? + + do { + try await setUp?() + try await test() + } catch { + primaryError = normalized(error) + } + + do { + try await tearDown?() + } catch { + let teardownError = normalized(error) + if let primaryError { + debug("\(title) teardown also failed: \(teardownError)", level: .ERROR) + throw primaryError + } + throw teardownError + } + + if let primaryError { + throw primaryError + } + } + + private func normalized(_ error: any Error) -> any Error { + if error is TestFailure { + return error + } + return TestFailure("\(title) failed: \(error)", source: source) + } +} + // Test Handlers @MainActor @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @@ -162,6 +295,8 @@ public final class TestCase: ObservableObject, @unchecked Sendable { } } public let title: String + public let source: SourceContext + public let executionMode: TestExecutionMode public let setUp: TestClosure? public var test: TestClosure public let tearDown: TestClosure? @@ -175,26 +310,46 @@ public final class TestCase: ObservableObject, @unchecked Sendable { set { test = newValue } } @Published public var progress: TestProgress = .notStarted - + /// Creates a reusable test with optional lifecycle closures. /// /// Teardown is attempted even when setup or the test throws, matching the cleanup expectation /// familiar from XCTest without claiming `XCTestCase` API or inheritance compatibility. public init( _ title: String, + executionMode: TestExecutionMode = .parallel, setUp: TestClosure? = nil, test: @escaping TestClosure, - tearDown: TestClosure? = nil + tearDown: TestClosure? = nil, + source: SourceContext = SourceContext() ) { self.title = title + self.source = source + self.executionMode = executionMode self.setUp = setUp self.test = test self.tearDown = tearDown } /// Creates a reusable test without separate setup or teardown work. - public convenience init(_ title: String, _ test: @escaping TestClosure) { - self.init(title, test: test) + public convenience init( + _ title: String, + executionMode: TestExecutionMode = .parallel, + source: SourceContext = SourceContext(), + _ test: @escaping TestClosure + ) { + self.init(title, executionMode: executionMode, test: test, source: source) + } + + private var execution: TestExecution { + TestExecution( + title: title, + source: source, + setUp: setUp, + test: test, + tearDown: tearDown, + mode: executionMode + ) } /// Executes the test closure directly for an external test framework. @@ -202,60 +357,35 @@ public final class TestCase: ObservableObject, @unchecked Sendable { /// Swift Testing and XCTest adapters should prefer this awaited path because thrown expectation /// failures retain the external runner's native test context without polling observable UI state. public func execute() async throws { - do { - try await setUp?() - try await test() - } catch { - // Cleanup should still run after a failure; preserve the original failure when cleanup succeeds. - do { - try await tearDown?() - } catch { - debug("Test teardown also failed: \(error)", level: .ERROR) - } - throw error - } - try await tearDown?() + try await execution.perform() } - + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public func run() { if case .running = progress { return } - let setUp = self.setUp - let test = self.test - let tearDown = self.tearDown + + let execution = execution let weakSelf = WeakReference(self) progress = .running - // Run on the detached executor, then publish the result back on the main actor. WebAssembly's - // cooperative executor preserves the same actor semantics even when its host is single threaded. - Task.detached(priority: .userInitiated) { [setUp, test, tearDown, weakSelf] in + + Task.detached(priority: .userInitiated) { do { - do { - try await setUp?() - try await test() - } catch { - // Mirror execute() cleanup while keeping this detached UI path independent of self. - do { - try await tearDown?() - } catch { - debug("Test teardown also failed: \(error)", level: .ERROR) - } - throw error - } - try await tearDown?() + try await execution.perform() await MainActor.run { weakSelf.value?.progress = .pass } } catch { + let message = String(describing: error) + debug(message, level: .ERROR) await MainActor.run { - debug(error.localizedDescription, level: .ERROR) - weakSelf.value?.progress = .fail("\(error.localizedDescription)") + weakSelf.value?.progress = .fail(message) } } } } - + public func isFinished() -> Bool { switch progress { case .pass, .fail: @@ -277,7 +407,7 @@ public final class TestCase: ObservableObject, @unchecked Sendable { public var errorMessage: String? { progress.errorMessage } - + public var description: String { var errorString = "" if let errorMessage = progress.errorMessage { @@ -300,7 +430,7 @@ public extension TestCase { } } -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension TestCase { /// Every reusable Compatibility test, grouped in deterministic display and execution order. /// @@ -350,7 +480,7 @@ public extension TestCase { }() } -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension Compatibility { /// Compatibility's global test catalog. @MainActor diff --git a/Sources/Foundation/CodingMixedTypes.swift b/Sources/Foundation/CodingMixedTypes.swift index b054b6a..9cc89c2 100644 --- a/Sources/Foundation/CodingMixedTypes.swift +++ b/Sources/Foundation/CodingMixedTypes.swift @@ -194,7 +194,7 @@ public enum MixedTypeField: Equatable, Sendable, Hashable { } #if compiler(>=5.9) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension MixedTypeField { /// Shared value, formatting, and `Field` integration tests available to the in-app and Swift Testing runners. @MainActor diff --git a/Sources/Foundation/Date.swift b/Sources/Foundation/Date.swift index 201a540..b174781 100644 --- a/Sources/Foundation/Date.swift +++ b/Sources/Foundation/Date.swift @@ -209,7 +209,7 @@ public extension Date { // Testing is only supported with Swift 5.9+ #if compiler(>=5.9) && canImport(Foundation) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension Date { @MainActor static let tests = [ @@ -224,7 +224,7 @@ public extension Date { #if canImport(SwiftUI) import SwiftUI -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) #Preview { VStack { Text("\(String(describing: Date(from: "2023-01-02 17:12:00", format: "yyyy-MM-dd HH:mm:ss")))") @@ -233,7 +233,7 @@ import SwiftUI Text("\(String(describing: Date(from: "2023-01-02 17:12:00", format: "yyyy-MM-dd HH:mm:ss")?.pretty))") } } -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) #Preview("Tests") { TestsListView(tests: Date.tests) } diff --git a/Sources/Foundation/DateString.swift b/Sources/Foundation/DateString.swift index 0c92446..f3e4e45 100644 --- a/Sources/Foundation/DateString.swift +++ b/Sources/Foundation/DateString.swift @@ -180,7 +180,7 @@ public extension Date { try expect(Date(parse: "Jan 2, 2023")?.mysqlDate == "2023-01-02") try expect(Date(parse: "not a date") == nil) } - @available(macOS 12, *) + @available(macOS 10.15, *) @MainActor internal static let testFormatted: TestClosure = { let date = Date(from: "2023-01-02 17:12:00", format: .mysqlDateTimeFormat) diff --git a/Sources/Foundation/Double.swift b/Sources/Foundation/Double.swift index 6c3cfb3..8c161c4 100644 --- a/Sources/Foundation/Double.swift +++ b/Sources/Foundation/Double.swift @@ -293,7 +293,7 @@ public extension Double { // Testing is only supported with Swift 5.9+ #if compiler(>=5.9) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension Double { @MainActor static let tests = [ diff --git a/Sources/Foundation/String.swift b/Sources/Foundation/String.swift index 028e7af..c6bb787 100644 --- a/Sources/Foundation/String.swift +++ b/Sources/Foundation/String.swift @@ -534,14 +534,14 @@ public extension String { #endif return URL(string: self) } - +#endif + /// Get last "path" component of a string (basically everything from the last `/` to the end) var lastPathComponent: String { - let parts = self.components(separatedBy: "/") - let last = parts.last ?? self + // enables support on all platforms and handles Windows-style \ paths unlike the previous Foundation-only implementation. + let last = self.split(whereSeparator: { $0 == "/" || $0 == "\\" }).last.map(String.init) ?? self return last } -#endif /// `true` if the byte length of the `String` is larger than 100k (the exact threashold may change) var isLarge: Bool { diff --git a/Sources/UI/Backport.swift b/Sources/UI/Backport.swift index 96c63e1..f4e47ae 100644 --- a/Sources/UI/Backport.swift +++ b/Sources/UI/Backport.swift @@ -35,7 +35,7 @@ extension Backport where Content == Any { } } -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) extension Backport where Content == Any { @ViewBuilder public static func LabeledContent(_ titleKey: String, value: some StringProtocol) -> some View { if titleKey.count > 35 { diff --git a/Sources/UI/OverlappingStack.swift b/Sources/UI/OverlappingStack.swift index 875eadf..cd4a158 100644 --- a/Sources/UI/OverlappingStack.swift +++ b/Sources/UI/OverlappingStack.swift @@ -219,7 +219,7 @@ private struct OverlappingStack: Layout { } } -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) #Preview("OverlappingHStack") { VStack { Text("All of these should be the same height.") diff --git a/Sources/UI/Pasteboard.swift b/Sources/UI/Pasteboard.swift index 46048db..5e078c8 100644 --- a/Sources/UI/Pasteboard.swift +++ b/Sources/UI/Pasteboard.swift @@ -204,7 +204,7 @@ public extension Compatibility { } #if compiler(>=5.9) -@available(iOS 13, macOS 12, tvOS 13, watchOS 6, *) +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) extension Pasteboard { /// Deterministic pasteboard tests shared by the in-app runner and Swift Testing. @MainActor