diff --git a/CHANGELOG.md b/CHANGELOG.md index 78aeff7..b66000e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,15 @@ # Changelog +## v1.19.0 2026-08-15 +Added `Compatibility Testing Library` and `ModuleTestEntry` so reusable module `TestCase`s run as named parameterized Swift Testing cases in SwiftPM and Xcode. +Unified reusable test execution through one lifecycle with parallel/serialized modes, source-aware failures, and reliable cleanup of mutable debug settings. +Consolidated debug formatting and source-context handling, and removed unnecessary main-actor isolation from debug logging. +Corrected `main` so it can be called from any thread while only its closure is main-actor isolated; full-runtime WebAssembly now uses real Swift concurrency for main-actor scheduling. +Removed misleading WASM/Embedded fallbacks for `sleep`, `background`, and `delay`; these APIs are now unavailable there rather than silently providing incorrect semantics. `main` remains available on full-runtime WASM but is unavailable in Embedded Swift. +Improved the reusable test UI and Xcode/SwiftPM test integration, including parameterized Test Navigator results and unified unit/UI test execution. +Expanded contributor guidance for short, staged, maintainer-reviewed coding workflows. +Increased automated code coverage to XX%. + ## 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..fcefc0c 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -2,6 +2,7 @@ Compatibility prioritizes portability, backwards compatibility, clear public documentation, and reviewable changes. Contributors and coding agents should follow these repository-specific rules. +## Specific prompt reference (AIs should ignore this section and skip to the Interactive Coding Preferences section) PROMPT prefix for Xcode or another context without memory for projects using Compatibility: Follow the included Compatibility `CONTRIBUTING.md` (or github.com/kudit/Compatibility/CONTRIBUTING.md), preserve existing edits, then complete this request: [REQUEST] @@ -10,6 +11,21 @@ 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. +## Interactive Coding Preferences +When working interactively with a maintainer, generally (this shouldn't be meant to override thread instructions but are here as a default): +- If there is ever any conflict between instructions in a prompt, pause and clarify before continuing. +- Work in small, reviewable stages rather than delivering a large implementation all at once (unless specifically requested). +- 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 unless specifically asked for. +- Do not ever make up code or delete comments with instructions unless you've followed the instructions and made the changes. Instruction comments, TODOs, migration notes, and user-authored comments may not be removed unless the requested work is implemented and the comment is replaced with an accurate explanation or removed with explicit justification. +- Don't offer verbose explanations in the chat interface. Long explanations should not be necessary if code is well documented inline and should be included there to read inline with code changes during diff review. The chat interface should be for clarifying questions and high level discussion, answering questions, and providing high-level feedback. When working on code projects, extra text and explanation in the chat is not a good way to preserve information. Put next steps into an appropriate section of a markdown file like the CHANGELOG, put potential future ideas there, and architecture plans and roadmaps rather than in the chat itself. + + ## Version and changelog rules - Keep changelog entries in `## vX.X.X YYYY-MM-DD` format, with short line-separated notes under the current version. @@ -23,6 +39,7 @@ Review this Swift package for adoption of the Module APIs introduced in github.c - Modules should have separate `README.md` and `CHANGELOG.md` files. Final apps may keep a Changelog section in their README. - When you notice existing/manual uncommitted edits, please automatically generate and add changelog comments for the manual changes. + ## Post-prompt checklist After every prompt-driven change, contributors and coding agents must: @@ -34,6 +51,8 @@ After every prompt-driven change, contributors and coding agents must: 5. Refresh the active unpushed changelog date when work continues on a later date. 6. Append the complete prompt as `PROMPT: [PROMPT TEXT]`. 7. Review both the normal diff and an ignore-whitespace diff, remove unrelated or whitespace-only changes, run `git diff --check`, and run the repository's real build and tests. +8. For multi-file edits, patch each repository or external file separately. After every patch, verify the tool result, inspect the exact diff, run syntax checks, and search for the removed symbol or dependency. Never report the overall change as complete when any hunk failed or remains unverified. Always list changed files and show all deltas using a diff editor (if in Codex). + A full changelog outline may include: @@ -55,16 +74,19 @@ Planned features grouped by future version. - [ ] Longer-term ideas, experiments, and possible improvements. ``` + ## Code style - Preserve public identifiers, established behavior, compatibility paths, and user-visible syntax unless a breaking change is explicitly requested. - Keep changes tightly scoped and avoid unrelated reformatting or whitespace-only edits. -- Add clear inline comments explaining new or modified code and why compatibility-specific behavior is necessary. +- Add clear inline comments explaining new or modified code and why the change is necessary. +- Please make clear when code is not best practice or the obvious way of doing things particularly when you're making stylistic or judgement choices. - Add complete DocC comments to public APIs and to non-obvious internal APIs. - Preserve existing comments unless they are obsolete. - Use concise comments for obvious behavior and more detail around compatibility, migration, concurrency, and platform-specific decisions. - Prefer plain Markdown and code blocks for text intended to be pasted into files, GitHub, Xcode, or terminals. + ## Swift rules - Include `github.com/kudit/Compatibility` as a dependency in Swift projects and reuse its APIs where appropriate. @@ -86,6 +108,7 @@ Planned features grouped by future version. - Swift does not expose a general-purpose `hasFeature(Concurrency)` condition that proves a target has a scheduler, threads, Dispatch, or suspending timers. Use `canImport(Dispatch)` for Dispatch-backed implementations, availability checks for deployed Apple concurrency runtimes, `hasFeature(Embedded)` only for known Embedded restrictions, and narrowly documented platform checks for host facilities such as WebAssembly timers. - Do not gate `Equatable`, `Encodable`, or `Decodable` merely because a build targets Linux, Android, WASM, or WASI. Those protocols are part of full Swift runtimes. Before changing a conformance gate, also check whether the concrete type is locally owned, is a typealias to a Foundation type, already conforms on that Foundation implementation, or requires Swift 6's `@retroactive` ownership annotation. + ## Design goals - Backwards compatibility where practical. diff --git a/Development/Compatibility.xcodeproj/project.pbxproj b/Development/Compatibility.xcodeproj/project.pbxproj index 850eac0..5ddcda1 100644 --- a/Development/Compatibility.xcodeproj/project.pbxproj +++ b/Development/Compatibility.xcodeproj/project.pbxproj @@ -17,7 +17,6 @@ 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; }; - B594CFB72DB0BACA001E8658 /* Compatibility Library in Frameworks */ = {isa = PBXBuildFile; productRef = B594CFB62DB0BACA001E8658 /* Compatibility Library */; }; B5CB1E3C2C6BB1D300CF542B /* PrivacyInfo.xcprivacy in Resources */ = {isa = PBXBuildFile; fileRef = B51B70C42C5D6DBF001F7DCF /* PrivacyInfo.xcprivacy */; }; B5E5FC832C3863B9004F2009 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = B5E5FC822C3863B9004F2009 /* Assets.xcassets */; }; /* End PBXBuildFile section */ @@ -30,13 +29,6 @@ remoteGlobalIDString = B5E5FC502C386144004F2009; remoteInfo = CompatibilityTest; }; - B60000032F00000100000001 /* PBXContainerItemProxy */ = { - isa = PBXContainerItemProxy; - containerPortal = B50E7B632C385BD8002D3F53 /* Project object */; - proxyType = 1; - remoteGlobalIDString = B5E5FC502C386144004F2009; - remoteInfo = CompatibilityTest; - }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ @@ -48,7 +40,7 @@ B52C8E082C386F2D008EBD2D /* Package.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; name = Package.swift; path = ../Package.swift; sourceTree = ""; }; B52C8E0C2C3886E6008EBD2D /* Compatibility.swiftpm */ = {isa = PBXFileReference; lastKnownFileType = wrapper; name = Compatibility.swiftpm; path = ..; sourceTree = ""; }; B58B5C3F2C38F98800689837 /* CompatibilityTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = CompatibilityTest.app; sourceTree = BUILT_PRODUCTS_DIR; }; - B594CFA92DB0B838001E8658 /* CompatibilityTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = CompatibilityTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + B5DC66AB30310F3900FAFC9E /* CompatibilityTest.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = CompatibilityTest.xctestplan; sourceTree = ""; }; B5E5FC3A2C3860EC004F2009 /* MyApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = MyApp.swift; sourceTree = ""; }; B5E5FC3E2C3860EC004F2009 /* CHANGELOG.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; name = CHANGELOG.md; path = ../CHANGELOG.md; sourceTree = ""; }; B5E5FC442C3860EC004F2009 /* LICENSE.txt */ = {isa = PBXFileReference; lastKnownFileType = text; name = LICENSE.txt; path = ../LICENSE.txt; sourceTree = ""; }; @@ -57,18 +49,8 @@ B5E5FC822C3863B9004F2009 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; /* End PBXFileReference section */ -/* Begin PBXFileSystemSynchronizedBuildFileExceptionSet section */ - B594CFB92DB0BBC4001E8658 /* PBXFileSystemSynchronizedBuildFileExceptionSet */ = { - isa = PBXFileSystemSynchronizedBuildFileExceptionSet; - membershipExceptions = ( - CompatibilityTest.xctestplan, - ); - target = B594CFA82DB0B838001E8658 /* CompatibilityTests */; - }; -/* End PBXFileSystemSynchronizedBuildFileExceptionSet section */ - /* Begin PBXFileSystemSynchronizedRootGroup section */ - B5965FE52DB0B4FD00784140 /* CompatibilityTests */ = {isa = PBXFileSystemSynchronizedRootGroup; exceptions = (B594CFB92DB0BBC4001E8658 /* PBXFileSystemSynchronizedBuildFileExceptionSet */, ); explicitFileTypes = {}; explicitFolders = (); path = CompatibilityTests; sourceTree = ""; }; + B5965FE52DB0B4FD00784140 /* CompatibilityTests */ = {isa = PBXFileSystemSynchronizedRootGroup; explicitFileTypes = {}; explicitFolders = (); path = CompatibilityTests; sourceTree = ""; }; /* End PBXFileSystemSynchronizedRootGroup section */ /* Begin PBXFrameworksBuildPhase section */ @@ -87,14 +69,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B594CFA62DB0B838001E8658 /* Frameworks */ = { - isa = PBXFrameworksBuildPhase; - buildActionMask = 2147483647; - files = ( - B594CFB72DB0BACA001E8658 /* Compatibility Library in Frameworks */, - ); - runOnlyForDeploymentPostprocessing = 0; - }; B5E5FC4E2C386144004F2009 /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; @@ -117,6 +91,7 @@ B50E7B622C385BD8002D3F53 = { isa = PBXGroup; children = ( + B5DC66AB30310F3900FAFC9E /* CompatibilityTest.xctestplan */, B5E5FC3E2C3860EC004F2009 /* CHANGELOG.md */, B5E5FC452C3860EC004F2009 /* README.md */, B52C8E082C386F2D008EBD2D /* Package.swift */, @@ -133,7 +108,6 @@ children = ( B5E5FC512C386144004F2009 /* CompatibilityTest.app */, B58B5C3F2C38F98800689837 /* CompatibilityTest.app */, - B594CFA92DB0B838001E8658 /* CompatibilityTests.xctest */, B50707072C60A00100000001 /* CompatibilityUITests.xctest */, ); name = Products; @@ -211,27 +185,6 @@ productReference = B58B5C3F2C38F98800689837 /* CompatibilityTest.app */; productType = "com.apple.product-type.application"; }; - B594CFA82DB0B838001E8658 /* CompatibilityTests */ = { - isa = PBXNativeTarget; - buildConfigurationList = B594CFAF2DB0B838001E8658 /* Build configuration list for PBXNativeTarget "CompatibilityTests" */; - buildPhases = ( - B594CFA52DB0B838001E8658 /* Sources */, - B594CFA62DB0B838001E8658 /* Frameworks */, - B594CFA72DB0B838001E8658 /* Resources */, - ); - buildRules = ( - ); - dependencies = ( - B60000042F00000100000001 /* PBXTargetDependency */, - ); - name = CompatibilityTests; - packageProductDependencies = ( - B594CFB62DB0BACA001E8658 /* Compatibility Library */, - ); - productName = CompatibilityTests; - productReference = B594CFA92DB0B838001E8658 /* CompatibilityTests.xctest */; - productType = "com.apple.product-type.bundle.unit-test"; - }; B5E5FC502C386144004F2009 /* CompatibilityTest */ = { isa = PBXNativeTarget; buildConfigurationList = B5E5FC5D2C386145004F2009 /* Build configuration list for PBXNativeTarget "CompatibilityTest" */; @@ -269,10 +222,6 @@ B58B5C3E2C38F98800689837 = { CreatedOnToolsVersion = 15.4; }; - B594CFA82DB0B838001E8658 = { - CreatedOnToolsVersion = 16.3; - TestTargetID = B5E5FC502C386144004F2009; - }; B5E5FC502C386144004F2009 = { CreatedOnToolsVersion = 15.4; }; @@ -296,7 +245,6 @@ targets = ( B5E5FC502C386144004F2009 /* CompatibilityTest */, B58B5C3E2C38F98800689837 /* CompatibilityTest Watch App */, - B594CFA82DB0B838001E8658 /* CompatibilityTests */, B50707012C60A00100000001 /* CompatibilityUITests */, ); }; @@ -319,13 +267,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B594CFA72DB0B838001E8658 /* Resources */ = { - isa = PBXResourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; B5E5FC4F2C386144004F2009 /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; @@ -356,13 +297,6 @@ ); runOnlyForDeploymentPostprocessing = 0; }; - B594CFA52DB0B838001E8658 /* Sources */ = { - isa = PBXSourcesBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - runOnlyForDeploymentPostprocessing = 0; - }; B5E5FC4D2C386144004F2009 /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; @@ -380,11 +314,6 @@ target = B5E5FC502C386144004F2009 /* CompatibilityTest */; targetProxy = B60000012F00000100000001 /* PBXContainerItemProxy */; }; - B60000042F00000100000001 /* PBXTargetDependency */ = { - isa = PBXTargetDependency; - target = B5E5FC502C386144004F2009 /* CompatibilityTest */; - targetProxy = B60000032F00000100000001 /* PBXContainerItemProxy */; - }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ @@ -396,7 +325,7 @@ DEVELOPMENT_TEAM = 3QPV894C33; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; PRODUCT_BUNDLE_IDENTIFIER = com.kudit.CompatibilityTest.UITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -416,7 +345,7 @@ DEVELOPMENT_TEAM = 3QPV894C33; GENERATE_INFOPLIST_FILE = YES; IPHONEOS_DEPLOYMENT_TARGET = 18.4; - MACOSX_DEPLOYMENT_TARGET = 11.0; + MACOSX_DEPLOYMENT_TARGET = 14.6; PRODUCT_BUNDLE_IDENTIFIER = com.kudit.CompatibilityTest.UITests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; @@ -488,7 +417,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 12.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 1.18.2; + MARKETING_VERSION = 1.19.0; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; @@ -559,7 +488,7 @@ IPHONEOS_DEPLOYMENT_TARGET = 12.0; LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MACOSX_DEPLOYMENT_TARGET = 10.15; - MARKETING_VERSION = 1.18.2; + MARKETING_VERSION = 1.19.0; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; OTHER_SWIFT_FLAGS = ""; @@ -630,53 +559,6 @@ }; name = Release; }; - B594CFAD2DB0B838001E8658 /* Debug */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_ENTITLEMENTS = ""; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 3QPV894C33; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; - MACOSX_DEPLOYMENT_TARGET = 11.0; - PRODUCT_BUNDLE_IDENTIFIER = com.kudit.CompatibilityTest.Tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CompatibilityTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CompatibilityTest"; - TEST_TARGET_NAME = CompatibilityTest; - TVOS_DEPLOYMENT_TARGET = 13.0; - }; - name = Debug; - }; - B594CFAE2DB0B838001E8658 /* Release */ = { - isa = XCBuildConfiguration; - buildSettings = { - BUNDLE_LOADER = "$(TEST_HOST)"; - CODE_SIGN_ENTITLEMENTS = ""; - CODE_SIGN_STYLE = Automatic; - DEVELOPMENT_TEAM = 3QPV894C33; - GENERATE_INFOPLIST_FILE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 18.4; - MACOSX_DEPLOYMENT_TARGET = 11.0; - PRODUCT_BUNDLE_IDENTIFIER = com.kudit.CompatibilityTest.Tests; - PRODUCT_NAME = "$(TARGET_NAME)"; - SDKROOT = auto; - SUPPORTED_PLATFORMS = "appletvos appletvsimulator iphoneos iphonesimulator macosx xros xrsimulator"; - SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_VERSION = 5.0; - TARGETED_DEVICE_FAMILY = "1,2,3"; - TEST_HOST = "$(BUILT_PRODUCTS_DIR)/CompatibilityTest.app/$(BUNDLE_EXECUTABLE_FOLDER_PATH)/CompatibilityTest"; - TEST_TARGET_NAME = CompatibilityTest; - TVOS_DEPLOYMENT_TARGET = 13.0; - VALIDATE_PRODUCT = YES; - }; - name = Release; - }; B5E5FC5E2C386145004F2009 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { @@ -781,15 +663,6 @@ defaultConfigurationIsVisible = 0; defaultConfigurationName = Release; }; - B594CFAF2DB0B838001E8658 /* Build configuration list for PBXNativeTarget "CompatibilityTests" */ = { - isa = XCConfigurationList; - buildConfigurations = ( - B594CFAD2DB0B838001E8658 /* Debug */, - B594CFAE2DB0B838001E8658 /* Release */, - ); - defaultConfigurationIsVisible = 0; - defaultConfigurationName = Release; - }; B5E5FC5D2C386145004F2009 /* Build configuration list for PBXNativeTarget "CompatibilityTest" */ = { isa = XCConfigurationList; buildConfigurations = ( @@ -819,11 +692,6 @@ package = B52C8E0D2C3886E6008EBD2D /* XCLocalSwiftPackageReference ".." */; productName = "Compatibility Library"; }; - B594CFB62DB0BACA001E8658 /* Compatibility Library */ = { - isa = XCSwiftPackageProductDependency; - package = B52C8E0D2C3886E6008EBD2D /* XCLocalSwiftPackageReference ".." */; - productName = "Compatibility Library"; - }; /* End XCSwiftPackageProductDependency section */ }; rootObject = B50E7B632C385BD8002D3F53 /* Project object */; diff --git a/Development/Compatibility.xcodeproj/xcshareddata/xcschemes/CompatibilityTest.xcscheme b/Development/Compatibility.xcodeproj/xcshareddata/xcschemes/CompatibilityTest.xcscheme index 6ef5942..19a315b 100644 --- a/Development/Compatibility.xcodeproj/xcshareddata/xcschemes/CompatibilityTest.xcscheme +++ b/Development/Compatibility.xcodeproj/xcshareddata/xcschemes/CompatibilityTest.xcscheme @@ -21,6 +21,20 @@ ReferencedContainer = "container:Compatibility.xcodeproj"> + + + + diff --git a/Development/CompatibilityDemoView.swift b/Development/CompatibilityDemoView.swift index 8a18677..59d90fe 100644 --- a/Development/CompatibilityDemoView.swift +++ b/Development/CompatibilityDemoView.swift @@ -7,6 +7,7 @@ #if canImport(SwiftUI) && compiler(>=5.9) && canImport(Foundation) import SwiftUI +import Foundation import Compatibility final class DemoFailureCounter: @unchecked Sendable { @@ -48,53 +49,75 @@ struct CompatibilityDemoView: View { ] ] - var body: some View { - TabView { - if #available(watchOS 9, *) { - CompatibilityEnvironmentTestView() - .tabItem { - Text("Compatibility") - } - DataStoreTestView() - .tabItem { - Text("DataStore") - } - } - // Application tracking has already registered the complete ordered module graph consumed here. - AllTestsListView(additionalTests: Self.additionalTests) - .tabItem { - Text("All Tests") - } - ClosureTestView() - .tabItem { - Text("Closure") - } - RandomBytesTestView() - .tabItem { - Text("Random Bytes") - } - ConvertTestView() - .tabItem { - Text("Convert") - } - TriangleShowcaseView() - .tabItem { - Text("Triangle Showcase") - } - FillAndStrokeTest() - .tabItem { - Text("Fill & Stroke") - } - PlacardShowcaseView() + @ViewBuilder + private var demoTabs: some View { + if #available(watchOS 9, *) { + CompatibilityEnvironmentTestView() + .accessibilityIdentifier("demo.compatibility") .tabItem { - Text("Placard Showcase") + Text("Compatibility") } - MaterialTestView() + DataStoreTestView() + .accessibilityIdentifier("demo.datastore") .tabItem { - Text("Material") + Text("DataStore") } } - .backport.tabViewStyle(.page) + // Application tracking has already registered the complete ordered module graph consumed here. + AllTestsListView(additionalTests: Self.additionalTests) + .accessibilityIdentifier("demo.allTests") + .tabItem { + Text("All Tests") + } + ClosureTestView() + .accessibilityIdentifier("demo.closure") + .tabItem { + Text("Closure") + } + RandomBytesTestView() + .accessibilityIdentifier("demo.randomBytes") + .tabItem { + Text("Random Bytes") + } + ConvertTestView() + .accessibilityIdentifier("demo.convert") + .tabItem { + Text("Convert") + } + TriangleShowcaseView() + .accessibilityIdentifier("demo.triangle") + .tabItem { + Text("Triangle Showcase") + } + FillAndStrokeTest() + .accessibilityIdentifier("demo.fillAndStroke") + .tabItem { + Text("Fill & Stroke") + } + PlacardShowcaseView() + .accessibilityIdentifier("demo.placard") + .tabItem { + Text("Placard Showcase") + } + MaterialTestView() + .accessibilityIdentifier("demo.material") + .tabItem { + Text("Material") + } + } + + var body: some View { + TabView { + demoTabs + }.closure { view in +#if os(macOS) + // Use SwiftUI's native macOS tab presentation. Page style collapses many pages behind a + // Navigation Tab Bar menu, which is less useful for this desktop test/demo application. + view +#else + view.backport.tabViewStyle(.page) +#endif + } } } diff --git a/Development/CompatibilityTests/CompatibilityTest.xctestplan b/Development/CompatibilityTest.xctestplan similarity index 77% rename from Development/CompatibilityTests/CompatibilityTest.xctestplan rename to Development/CompatibilityTest.xctestplan index affa57b..db16326 100644 --- a/Development/CompatibilityTests/CompatibilityTest.xctestplan +++ b/Development/CompatibilityTest.xctestplan @@ -1,7 +1,7 @@ { "configurations" : [ { - "id" : "2E2FC559-E6BB-4797-9D40-0CD8EC7E9991", + "id" : "45A88BEA-0855-421E-9A28-2A4812BED761", "name" : "Test Scheme Action", "options" : { @@ -10,6 +10,7 @@ ], "defaultOptions" : { "codeCoverage" : true, + "performanceAntipatternCheckerEnabled" : true, "targetForVariableExpansion" : { "containerPath" : "container:Compatibility.xcodeproj", "identifier" : "B5E5FC502C386144004F2009", @@ -20,15 +21,15 @@ { "target" : { "containerPath" : "container:Compatibility.xcodeproj", - "identifier" : "B594CFA82DB0B838001E8658", - "name" : "CompatibilityTests" + "identifier" : "B50707012C60A00100000001", + "name" : "CompatibilityUITests" } }, { "target" : { - "containerPath" : "container:Compatibility.xcodeproj", - "identifier" : "B50707012C60A00100000001", - "name" : "CompatibilityUITests" + "containerPath" : "container:..", + "identifier" : "CompatibilityTests", + "name" : "CompatibilityTests" } } ], 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..1593084 --- /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(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/Development/CompatibilityUITests/CompatibilityUITests.swift b/Development/CompatibilityUITests/CompatibilityUITests.swift index 768658a..08e99fc 100644 --- a/Development/CompatibilityUITests/CompatibilityUITests.swift +++ b/Development/CompatibilityUITests/CompatibilityUITests.swift @@ -30,62 +30,159 @@ private extension XCUIElement { var backport: XCUIElementBackport { XCUIElementBackport(element: self) } } -/// Smoke tests for the Compatibility demo application. +/// UI coverage for the Compatibility demo application. /// -/// These tests intentionally launch the real demo app instead of constructing -/// views directly so Xcode coverage sees the SwiftUI app, scene, tab container, -/// and first visible content path as user-facing code. +/// The app launches once and the test advances through the real `TabView` in declaration order. +/// This deliberately avoids numeric selection tags so inserting or rearranging demo tabs does not require +/// keeping a second set of tab indices synchronized. Navigation follows the native platform presentation: +/// direct tab selection on macOS, page gestures on touch platforms, and remote navigation on tvOS. final class CompatibilityUITests: XCTestCase { + private struct DemoScreen { + let name: String + let identifier: String + } + + private let screens = [ + DemoScreen(name: "Compatibility", identifier: "demo.compatibility"), + DemoScreen(name: "DataStore", identifier: "demo.datastore"), + DemoScreen(name: "All Tests", identifier: "demo.allTests"), + DemoScreen(name: "Closure", identifier: "demo.closure"), + DemoScreen(name: "Random Bytes", identifier: "demo.randomBytes"), + DemoScreen(name: "Convert", identifier: "demo.convert"), + DemoScreen(name: "Triangle Showcase", identifier: "demo.triangle"), + DemoScreen(name: "Fill & Stroke", identifier: "demo.fillAndStroke"), + DemoScreen(name: "Placard Showcase", identifier: "demo.placard"), + DemoScreen(name: "Material", identifier: "demo.material"), + ] + override func setUpWithError() throws { continueAfterFailure = false } @MainActor - func testDemoAppLaunchesAndShowsCompatibilityContent() throws { + func testEveryDemoScreenAndRepresentativeInteractions() async throws { let app = XCUIApplication() - - // Ignore saved state so the smoke test starts from the same first tab - // even when Xcode or a previous manual run restored another demo page. app.launchArguments += ["-ApplePersistenceIgnoreState", "YES"] - // UI tests run out of process, so explicitly pass the generic testing environment to the app under test. app.launchEnvironment["TESTING"] = "1" app.launch() - XCTAssertTrue(app.wait(for: .runningForeground, timeout: 15), "CompatibilityTest app should launch into the foreground.") + for (index, screen) in screens.enumerated() { + let screenElement = app.descendants(matching: .any)[screen.identifier] + let rendered = await waitForElement(screenElement, timeout: 10) + XCTAssertTrue(rendered, "\(screen.name) should render its demo screen.") - // The first tab renders environment/application sections, which proves - // the app scene and core Compatibility SwiftUI demo path are visible. - XCTAssertTrue(waitForAnyText(["Application", "Compatibility", "iCloud"], in: app), "The Compatibility demo should show its first-page sections.") + await exercise(screenAt: index, in: app) - // Tapping exposed tab labels exercises additional demo pages on - // platforms where SwiftUI exposes the page/tab controls to UI testing. - for tabName in ["DataStore", "All Tests", "Closure", "Random Bytes", "Convert"] { - tapIfPresent(tabName, in: app) + if index < screens.count - 1 { + await navigate(to: screens[index + 1], in: app) + } } } @MainActor - private func waitForAnyText(_ labels: [String], in app: XCUIApplication, timeout: TimeInterval = 10) -> Bool { - for label in labels { - if app.staticTexts[label].waitForExistence(timeout: timeout) { - return true + private func exercise(screenAt index: Int, in app: XCUIApplication) async { + switch index { + case 0, 1, 4, 7, 8, 9: + // These pages are primarily exercised by rendering. Keep the UI tour fast and avoid + // synthetic scrolling where there is no behavior we specifically need to validate. + break + + case 2: + // The test list is long enough that a few gestures are useful for rendering off-screen rows, + // but traversing the entire list adds time without meaningfully improving this smoke test. + scrollThroughAllTests(in: app) + + case 3: + // Open the real menu when exposed so Menu callbacks and menu-item construction are covered. + let symbols = app.buttons["Symbols"] + if await waitForElement(symbols, timeout: 2), symbols.isHittable { + symbols.backport.tap() + let star = app.buttons["star"] + if await waitForElement(star, timeout: 2), star.isHittable { + star.backport.tap() + } + } + + case 5: + // Exercise Binding.convert through the Convert screen's slider. + let slider = app.sliders.firstMatch + if await waitForElement(slider, timeout: 2) { + slider.adjust(toNormalizedSliderPosition: 0.75) } + + case 6: + // Exercise Triangle drawing and navigationDestination, then return to the showcase. + let button = app.buttons.firstMatch + if await waitForElement(button, timeout: 2), button.isHittable { + button.backport.tap() + let destination = app.buttons["Navigation Destination TestCase"] + if await waitForElement(destination, timeout: 2), destination.isHittable { + destination.backport.tap() + } + } + + default: + XCTFail("Unexpected Compatibility demo screen index: \(index)") } - return false } @MainActor - private func tapIfPresent(_ label: String, in app: XCUIApplication) { - let button = app.buttons[label] - if button.waitForExistence(timeout: 1) { - button.backport.tap() - return + private func navigate(to screen: DemoScreen, in app: XCUIApplication) async { +#if os(macOS) + // Native macOS TabView exposes its tabs directly to accessibility. Query by the visible + // tab name instead of depending on a particular AppKit control class. + let tab = app.descendants(matching: .any)[screen.name] + let found = await waitForElement(tab, timeout: 5) + XCTAssertTrue(found, "macOS should expose the \(screen.name) tab.") + guard found else { return } + XCTAssertTrue(tab.isHittable, "The \(screen.name) tab should be directly selectable.") + if tab.isHittable { + tab.backport.tap() + } +#elseif os(tvOS) + XCUIRemote.shared.press(.right) +#else + app.swipeLeft() +#endif + } + + @MainActor + private func waitForElement(_ element: XCUIElement, timeout: TimeInterval) async -> Bool { + let deadline = Date().addingTimeInterval(timeout) + repeat { + if element.exists { + return true + } + if Date() >= deadline { + return false + } + // Yield the main actor instead of calling XCTest's synchronous waitForExistence(timeout:), + // which the performance diagnostics correctly flag as blocking UI responsiveness. + try? await Task.sleep(nanoseconds: 100_000_000) + } while true + } + + @MainActor + private func scrollThroughAllTests(in app: XCUIApplication) { + let scrollView = app.scrollViews.firstMatch + let table = app.tables.firstMatch + let collection = app.collectionViews.firstMatch + + let scrollable: XCUIElement + if scrollView.exists { + scrollable = scrollView + } else if table.exists { + scrollable = table + } else if collection.exists { + scrollable = collection + } else { + scrollable = app } - let text = app.staticTexts[label] - if text.waitForExistence(timeout: 1) { - text.backport.tap() + for _ in 0..<3 { + scrollable.swipeUp() } + scrollable.swipeDown() } } #endif diff --git a/Package.swift b/Package.swift index de0d7b2..e0c7c0c 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.19.0" 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,11 +153,11 @@ 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 - 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. - exclude: ["CompatibilityTest.xctestplan"] + 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" ), ] #endif diff --git a/Sources/Compatibility.swift b/Sources/Compatibility.swift index 90cef8c..27fa662 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.19.0" /// Public source repository for Compatibility so support reports can direct developers to its source and issue history. /// @@ -77,8 +77,16 @@ public enum Compatibility: Module { /* + Apple Platform / Swift Generation Reference + + This is primarily a developer reference for choosing useful compile-time + generation checks. `canImport(...)` confirms that a module exists in the + current SDK/toolchain; it does NOT prove that the running OS satisfies the + framework's deployment availability. Use `#available(...)` for runtime + availability and explicit compiler checks for Swift-language features. + For module checks to conditionally compile for versions: - + canImport(StoreKit) iOS 3.0+ iPadOS 3.0+ @@ -163,22 +171,23 @@ public enum Compatibility: Module { visionOS 1.0+ SF Symbols 4.0 - 2023 Swift 5.8 (March), Swift 5.9 (September) (added #Preview syntax and @availability syntax) - canImport(SwiftData) + 2023 Swift 5.8 (March), Swift 5.9 (September) (added #Preview syntax, @availability syntax, and macros) + canImport(SwiftData) iOS 17+ iPadOS 17.0+ - macOS 14+ + macOS 14+ Sonoma Mac Catalyst 17.0+ tvOS 17+ watchOS 10+ (practical minimum for WidgetKit (due to requirement of WidgetConfigurationIntent which is only available on iOS 17, macOS 14, and watchOS 10) visionOS 1.0+ SF Symbols 5.0 + Xcode 15 2024 Swift 5.10 (March), Swift 6 (September) -canImport(Testing) + canImport(Testing) // earliest Embedded Swift versions iOS 18+ iPadOS 18+ - macOS 15+ + macOS 15+ Sequoia Mac Catalyst 18+ tvOS 18+ watchOS 11+ @@ -189,9 +198,10 @@ canImport(Testing) Swift Playgrounds 4.6.4 - Swift 6.0 Compiler 2025 Swift 6.1 (March), Swift 6.2 (September) + Apple platform version numbers synchronized! iOS 26+ iPadOS 26+ - macOS 26+ + macOS 26+ Tahoe Mac Catalyst 26+ tvOS 26+ watchOS 26+ @@ -199,7 +209,10 @@ canImport(Testing) SF Symbols 7.0 Xcode 26 - In Swift 6.2, Foundation is not available in WASM + 2026 Swift 6.3 (March), Swift 6.4? (September) + xOS 27+ Golden Gate + SF Symbols 8.0 + Xcode 27 */ // MARK: - Configuration 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/Application.swift b/Sources/Core/Application.swift index cd4f079..e6fa1aa 100644 --- a/Sources/Core/Application.swift +++ b/Sources/Core/Application.swift @@ -156,34 +156,35 @@ public class Application: ObservableObject { // The private initializer preserve /// Place this in `application(_:didFinishLaunchingWithOptions:)` or the `@main` type's initializer. /// Compatibility is always registered automatically. Pass only the highest-level modules used directly /// by the application; their ``Module/dependencies`` are discovered recursively. - /// - /// - Parameters: - /// - modules: Top-level modules used by the application. - /// - file: Source file that initiated tracking. - /// - function: Source function that initiated tracking. - /// - line: Source line that initiated tracking. - /// - column: Source column that initiated tracking. public static func track(including modules: [Module.Type] = [], file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) { + track( + including: modules, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Source-forwarding form for callers that have already captured their own call site. + public static func track(including modules: [Module.Type] = [], source: SourceContext) { // Compatibility supplies Application itself, so it belongs in every tracked application's module report. Compatibility.include() Build.register(modules) // Prevent late mutation once asynchronous support reporting can begin reading the global registry. Build.finishModuleRegistration() // Calling Application.main is what initializes the application and does the tracking. This really should only be called once. TODO: Should we check to make sure this isn't called twice?? Application.main singleton should only be inited once. - debug("Application Tracking: \(Application.main.appName)", level: .NOTICE, file: file, function: function, line: line, column: column) // Initialize persisted version state synchronously before detached reporting begins. + Compatibility.debug("Application Tracking: \(Application.main.appName)", level: .NOTICE, source: source) // Initialize persisted version state synchronously before detached reporting begins. // Defer the complete report so modules may calculate or fetch metadata without blocking application launch. #if arch(wasm32) // Full-runtime WebAssembly supports unstructured tasks, but the detached // convenience wrappers require host scheduling facilities. Task { @MainActor in let description = await Application.main.loadDetailedDescription() - debug("Application Detailed Tracking:\n\(description)", level: .NOTICE, file: file, function: function, line: line, column: column) + debug("Application Detailed Tracking:\n\(description)", level: .NOTICE, source: source) } #else Task.background { let description = await Application.main.loadDetailedDescription() Task.main { - debug("Application Detailed Tracking:\n\(description)", level: .NOTICE, file: file, function: function, line: line, column: column) + Compatibility.debug("Application Detailed Tracking:\n\(description)", level: .NOTICE, source: source) } } #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..e1c53e2 100644 --- a/Sources/Core/Debug.swift +++ b/Sources/Core/Debug.swift @@ -1,6 +1,45 @@ +// 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 + } +} + +/// Structured debug formatter. New formatting options can be added to `DebugFormatContext` +/// without expanding a positional closure signature. +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 @@ -14,7 +53,7 @@ public struct CompatibilityConfiguration: PropertyIterable { /// Set this to a set of levels where we should include the context info. Defaults to `.important` so that `NOTICE` and `DEBUG` messages are less noisy and easier to see. Set this to `.none` to make `debug()` act exactly like `print()` at all levels. public var debugLevelsToIncludeContext = DebugLevels.important - /// Set whether timestamps should be included in debug messages. If you need to customize the format of timestamps, use the `debugFormat()` override. + /// Set whether timestamps should be included in debug messages. If you need to customize the format of timestamps, use the `debugFormatter` override. @available(*, deprecated, renamed: "debugLevelsToIncludeTimestamp", message: "Set `debugLevelsToIncludeTimestamp` instead.") public var debugIncludeTimestamp: Bool { get { @@ -25,32 +64,72 @@ public struct CompatibilityConfiguration: PropertyIterable { } } public var debugLevelsToIncludeTimestamp = DebugLevels.none - - /// Generates string with context. Set level to `.OFF` to just return the context without the message portion. - public var debugFormat = { (message: String, level: DebugLevel, isMainThread: Bool, emojiSupported: Bool, includeContext: Bool, includeTimestamp: Bool, file: String, function: String, line: Int, column: Int) -> String in - let message = "\(emojiSupported ? level.emoji : level.symbol) \(message)" + + /// Preferred structured formatter used by all normal debug output. + public var debugFormatter: DebugFormatter = { context in + let message = "\(context.emojiSupported ? context.level.emoji : context.level.symbol) \(context.message)" var timestamp = "" - if includeTimestamp { + if context.includeTimestamp { #if canImport(Foundation) timestamp = "\(Date.nowBackport.mysqlDateTime): " #else timestamp = "UNABLE TO GET TIMESTAMP WITHOUT Foundation.Date: " #endif } - if includeContext { - let threadInfo = isMainThread ? "" : "^" + if context.includeContext { + let threadInfo = context.isMainThread ? "" : "^" #if canImport(Foundation) - let simplerFile = URL(fileURLWithPath: file).lastPathComponent - let simplerFunction = function.replacingOccurrences(of: "__preview__", with: "_p_") + let simplerFile = URL(fileURLWithPath: context.source.file).lastPathComponent + let simplerFunction = context.source.function.replacingOccurrences(of: "__preview__", with: "_p_") #else - let simplerFile = "\(file)".components(separatedBy: "/").last ?? "UNABLE TO GET LAST PATH COMPONENT WITHOUT Foundation.URL" - let simplerFunction = function + let simplerFile = "\(context.source.file)".components(separatedBy: "/").last ?? "UNABLE TO GET LAST PATH COMPONENT WITHOUT Foundation.URL" + let simplerFunction = context.source.function #endif - return "\(timestamp)\(simplerFile)(\(line)) : \(simplerFunction)\(threadInfo)\(level == .OFF ? "" : "\n\(message)")" + return "\(timestamp)\(simplerFile)(\(context.source.line)) : \(simplerFunction)\(threadInfo)\(context.level == .OFF ? "" : "\n\(message)")" } else { return "\(timestamp)\(message)" } } + + /// Legacy positional formatter retained for source compatibility. + /// + /// New code should use `debugFormatter`, whose labeled context can grow without changing + /// the closure's function type or forcing every formatter assignment to update. + @available(*, deprecated, message: "Use debugFormatter with DebugFormatContext instead.") + public var debugFormat: (String, DebugLevel, Bool, Bool, Bool, Bool, String, String, Int, Int) -> String { + get { + let formatter = debugFormatter + return { message, level, isMainThread, emojiSupported, includeContext, includeTimestamp, file, function, line, column in + formatter( + DebugFormatContext( + message: message, + level: level, + isMainThread: isMainThread, + emojiSupported: emojiSupported, + includeContext: includeContext, + includeTimestamp: includeTimestamp, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + ) + } + } + set { + debugFormatter = { context in + newValue( + context.message, + context.level, + context.isMainThread, + context.emojiSupported, + context.includeContext, + context.includeTimestamp, + context.source.file, + context.source.function, + context.source.line, + context.source.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 +193,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 { @@ -238,74 +317,76 @@ 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.") +/// Generates context string. +@available(*, deprecated, message: "Use Compatibility.settings.debugFormatter with DebugFormatContext instead.") public func debugContext(isMainThread: Bool, file: String, function: String, line: Int, column: Int) -> String { - Compatibility.settings.debugFormat( - "", - .OFF, - isMainThread, - Compatibility.settings.debugEmojiSupported, - true, - Compatibility.settings.debugIncludeTimestamp, - file, function, line, column) + Compatibility.settings.debugFormatter( + DebugFormatContext( + message: "", + level: .OFF, + isMainThread: isMainThread, + emojiSupported: Compatibility.settings.debugEmojiSupported, + includeContext: true, + includeTimestamp: Compatibility.settings.debugLevelsToIncludeTimestamp.contains(.OFF), + source: SourceContext(file: file, function: function, line: line, column: 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 - - - 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. - - Parameter function: For bubbling down the #function name from a call site. - - 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 - let isMainThread = true -#endif - let message = String(describing: message) // convert to sendable item to avoid any thread issues. - - return debug(message, isMainThread: isMainThread, level: level, file: file, function: function, line: line, column: column) - } -#endif - /// Put most of the business logic here for compatibility with WASM. isMainThread: is required to differentiate but can be removed in global definition + /// Canonical debug implementation for APIs that have already captured their caller's source context. + /// + /// Normal application code should generally use the unqualified ``debug(_:level:file:function:line:column:)`` + /// convenience below. Helper APIs that intentionally preserve their own caller's source location can capture + /// a ``SourceContext`` once and forward it here. @discardableResult - static func debug(_ message: String, isMainThread: Bool, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { + static func debug(_ message: DebugMessage, level: DebugLevel = .defaultLevel, source: SourceContext) -> String { guard DebugLevel.isAtLeast(level) else { // check current debug level from settings return "" // don't actually print } - let debugMessage = Compatibility.settings.debugFormat( - message, - level, - isMainThread, - Compatibility.settings.debugEmojiSupported, - Compatibility.settings.debugLevelsToIncludeContext.contains(level), - Compatibility.settings.debugLevelsToIncludeTimestamp.contains(level), - file, function, line, column) - - // log message + +#if hasFeature(Embedded) || !canImport(Foundation) + // Embedded/Foundation-less runtimes do not expose Foundation.Thread identity. Their supported + // execution model is treated as main-thread work rather than accepting a manually supplied override. + let isMainThread = true +#else + let isMainThread = Thread.isMainThread // capture before any logger/formatter implementation can switch threads +#endif + +#if hasFeature(Embedded) + // Embedded Swift already narrows `DebugMessage` to `String`, so no dynamic conversion is needed. + let messageString = message +#else + // Full Swift runtimes allow `DebugMessage == Any`; stringify exactly once before formatting/logging. + let messageString = String(describing: message) +#endif + + let debugMessage = Compatibility.settings.debugFormatter( + DebugFormatContext( + message: messageString, + level: level, + isMainThread: isMainThread, + emojiSupported: Compatibility.settings.debugEmojiSupported, + includeContext: Compatibility.settings.debugLevelsToIncludeContext.contains(level), + includeTimestamp: Compatibility.settings.debugLevelsToIncludeTimestamp.contains(level), + source: source + ) + // possible future hook to log message + ) + Compatibility.settings.debugLog(debugMessage) - + // do this AFTER Printing so we can see what the message is in the console checkBreakpoint(level: level) - + return debugMessage } } //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,39 +394,34 @@ 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 { - return Compatibility.debug(message, level: level, file: file, function: function, line: line, column: column) +public func debug(_ message: DebugMessage, level: DebugLevel = .defaultLevel, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) -> String { + Compatibility.debug( + message, + level: level, + source: SourceContext(file: file, function: function, line: line, column: column) + ) } -#else -@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) -} -#endif // MARK: Debug(error) // This is to provide debugging at calltime when creating errors. public extension Error { /** Outputs the error's localized description at the specified debug level and return. Can append to errors to debug output at the throwing location rather than the caught location. - - - Parameter level: The logging level to use. - - Parameter file: For bubbling down the #file name from a call site. - - Parameter function: For bubbling down the #function name from a call site. - - 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). */ 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 + debug( + level: level, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Package-internal source-forwarding form used after a helper has already captured its caller. + internal func debug(level: DebugLevel = .defaultLevel, source: SourceContext) -> Self { + Compatibility.debug(self.localizedDescription, level: level, source: source) return self } + #if !canImport(Foundation) var localizedDescription: String { "There was an error but without Foundation, we're using the default `localizedDescription`." @@ -353,16 +429,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 @@ -374,20 +468,19 @@ public extension DebugLevel { try expect(Compatibility.settings.debugLevelDefault == .WARNING, "expected default debug level to be .WARNING but found \(Compatibility.settings.debugLevelDefault)") Compatibility.settings.debugEmojiSupported = false // testing symbols -// Compatibility.settings.debugIncludeTimestamp = true // test deprecated code + // Compatibility.settings.debugIncludeTimestamp = true // test deprecated code Compatibility.settings.debugLevelsToIncludeTimestamp = .all // test timestamps - let defaultFormat = Compatibility.settings.debugFormat - Compatibility.settings.debugFormat = { (message: String, level: DebugLevel, isMainThread: Bool, emojiSupported: Bool, includeContext: Bool, includeTimestamp: Bool, file: String, function: String, line: Int, column: Int) -> String in - - let defaultOutput = defaultFormat(message, level, isMainThread, emojiSupported, includeContext, includeTimestamp, file, function, line, column) + let defaultFormatter = Compatibility.settings.debugFormatter + Compatibility.settings.debugFormatter = { context in + let defaultOutput = defaultFormatter(context) return """ -Message: \(message) -Level: \(level) -isMainThread: \(isMainThread) -emojiSupported: \(emojiSupported) -includeContext: \(includeContext) -includeTimestamp: \(includeTimestamp) -file: \(file) +Message: \(context.message) +Level: \(context.level) +isMainThread: \(context.isMainThread) +emojiSupported: \(context.emojiSupported) +includeContext: \(context.includeContext) +includeTimestamp: \(context.includeTimestamp) +file: \(context.source.file) Normal output: \(defaultOutput) """ } @@ -404,10 +497,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 +530,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/Network.swift b/Sources/Core/Network.swift index 14c3c21..f6b329e 100644 --- a/Sources/Core/Network.swift +++ b/Sources/Core/Network.swift @@ -162,17 +162,23 @@ extension URLRequest { } extension Compatibility { - /// Fetch data from URL including optional postData. Will report included file information and automatically debug output to the logs. + /// Fetch data from URL including optional postData. Will report the original caller in debug output. @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) // for concurrency public static func fetchURLData(urlString: String, postData: PostData? = nil, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) async throws -> Data { -#if !hasFeature(Embedded) - debug("Fetching URL [\(urlString)]...", level: .NOTICE, file: file, function: function, line: line, column: column) -#else - debug("Fetching URL [\(urlString)]...", isMainThread: false, file: file, function: function, line: line, column: column) -#endif + try await fetchURLData( + urlString: urlString, + postData: postData, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Source-forwarding form for APIs that have already captured their caller's location. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) // for concurrency + public static func fetchURLData(urlString: String, postData: PostData? = nil, source: SourceContext) async throws -> Data { + Compatibility.debug("Fetching URL [\(urlString)]...", level: .NOTICE, source: source) // create the url with URL guard let url = URL(string: urlString) else { - throw NetworkError.urlParsing(urlString: urlString).debug(level: .ERROR, file: file, function: function, line: line, column: column) + throw NetworkError.urlParsing(urlString: urlString).debug(level: .ERROR, source: source) } // now create the URLRequest object using the url object @@ -181,18 +187,13 @@ extension Compatibility { // encode the postData if provided, otherwise set the method to GET. if let parameters = postData { request.httpMethod = "POST" //set http method as POST - - // declare the parameter as a dictionary that contains string as key and value combination. considering inputs are valid - - //let parameters: [String: Any] = ["id": 13, "name": "jack"] guard let data = postData?.queryEncoded else { - throw NetworkError.postDataEncoding(parameters).debug(level: .ERROR, file: file, function: function, line: line, column: column) + throw NetworkError.postDataEncoding(parameters).debug(level: .ERROR, source: source) } request.httpBody = data } else { request.httpMethod = "GET" //set http method as GET } - //debug("FETCHING: \(request)", level: .DEBUG, file: file, function: function, line: line, column: column) var data: Data var response: URLResponse @@ -206,55 +207,71 @@ extension Compatibility { } } catch { if let error = error as? URLError, error.code.rawValue == -1003 { - throw NetworkError.missingEntitlement.debug(level: .ERROR, file: file, function: function, line: line, column: column) + throw NetworkError.missingEntitlement.debug(level: .ERROR, source: source) } else { - throw error.debug(level: .ERROR, file: file, function: function, line: line, column: column) + throw error.debug(level: .ERROR, source: source) } } - //debug("DEBUG RESPONSE DATA: \(data)") // Check response status code exists (should nearly always pass) guard let statusCode = (response as? HTTPURLResponse)?.statusCode else { let debugMessage = "No status code in HTTP response. Possibly offline?: \(String(describing: response))" -#if !hasFeature(Embedded) - debug(debugMessage, level: .ERROR) -#else - debug(debugMessage, isMainThread: false, level: .ERROR) -#endif - throw NetworkError.invalidResponse().debug(level: .ERROR, file: file, function: function, line: line, column: column) + Compatibility.debug(debugMessage, level: .ERROR, source: source) + throw NetworkError.invalidResponse().debug(level: .ERROR, source: source) } // check status code (should always be 200) guard statusCode == 200 else { - throw NetworkError.invalidResponse(code: statusCode).debug(level: .ERROR, file: file, function: function, line: line, column: column) + throw NetworkError.invalidResponse(code: statusCode).debug(level: .ERROR, source: source) } return data } - /// Fetch a string from the provided URL. If `postData` is provided, will use `POST` method instead of `GET`. + + /// Fetch a string from the provided URL. If `postData` is provided, will use `POST` method instead of `GET`. @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) // for concurrency public static func fetchURL(urlString: String, postData: PostData? = nil, encoding: String.Encoding = .utf8, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) async throws -> String { - let data = try await fetchURLData(urlString: urlString, postData: postData, file: file, function: function, line: line, column: column) + try await fetchURL( + urlString: urlString, + postData: postData, + encoding: encoding, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Source-forwarding form for APIs that have already captured their caller's location. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) // for concurrency + public static func fetchURL(urlString: String, postData: PostData? = nil, encoding: String.Encoding = .utf8, source: SourceContext) async throws -> String { + let data = try await fetchURLData(urlString: urlString, postData: postData, source: source) // convert result data to string guard let responseString = String(data: data, encoding: encoding) else { #if compiler(>=5.9) - throw NetworkError.dataError(data).debug(level: .ERROR, file: file, function: function, line: line, column: column) + throw NetworkError.dataError(data).debug(level: .ERROR, source: source) #else - throw CustomError("Data error: \(data)", level: .ERROR, file: file, function: function, line: line, column: column) + throw CustomError("Data error: \(data)", level: .ERROR, file: source.file, function: source.function, line: source.line, column: source.column) #endif } - //debug("Response String:\n\(responseString)", level: .SILENT) // this could be way too chatty if happens all the time. Just debug at the calling site if needed. return responseString } } + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) // for concurrency public func fetchURLData(urlString: String, postData: PostData? = nil, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) async throws -> Data { - try await Compatibility.fetchURLData(urlString: urlString, postData: postData, file: file, function: function, line: line, column: column) + try await Compatibility.fetchURLData( + urlString: urlString, + postData: postData, + source: SourceContext(file: file, function: function, line: line, column: column) + ) } + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) // for concurrency public func fetchURL(urlString: String, postData: PostData? = nil, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) async throws -> String { - try await Compatibility.fetchURL(urlString: urlString, postData: postData, file: file, function: function, line: line, column: column) + try await Compatibility.fetchURL( + urlString: urlString, + postData: postData, + source: SourceContext(file: file, function: function, line: line, column: column) + ) } @available(iOS 15, macOS 10.15, tvOS 13, watchOS 6, *) diff --git a/Sources/Core/Shell.swift b/Sources/Core/Shell.swift index f43cbed..9687958 100644 --- a/Sources/Core/Shell.swift +++ b/Sources/Core/Shell.swift @@ -20,9 +20,9 @@ public extension Compatibility { /// /// - Note: This is only available in macOS and **not** macCatalyst or any other platform. @discardableResult // Add to suppress warnings when you don't want/need the result - static func safeShell(_ command: String, shell: String = "/bin/zsh", logCommand: Bool = true) throws -> String { + static func safeShell(_ command: String, shell: String = "/bin/zsh", logCommand: Bool = true, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) throws -> String { if logCommand { - debug("Attempting to run shell command:\n\(command)", level: .NOTICE) + Compatibility.debug("Attempting to run shell command:\n\(command)", level: .NOTICE, source: SourceContext(file: file, function: function, line: line, column: column)) } let task = Process() @@ -38,7 +38,7 @@ public extension Compatibility { let data = pipe.fileHandleForReading.readDataToEndOfFile() guard let output = String(data: data, encoding: .utf8) else { - throw CustomError("Failed to parse shell output as UTF-8", level: .ERROR) // this should never happen + throw CustomError("Failed to parse shell output as UTF-8", level: .ERROR, file: file, function: function, line: line, column: column) // this should never happen } return output diff --git a/Sources/Core/Test.swift b/Sources/Core/Test.swift index b0973ad..a9b0277 100644 --- a/Sources/Core/Test.swift +++ b/Sources/Core/Test.swift @@ -6,13 +6,13 @@ 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 public let column: Int - /// Captures the call site by default. + /// Captures the call site when its individual defaults are used directly by a caller. public init( file: String = #file, function: String = #function, @@ -24,7 +24,43 @@ 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 + + /// Caller-capturing convenience that preserves the source of a naked `TestFailure("...")` call. + public init(_ message: String, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) { + self.init( + message, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Source-forwarding form for callers that have already captured their own call site. + public init(_ message: String, source: 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. @@ -40,27 +76,19 @@ public struct SourceContext: Sendable { /// The source location defaults mirror Swift Testing's diagnostics while remaining callable from /// 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 { + try expect( + condition, + debugString, + source: SourceContext(file: file, function: function, line: line, column: column) + ) +} + +/// Source-forwarding form for reusable expectation helpers. +public func expect(_ condition: Bool, _ debugString: String? = nil, source: SourceContext) 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" + Compatibility.debug(message, level: .ERROR, source: source) + throw TestFailure(message, source: source) } } @@ -71,16 +99,44 @@ public func expect(_ condition: Bool, _ debugString: String? = nil, file: String /// - expected: The value the test requires. /// - message: Optional context appended to the generated actual-versus-expected diagnostic. public func expectEqual(_ actual: Value, _ expected: Value, _ message: String? = nil, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) throws { + try expectEqual( + actual, + expected, + message, + source: SourceContext(file: file, function: function, line: line, column: column) + ) +} + +/// Source-forwarding form for APIs that already captured the original comparison call site. +public func expectEqual(_ actual: Value, _ expected: Value, _ message: String? = nil, source: SourceContext) throws { // Build the comparison text here so UI runs receive the same useful values that Swift Testing displays. let context = message.map { " \($0)" } ?? "" - try expect(actual == expected, "Expected \(String(reflecting: expected)), but received \(String(reflecting: actual)).\(context)", file: file, function: function, line: line, column: column) + try expect( + actual == expected, + "Expected \(String(reflecting: expected)), but received \(String(reflecting: actual)).\(context)", + source: source + ) } /// Requires two equatable values to differ and reports the shared value when they do not. public func expectNotEqual(_ actual: Value, _ unexpected: Value, _ message: String? = nil, file: String = #file, function: String = #function, line: Int = #line, column: Int = #column) throws { + try expectNotEqual( + actual, + unexpected, + message, + source: SourceContext(file: file, function: function, line: line, column: column) + ) +} + +/// Source-forwarding form for APIs that already captured the original comparison call site. +public func expectNotEqual(_ actual: Value, _ unexpected: Value, _ message: String? = nil, source: SourceContext) throws { // Include the unexpected value so a failure remains actionable outside a debugger. let context = message.map { " \($0)" } ?? "" - try expect(actual != unexpected, "Expected a value other than \(String(reflecting: unexpected)), but received it.\(context)", file: file, function: function, line: line, column: column) + try expect( + actual != unexpected, + "Expected a value other than \(String(reflecting: unexpected)), but received it.\(context)", + source: source + ) } // NOTE: Really wish there was a way of writing a possibly async function or doing this using a generic so we don't have to duplicate code. @@ -120,6 +176,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 +340,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 +355,83 @@ 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. + + /// Creates a reusable test with optional lifecycle closures while capturing its declaration site. + public convenience init( + _ title: String, + executionMode: TestExecutionMode = .parallel, + setUp: TestClosure? = nil, + test: @escaping TestClosure, + tearDown: TestClosure? = nil, + file: String = #file, + function: String = #function, + line: Int = #line, + column: Int = #column + ) { + self.init( + title, + executionMode: executionMode, + setUp: setUp, + test: test, + tearDown: tearDown, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Source-forwarding form for callers that already captured the declaration site. public init( _ title: String, + executionMode: TestExecutionMode = .parallel, setUp: TestClosure? = nil, test: @escaping TestClosure, - tearDown: TestClosure? = nil + tearDown: TestClosure? = nil, + source: 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, + file: String = #file, + function: String = #function, + line: Int = #line, + column: Int = #column, + _ test: @escaping TestClosure + ) { + self.init( + title, + executionMode: executionMode, + test: test, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Source-forwarding trailing-closure form. + public convenience init( + _ title: String, + executionMode: TestExecutionMode = .parallel, + source: 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 +439,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 +489,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 +512,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 +562,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/CodingFoundation.swift b/Sources/Foundation/CodingFoundation.swift index c737011..983e8c8 100644 --- a/Sources/Foundation/CodingFoundation.swift +++ b/Sources/Foundation/CodingFoundation.swift @@ -107,6 +107,16 @@ private struct CodingRoundTripTestModel: Codable, Equatable { let tags: [String] } +private enum ExpectedCodingTestError: Error { + case expected +} + +private struct ThrowingCodingTestModel: Encodable { + func encode(to encoder: Encoder) throws { + throw ExpectedCodingTestError.expected + } +} + @Sendable private func testCodingRoundTrips() throws { let model = CodingRoundTripTestModel(name: "Compatibility", count: 3, enabled: true, tags: ["json", "dictionary", "mixed"]) @@ -130,10 +140,45 @@ private func testCodingRoundTrips() throws { try expect(decodedFromMixedField == model, "MixedTypeField round trip should preserve the codable model") } +@Sendable +private func testDictionaryCodingStrategies() throws { + let encoder = DictionaryEncoder() + encoder.dateEncodingStrategy = .secondsSince1970 + encoder.dataEncodingStrategy = .base64 + encoder.nonConformingFloatEncodingStrategy = .convertToString( + positiveInfinity: "INF", + negativeInfinity: "-INF", + nan: "NaN" + ) + encoder.keyEncodingStrategy = .convertToSnakeCase + _ = encoder.dateEncodingStrategy + _ = encoder.dataEncodingStrategy + _ = encoder.nonConformingFloatEncodingStrategy + _ = encoder.keyEncodingStrategy + + let decoder = DictionaryDecoder() + decoder.dateDecodingStrategy = .secondsSince1970 + decoder.dataDecodingStrategy = .base64 + decoder.nonConformingFloatDecodingStrategy = .convertFromString( + positiveInfinity: "INF", + negativeInfinity: "-INF", + nan: "NaN" + ) + decoder.keyDecodingStrategy = .convertFromSnakeCase + _ = decoder.dateDecodingStrategy + _ = decoder.dataDecodingStrategy + _ = decoder.nonConformingFloatDecodingStrategy + _ = decoder.keyDecodingStrategy + + // Exercise the convenience API's documented nil-on-encoding-failure behavior. + try expect(ThrowingCodingTestModel().asDictionary() == nil) +} + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @MainActor internal let codingTests: [TestCase] = [ TestCase("Coding round trips", testCodingRoundTrips), + TestCase("Dictionary coding strategies", testDictionaryCodingStrategies), ] #endif 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/Foundation/Threading.swift b/Sources/Foundation/Threading.swift index cf138a0..5d93b19 100644 --- a/Sources/Foundation/Threading.swift +++ b/Sources/Foundation/Threading.swift @@ -83,45 +83,10 @@ private func timeTolerance(start: TimeInterval, end: TimeInterval, expected: Tim // MARK: - Sleep -#if arch(wasm32) -public extension Compatibility { - /// WebAssembly compatibility spelling for sleep. - /// - /// A generic WebAssembly host does not guarantee a suspending timer, so this returns immediately - /// while preserving cross-platform source compatibility for code that does not require a delay. - static func sleep( - seconds: Double, - file: String = #file, - function: String = #function, - line: Int = #line, - column: Int = #column - ) { - // This gate describes the missing timer primitive, not missing Swift concurrency support: - // browser hosts must schedule a JavaScript timer while WASI hosts use host-specific clocks. - Compatibility.debug( - "Sleep is unavailable on this WebAssembly runtime; no delay occurred. Prefer an asynchronous host timer for browser or WASI code.", - isMainThread: true, - level: .WARNING, - file: file, - function: function, - line: line, - column: column - ) - } -} - -/// Legacy WebAssembly sleep spelling retained as an immediate compatibility fallback. -@available(*, deprecated, renamed: "Compatibility.sleep(seconds:)", message: "Use Compatibility.sleep(seconds:) instead.") -public func sleep( - seconds: Double, - file: String = #file, - function: String = #function, - line: Int = #line, - column: Int = #column -) { - Compatibility.sleep(seconds: seconds, file: file, function: function, line: line, column: column) -} -#else +// A sleep helper must actually suspend for the requested duration. Generic WASM hosts and Embedded +// Swift do not provide the timer guarantees required by this API, so do not expose a no-op spelling +// that silently returns immediately and masks timing assumptions in portable code. +#if !arch(wasm32) && !hasFeature(Embedded) public extension Compatibility { /// Suspends the current asynchronous task for a number of seconds. /// @@ -135,14 +100,23 @@ public extension Compatibility { line: Int = #line, column: Int = #column ) async { + await sleep( + seconds: seconds, + source: SourceContext(file: file, function: function, line: line, column: column) + ) + } + + /// Source-forwarding form for helpers that already captured the original call site. + @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) + static func sleep(seconds: Double, source: SourceContext) async { let duration = UInt64(seconds * 1_000_000_000) do { try await Task.sleep(nanoseconds: duration) - // // Fallback on earlier versions - // sleep(UInt32(seconds)) // give fetch from server time to finish + // Potential fallback for earlier versions/backport? Likely unnecessary/unusable due to async but may be useful for a synchronous fallback?: + // sleep(UInt32(seconds)) // give fetch from server time to finish } catch { // do nothing but make debug log if we can. - debug("Sleep function was interrupted", level: .DEBUG, file: file, function: function, line: line, column: column) + Compatibility.debug("Sleep function was interrupted", level: .DEBUG, source: source) } } } @@ -157,7 +131,10 @@ public func sleep( line: Int = #line, column: Int = #column ) async { - await Compatibility.sleep(seconds: seconds, file: file, function: function, line: line, column: column) + await Compatibility.sleep( + seconds: seconds, + source: SourceContext(file: file, function: function, line: line, column: column) + ) } @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @@ -170,7 +147,10 @@ public extension Task where Success == Never, Failure == Never { line: Int = #line, column: Int = #column ) async { - await Compatibility.sleep(seconds: seconds, file: file, function: function, line: line, column: column) + await Compatibility.sleep( + seconds: seconds, + source: SourceContext(file: file, function: function, line: line, column: column) + ) } } @@ -214,11 +194,12 @@ private let sleepTests: [TestCase] = [ // MARK: - Background Tasks +// A background helper must actually move work away from the caller. Do not provide a WASM/Embedded +// syntax-only fallback that executes synchronously: that masks threading assumptions and can turn +// otherwise-correct code into blocking work. These APIs are therefore unavailable on WASM/Embedded. +#if !arch(wasm32) && !hasFeature(Embedded) public extension Compatibility { /// Runs potentially long synchronous work away from the main queue when threads are available. - /// - /// WebAssembly currently has no universally available Dispatch fallback, so its synchronous - /// implementation executes immediately even though actor and task language features exist. static func background( _ closure: @Sendable @escaping () -> Void, file: String = #file, @@ -226,22 +207,17 @@ public extension Compatibility { line: Int = #line, column: Int = #column ) { -#if arch(wasm32) - closure() -#else DispatchQueue.global().async { -// debug("Running background block", level: .DEBUG, file: file, function: function, line: line, column: column) +// Compatibility.debug("Running background block", level: .DEBUG, source: SourceContext(file: file, function: function, line: line, column: column)) closure() } -#endif } -#if !arch(wasm32) /// Starts nonthrowing asynchronous work in a detached background task. @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) - static func background(_ closure: @Sendable @escaping () async -> Void) { + static func background(_ closure: @Sendable @escaping () async -> Void) { // TODO: Should this capture SourceContext for debugging? Task.detached(priority: .background) { -// debug("Running asynchronous background block", level: .DEBUG) +// Compatibility.debug("Running asynchronous background block", level: .DEBUG, source: SourceContext(file: file, function: function, line: line, column: column)) await closure() } } @@ -251,11 +227,8 @@ public extension Compatibility { static func background( _ closure: @Sendable @escaping () async throws -> ReturnType ) async throws -> ReturnType { -#if canImport(Foundation) + // A full Swift runtime can still provide detached tasks without Foundation. return try await Task.detached(priority: .background, operation: closure).value -#else - return try await closure() -#endif } /// Runs nonthrowing asynchronous work that returns an optional value. @@ -265,20 +238,18 @@ public extension Compatibility { ) async -> ReturnType? { await Task.detached(priority: .background, operation: closure).value } -#endif } /// Runs synchronous work away from the main queue using the concise, deployment-compatible spelling. /// /// Use ``Compatibility/background(_:file:function:line:column:)`` when another API, such as /// SwiftUI's `View.background`, makes the unqualified name ambiguous. Callers that already require -/// iOS 13, macOS 10.15, tvOS 13, or watchOS 6 can instead use `Task.background`. +/// iOS 13, macOS 10.15, tvOS 13, or watchOS 6 should instead use `Task.background`. public func background(_ closure: @Sendable @escaping () -> Void) { // Keep this concise API independent of Swift concurrency so callers can deploy before iOS 13. Compatibility.background(closure) } -#if !arch(wasm32) /// Legacy unqualified asynchronous background helper retained for source compatibility. @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) @available(*, deprecated, renamed: "Task.background", message: "Use Compatibility.background or Task.background instead.") @@ -343,38 +314,12 @@ private let backgroundTests: [TestCase] = [ // MARK: - Main -#if arch(wasm32) +// Embedded Swift cannot provide the scheduling semantics promised here, so omit the API there. +// Full-runtime WebAssembly has Swift concurrency and follows the same modern availability branch; +// only the legacy Dispatch fallback is conditionally compiled where Dispatch is actually available. +#if !hasFeature(Embedded) public extension Compatibility { - /// Executes main-actor work immediately because this WebAssembly compatibility path is single threaded. - @MainActor - static func main( - _ closure: @Sendable @MainActor @escaping () -> Void, - file: String = #file, - function: String = #function, - line: Int = #line, - column: Int = #column - ) { - closure() - } -} - -/// Runs work on the main actor using the concise spelling on WebAssembly. -/// -/// Use ``Compatibility/main(_:file:function:line:column:)`` when an unqualified `main` name is ambiguous. -@MainActor -public func main( - _ closure: @Sendable @MainActor @escaping () -> Void, - file: String = #file, - function: String = #function, - line: Int = #line, - column: Int = #column -) { - // Forward through the shared implementation so the concise and qualified spellings remain equivalent. - Compatibility.main(closure, file: file, function: function, line: line, column: column) -} -#else -public extension Compatibility { - /// Schedules work on the main actor using concurrency or the older dispatch fallback. + /// Schedules work on the main actor using Swift concurrency or the older dispatch fallback. static func main( _ closure: @Sendable @MainActor @escaping () -> Void, file: String = #file, @@ -384,7 +329,7 @@ public extension Compatibility { ) { if #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) { Task { @MainActor in -// debug("Running main-thread block", level: .DEBUG, file: file, function: function, line: line, column: column) + // debug("Running main-thread block", level: .DEBUG, file: file, function: function, line: line, column: column) closure() } } else { @@ -406,7 +351,6 @@ public func main( line: Int = #line, column: Int = #column ) { - // Keep this concise API available before Swift concurrency by forwarding to the dispatch-capable implementation. Compatibility.main(closure, file: file, function: function, line: line, column: column) } @@ -445,13 +389,12 @@ private let mainTests: [TestCase] = [ // MARK: - Delay +// A delay helper must actually postpone execution. Generic WASM hosts and Embedded Swift do not +// provide the timing guarantees required here, so omit the API instead of executing immediately. +#if !arch(wasm32) && !hasFeature(Embedded) public extension Compatibility { /// Runs a closure after a delay, using dispatch when Swift concurrency is unavailable. static func delay(_ seconds: Double, closure: @Sendable @escaping () -> Void) { -#if arch(wasm32) - // WebAssembly has no blocking or asynchronous delay fallback in this compatibility layer. - closure() -#else if #available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) { Task { await Task.sleep(seconds: seconds) @@ -460,7 +403,6 @@ public extension Compatibility { } else { DispatchQueue.global().asyncAfter(deadline: DispatchTime.now() + seconds, execute: closure) } -#endif } } @@ -470,7 +412,6 @@ public func delay(_ seconds: Double, closure: @Sendable @escaping () -> Void) { Compatibility.delay(seconds, closure: closure) } -#if !arch(wasm32) @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension Task where Success == Never, Failure == Never { /// Preferred concise spelling for Compatibility's delayed closure helper. @@ -497,16 +438,15 @@ private let delayTests: [TestCase] = [ #endif // MARK: - Tests and Previews - #if compiler(>=5.9) @available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) public extension Compatibility { /// Reusable threading checks grouped without adding another public namespace. @MainActor static let threadingTests: [TestCase] = { -#if arch(wasm32) - // Generic WebAssembly hosts do not provide the timing guarantees these - // delay and dispatch tests assert, so retain the catalog as an empty API. +#if arch(wasm32) || hasFeature(Embedded) + // WASM/Embedded omit sleep, background, and delay rather than providing semantic no-op fallbacks. + // WebAssembly still exposes real MainActor scheduling, but its host-independent test catalog remains empty. return [] #else return sleepTests + backgroundTests + mainTests + delayTests 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/BackportButtonStyle.swift b/Sources/UI/BackportButtonStyle.swift new file mode 100644 index 0000000..235df68 --- /dev/null +++ b/Sources/UI/BackportButtonStyle.swift @@ -0,0 +1,39 @@ +#if canImport(SwiftUI) && compiler(>=5.9) && canImport(Foundation) +import SwiftUI + +/// Button styles whose newest system appearance can be used through Compatibility's backport surface. +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +public enum BackportButtonStyle: Sendable { + /// Uses the system Liquid Glass button style when available and a material-backed circular fallback otherwise. + case glass +} + +@available(iOS 13, macOS 10.15, tvOS 13, watchOS 6, *) +@MainActor +public extension Backport where Content: View { + /// Applies a Compatibility-managed button style whose appearance degrades gracefully on older systems. + /// + /// Use `.backport.buttonStyle(.glass)` instead of repeating availability checks at each call site. + @ViewBuilder + func buttonStyle(_ style: BackportButtonStyle) -> some View { + switch style { + case .glass: + if #available(iOS 26, macOS 26, tvOS 26, watchOS 26, *) { + content + .buttonStyle(.glass) + .buttonBorderShape(.circle) + } else if #available(iOS 15, macOS 12, tvOS 15, watchOS 8, *) { + content + .buttonStyle(.plain) + .background(.regularMaterial, in: Circle()) + .contentShape(Circle()) + } else { + content + .buttonStyle(.plain) + .background(Circle().fill(Color.secondary.opacity(0.15))) + .contentShape(Circle()) + } + } + } +} +#endif 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 diff --git a/Sources/UI/TestUI.swift b/Sources/UI/TestUI.swift index 65f5dae..5bf87b4 100644 --- a/Sources/UI/TestUI.swift +++ b/Sources/UI/TestUI.swift @@ -17,9 +17,15 @@ public struct TestRow: View { Text(test.progress.symbol) Text(test.title) Spacer() - Button("▶️") { + Button { test.run() + } label: { + Backport.Image(systemName: "play.fill") + .font(.system(size: 12, weight: .semibold)) + .frame(width: 28, height: 28) } + .backport.buttonStyle(.glass) + .accessibility(label: Text("Run test")) } if let errorMessage = test.errorMessage { Text(errorMessage)