diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 6bc36fb..58a590a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -20,7 +20,7 @@ jobs: matrix: xcode: ['26.5'] config: ['debug', 'release'] - traits: ['', 'CasePaths', 'CustomDump', 'IdentifiedCollections'] + traits: ['', 'CasePaths', 'CustomDump', 'IdentifiedCollections', 'IssueReporting'] runs-on: macos-26 steps: - uses: actions/checkout@v4 @@ -35,7 +35,7 @@ jobs: matrix: swift: - '6.3' - traits: ['', 'CasePaths', 'CustomDump', 'IdentifiedCollections'] + traits: ['', 'CasePaths', 'CustomDump', 'IdentifiedCollections', 'IssueReporting'] runs-on: ubuntu-latest container: swift:${{ matrix.swift }} steps: diff --git a/Package.swift b/Package.swift index c2b8cc7..4032ffc 100644 --- a/Package.swift +++ b/Package.swift @@ -30,6 +30,10 @@ let package = Package( name: "IdentifiedCollections", description: "Derive Shared elements from Shared collections using IdentifiedCollections" ), + .trait( + name: "IssueReporting", + description: "Improve test coverage by raising runtime warnings as test failures" + ), ], dependencies: [ .package(url: "https://github.com/pointfreeco/combine-schedulers", from: "1.0.0"), @@ -71,7 +75,13 @@ let package = Package( traits: ["IdentifiedCollections"] ) ), - .product(name: "IssueReporting", package: "xctest-dynamic-overlay"), + .product( + name: "IssueReporting", + package: "xctest-dynamic-overlay", + condition: .when( + traits: ["IssueReporting"] + ) + ), .product(name: "PerceptionCore", package: "swift-perception"), ], resources: [ @@ -116,6 +126,7 @@ package.traits.insert( : [ "CustomDump", "IdentifiedCollections", + "IssueReporting", ] ) ) diff --git a/Package@swift-5.9.swift b/Package@swift-5.9.swift index e8139b5..aa2b4fb 100644 --- a/Package@swift-5.9.swift +++ b/Package@swift-5.9.swift @@ -58,5 +58,6 @@ for target in package.targets { target.swiftSettings?.append(contentsOf: [ .define("CustomDump"), .define("IdentifiedCollections"), + .define("IssueReporting"), ]) } diff --git a/Package@swift-6.0.swift b/Package@swift-6.0.swift index a2c79b0..c25bad0 100644 --- a/Package@swift-6.0.swift +++ b/Package@swift-6.0.swift @@ -69,6 +69,7 @@ for target in package.targets { target.swiftSettings?.append(contentsOf: [ .define("CustomDump"), .define("IdentifiedCollections"), + .define("IssueReporting"), .enableUpcomingFeature("ExistentialAny"), .enableUpcomingFeature("ImmutableWeakCaptures"), .enableUpcomingFeature("InferIsolatedConformances"), diff --git a/Sources/Sharing/Internal/Reference.swift b/Sources/Sharing/Internal/Reference.swift index 2fb56d1..234fd4e 100644 --- a/Sources/Sharing/Internal/Reference.swift +++ b/Sources/Sharing/Internal/Reference.swift @@ -1,6 +1,5 @@ import Dependencies import Foundation -import IssueReporting public import PerceptionCore #if canImport(Combine) diff --git a/Sources/Sharing/Internal/SharedChangeTracker.swift b/Sources/Sharing/Internal/SharedChangeTracker.swift index f88e06a..88fedb9 100644 --- a/Sources/Sharing/Internal/SharedChangeTracker.swift +++ b/Sources/Sharing/Internal/SharedChangeTracker.swift @@ -1,6 +1,5 @@ public import Dependencies import Foundation -public import IssueReporting @_spi(SharedChangeTracking) public struct SharedChangeTracker: Hashable, Sendable { diff --git a/Sources/Sharing/SharedContinuations.swift b/Sources/Sharing/SharedContinuations.swift index dbf1bc2..6c01539 100644 --- a/Sources/Sharing/SharedContinuations.swift +++ b/Sources/Sharing/SharedContinuations.swift @@ -1,5 +1,4 @@ import Foundation -import IssueReporting /// A mechanism to communicate with a shared key's external system, synchronously or asynchronously. /// diff --git a/Sources/Sharing/SharedKeys/AppStorageKey.swift b/Sources/Sharing/SharedKeys/AppStorageKey.swift index 23f815e..7b7e6fa 100644 --- a/Sources/Sharing/SharedKeys/AppStorageKey.swift +++ b/Sources/Sharing/SharedKeys/AppStorageKey.swift @@ -2,7 +2,6 @@ import ConcurrencyExtras public import Dependencies @preconcurrency public import Foundation - import IssueReporting #if canImport(AppKit) import AppKit diff --git a/Sources/Sharing/SharedReader.swift b/Sources/Sharing/SharedReader.swift index 1446474..ccce033 100644 --- a/Sources/Sharing/SharedReader.swift +++ b/Sources/Sharing/SharedReader.swift @@ -1,5 +1,4 @@ import Foundation -import IssueReporting public import Observation public import PerceptionCore diff --git a/Sources/Sharing/Traits/IdentifiedCollections.swift b/Sources/Sharing/Traits/IdentifiedCollections.swift index 5c1aa93..a23c767 100644 --- a/Sources/Sharing/Traits/IdentifiedCollections.swift +++ b/Sources/Sharing/Traits/IdentifiedCollections.swift @@ -1,6 +1,5 @@ #if IdentifiedCollections public import IdentifiedCollections - import IssueReporting extension RangeReplaceableCollection { /// Creates an collection of shared elements from a shared collection. diff --git a/Sources/Sharing/Traits/IssueReporting.swift b/Sources/Sharing/Traits/IssueReporting.swift new file mode 100644 index 0000000..6781810 --- /dev/null +++ b/Sources/Sharing/Traits/IssueReporting.swift @@ -0,0 +1,151 @@ +#if IssueReporting + public import IssueReporting + + @_transparent + func reportIssue( + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + IssueReporting.reportIssue( + message(), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } + + @_transparent + func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + IssueReporting.reportIssue( + error, + message(), + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + } +#else + #if canImport(os) + public import os + public import Foundation + #else + @preconcurrency import Foundation + #endif + + #if canImport(os) + @_transparent + #endif + public func reportIssue( + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + runtimeWarn( + message(), + fileID: fileID, + line: line, + ) + } + + #if canImport(os) + @_transparent + #endif + func reportIssue( + _ error: any Error, + _ message: @autoclosure () -> String? = nil, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) { + runtimeWarn( + "Caught error: \(error)\(message().map { ": \($0)" } ?? "")", + fileID: fileID, + line: line, + ) + } + + #if canImport(os) + @_transparent + @inlinable + #endif + func runtimeWarn( + _ message: @autoclosure () -> String?, + fileID: StaticString, + line: UInt + ) { + var message = message() ?? "" + if message.isEmpty { + message = "Issue reported" + } + #if canImport(os) + guard ProcessInfo.processInfo.environment["XCODE_RUNNING_FOR_PREVIEWS"] != "1" + else { + print("🟣 \(fileID):\(line): \(message)") + return + } + let moduleName = String( + Substring("\(fileID)".utf8.prefix(while: { $0 != UTF8.CodeUnit(ascii: "/") })) + ) + os_log( + .fault, + dso: dso, + log: OSLog(subsystem: "com.apple.runtime-issues", category: moduleName), + "%@", + "\(isTesting ? "\(fileID):\(line): " : "")\(message)" + ) + #else + fputs("\(message)\n", stderr) + #endif + } + + @usableFromInline nonisolated(unsafe) let dso: UnsafeRawPointer = { + #if canImport(os) + let count = _dyld_image_count() + for i in 0..? = $model.detail - let detail = try #require(_detail) - - withKnownIssue { - do { - let tracker = SharedChangeTracker() - tracker.track { - detail.withLock { $0 += 1 } + #if IssueReporting + @Test func tracking() throws { + @Shared(value: Route.detail(0)) var model + let _detail: Shared? = $model.detail + let detail = try #require(_detail) + + withKnownIssue { + do { + let tracker = SharedChangeTracker() + tracker.track { + detail.withLock { $0 += 1 } + } } + } matching: { + print("# " + $0.description) + return $0.description.hasSuffix( + """ + Tracked unasserted changes to 'Shared(value: SharingTests.CasePathsTraitTests.Route.detail(1))': SharingTests.CasePathsTraitTests.Route.detail(0) → SharingTests.CasePathsTraitTests.Route.detail(1) + """ + ) } - } matching: { - print("# " + $0.description) - return $0.description.hasSuffix( - """ - Tracked unasserted changes to 'Shared(value: SharingTests.CasePathsTraitTests.Route.detail(1))': SharingTests.CasePathsTraitTests.Route.detail(0) → SharingTests.CasePathsTraitTests.Route.detail(1) - """ - ) } - } + #endif } #endif diff --git a/Tests/SharingTests/ContinuationTests.swift b/Tests/SharingTests/ContinuationTests.swift index a6c4a77..2ec3e8a 100644 --- a/Tests/SharingTests/ContinuationTests.swift +++ b/Tests/SharingTests/ContinuationTests.swift @@ -1,59 +1,61 @@ -import Sharing -import Testing +#if IssueReporting + import Sharing + import Testing -@Suite struct ContinuationTests { - @Test func dontResumeLoadContinuation() async throws { - try await withKnownIssue { - @SharedReader(DontResumeLoadContinuationKey()) var value = 0 - #expect($value.isLoading == false) - try await $value.load() - #expect($value.isLoading == false) - } matching: { issue in - issue.description.hasSuffix( - """ - 'DontResumeLoadContinuationKey()' leaked its continuation without one of its resume \ - methods being invoked. This will cause tasks waiting on it to resume immediately. - """ - ) + @Suite struct ContinuationTests { + @Test func dontResumeLoadContinuation() async throws { + try await withKnownIssue { + @SharedReader(DontResumeLoadContinuationKey()) var value = 0 + #expect($value.isLoading == false) + try await $value.load() + #expect($value.isLoading == false) + } matching: { issue in + issue.description.hasSuffix( + """ + 'DontResumeLoadContinuationKey()' leaked its continuation without one of its resume \ + methods being invoked. This will cause tasks waiting on it to resume immediately. + """ + ) + } } - } - @Test func dontResumeSaveContinuation() async throws { - @Shared(DontResumeSaveContinuationKey()) var value = 0 - try await withKnownIssue { - try await $value.save() - } matching: { issue in - issue.description.hasSuffix( - """ - 'DontResumeSaveContinuationKey()' leaked its continuation without one of its resume \ - methods being invoked. This will cause tasks waiting on it to resume immediately. - """ - ) + @Test func dontResumeSaveContinuation() async throws { + @Shared(DontResumeSaveContinuationKey()) var value = 0 + try await withKnownIssue { + try await $value.save() + } matching: { issue in + issue.description.hasSuffix( + """ + 'DontResumeSaveContinuationKey()' leaked its continuation without one of its resume \ + methods being invoked. This will cause tasks waiting on it to resume immediately. + """ + ) + } } } -} -private struct DontResumeLoadContinuationKey: SharedReaderKey { - var id: some Hashable { 0 } - func load(context: LoadContext, continuation: LoadContinuation) {} - func subscribe( - context: LoadContext, - subscriber: SharedSubscriber - ) -> SharedSubscription { - SharedSubscription {} + private struct DontResumeLoadContinuationKey: SharedReaderKey { + var id: some Hashable { 0 } + func load(context: LoadContext, continuation: LoadContinuation) {} + func subscribe( + context: LoadContext, + subscriber: SharedSubscriber + ) -> SharedSubscription { + SharedSubscription {} + } } -} -private struct DontResumeSaveContinuationKey: SharedKey { - var id: some Hashable { 0 } - func load(context: LoadContext, continuation: LoadContinuation) { - continuation.resume(returning: 42) - } - func subscribe( - context: LoadContext, - subscriber: SharedSubscriber - ) -> SharedSubscription { - SharedSubscription {} + private struct DontResumeSaveContinuationKey: SharedKey { + var id: some Hashable { 0 } + func load(context: LoadContext, continuation: LoadContinuation) { + continuation.resume(returning: 42) + } + func subscribe( + context: LoadContext, + subscriber: SharedSubscriber + ) -> SharedSubscription { + SharedSubscription {} + } + func save(_ value: Int, context: SaveContext, continuation: SaveContinuation) {} } - func save(_ value: Int, context: SaveContext, continuation: SaveContinuation) {} -} +#endif diff --git a/Tests/SharingTests/SharedChangeTrackerTests.swift b/Tests/SharingTests/SharedChangeTrackerTests.swift index d55fcf7..f2e5ed5 100644 --- a/Tests/SharingTests/SharedChangeTrackerTests.swift +++ b/Tests/SharingTests/SharedChangeTrackerTests.swift @@ -1,4 +1,4 @@ -#if CustomDump +#if CustomDump && IssueReporting import Combine import CustomDump import PerceptionCore