From 75e0d469c12aebd1270f0daa440abd3dcde2a295 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Wed, 18 Feb 2026 16:38:45 -0800 Subject: [PATCH 1/7] Initial Commit --- .gitignore | 8 ++++++ Package.swift | 26 +++++++++++++++++++ Sources/ObservationKit/ObservationKit.swift | 2 ++ .../ObservationKitTests.swift | 6 +++++ 4 files changed, 42 insertions(+) create mode 100644 .gitignore create mode 100644 Package.swift create mode 100644 Sources/ObservationKit/ObservationKit.swift create mode 100644 Tests/ObservationKitTests/ObservationKitTests.swift diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..0023a53 --- /dev/null +++ b/.gitignore @@ -0,0 +1,8 @@ +.DS_Store +/.build +/Packages +xcuserdata/ +DerivedData/ +.swiftpm/configuration/registries.json +.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata +.netrc diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..9fe6585 --- /dev/null +++ b/Package.swift @@ -0,0 +1,26 @@ +// swift-tools-version: 6.2 +// The swift-tools-version declares the minimum version of Swift required to build this package. + +import PackageDescription + +let package = Package( + name: "ObservationKit", + products: [ + // Products define the executables and libraries a package produces, making them visible to other packages. + .library( + name: "ObservationKit", + targets: ["ObservationKit"] + ), + ], + targets: [ + // Targets are the basic building blocks of a package, defining a module or a test suite. + // Targets can depend on other targets in this package and products from dependencies. + .target( + name: "ObservationKit" + ), + .testTarget( + name: "ObservationKitTests", + dependencies: ["ObservationKit"] + ), + ] +) diff --git a/Sources/ObservationKit/ObservationKit.swift b/Sources/ObservationKit/ObservationKit.swift new file mode 100644 index 0000000..08b22b8 --- /dev/null +++ b/Sources/ObservationKit/ObservationKit.swift @@ -0,0 +1,2 @@ +// The Swift Programming Language +// https://docs.swift.org/swift-book diff --git a/Tests/ObservationKitTests/ObservationKitTests.swift b/Tests/ObservationKitTests/ObservationKitTests.swift new file mode 100644 index 0000000..601a204 --- /dev/null +++ b/Tests/ObservationKitTests/ObservationKitTests.swift @@ -0,0 +1,6 @@ +import Testing +@testable import ObservationKit + +@Test func example() async throws { + // Write your test here and use APIs like `#expect(...)` to check expected conditions. +} From bbb032fc7e8b8761903dc012b8cde5d92051f031 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 24 Feb 2026 19:39:36 -0800 Subject: [PATCH 2/7] feat: initial setup --- .github/workflows/ci.yml | 64 ++++ .gitignore | 1 + .swift-format | 75 +++++ .../xcshareddata/IDETemplateMacros.plist | 14 + Package.resolved | 42 +++ Package.swift | 56 +++- README.md | 74 +++++ Sources/ObservationKit/ObservationKit.swift | 2 - .../ManagedCriticalState.swift | 25 ++ .../ObservationShim/ObservationsShim.swift | 285 ++++++++++++++++++ .../ObservationTesting/FulfillmentError.swift | 29 ++ .../FulfillmentOfCondition.swift | 58 ++++ .../FulfillmentOfValues.swift | 85 ++++++ .../FulfillmentWorker.swift | 44 +++ .../ObservationKitTests.swift | 6 - .../TestObservationShim.swift | 49 +++ .../TestFulfillmentOfConditions.swift | 53 ++++ .../TestFulfillmentOfValues.swift | 78 +++++ .../TestFulfillmentOfValuesByArray.swift | 79 +++++ 19 files changed, 1102 insertions(+), 17 deletions(-) create mode 100644 .github/workflows/ci.yml create mode 100644 .swift-format create mode 100644 .swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist create mode 100644 Package.resolved create mode 100644 README.md delete mode 100644 Sources/ObservationKit/ObservationKit.swift create mode 100644 Sources/ObservationShim/ManagedCriticalState.swift create mode 100644 Sources/ObservationShim/ObservationsShim.swift create mode 100644 Sources/ObservationTesting/FulfillmentError.swift create mode 100644 Sources/ObservationTesting/FulfillmentOfCondition.swift create mode 100644 Sources/ObservationTesting/FulfillmentOfValues.swift create mode 100644 Sources/ObservationTesting/FulfillmentWorker.swift delete mode 100644 Tests/ObservationKitTests/ObservationKitTests.swift create mode 100644 Tests/ObservationShimTests/TestObservationShim.swift create mode 100644 Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift create mode 100644 Tests/ObservationTestingTests/TestFulfillmentOfValues.swift create mode 100644 Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..62f74d5 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,64 @@ +name: CI + +on: + push: + branches: [main] + pull_request: + branches: [main] + +concurrency: + group: ci-${{ github.ref }} + cancel-in-progress: true + +jobs: + format: + name: Swift Format + runs-on: macos-15 + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode 26.2 + run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + + - name: Check Formatting + run: swift format lint --strict --parallel --recursive Sources Tests + + build-and-test: + name: ${{ matrix.name }} + runs-on: macos-15 + strategy: + fail-fast: false + matrix: + include: + - name: "iOS" + destination: "platform=iOS Simulator,name=iPhone 16,OS=26.2" + action: test + - name: "macOS" + destination: "platform=macOS" + action: test + - name: "Mac Catalyst" + destination: "platform=macOS,variant=Mac Catalyst" + action: test + - name: "tvOS" + destination: "platform=tvOS Simulator,name=Apple TV,OS=26.2" + action: test + - name: "visionOS" + destination: "platform=visionOS Simulator,name=Apple Vision Pro,OS=26.2" + action: test + - name: "watchOS" + destination: "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm),OS=26.2" + action: build + steps: + - uses: actions/checkout@v4 + + - name: Select Xcode 26.2 + run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer + + - name: Build & Test + env: + NSUnbufferedIO: "YES" + run: | + set -o pipefail + xcodebuild ${{ matrix.action }} \ + -scheme ObservationKit \ + -destination '${{ matrix.destination }}' diff --git a/.gitignore b/.gitignore index 0023a53..ae43ed6 100644 --- a/.gitignore +++ b/.gitignore @@ -6,3 +6,4 @@ DerivedData/ .swiftpm/configuration/registries.json .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata .netrc +*.log diff --git a/.swift-format b/.swift-format new file mode 100644 index 0000000..efe5aa4 --- /dev/null +++ b/.swift-format @@ -0,0 +1,75 @@ +{ + "fileScopedDeclarationPrivacy" : { + "accessLevel" : "private" + }, + "indentConditionalCompilationBlocks" : true, + "indentSwitchCaseLabels" : false, + "indentation" : { + "spaces" : 2 + }, + "lineBreakAroundMultilineExpressionChainComponents" : false, + "lineBreakBeforeControlFlowKeywords" : false, + "lineBreakBeforeEachArgument" : false, + "lineBreakBeforeEachGenericRequirement" : false, + "lineBreakBetweenDeclarationAttributes" : false, + "lineLength" : 100, + "maximumBlankLines" : 1, + "multiElementCollectionTrailingCommas" : true, + "noAssignmentInExpressions" : { + "allowedFunctions" : [ + "XCTAssertNoThrow" + ] + }, + "prioritizeKeepingFunctionOutputTogether" : false, + "reflowMultilineStringLiterals" : "never", + "respectsExistingLineBreaks" : true, + "rules" : { + "AllPublicDeclarationsHaveDocumentation" : false, + "AlwaysUseLiteralForEmptyCollectionInit" : false, + "AlwaysUseLowerCamelCase" : true, + "AmbiguousTrailingClosureOverload" : true, + "AvoidRetroactiveConformances" : true, + "BeginDocumentationCommentWithOneLineSummary" : false, + "DoNotUseSemicolons" : true, + "DontRepeatTypeInStaticProperties" : true, + "FileScopedDeclarationPrivacy" : true, + "FullyIndirectEnum" : true, + "GroupNumericLiterals" : true, + "IdentifiersMustBeASCII" : true, + "NeverForceUnwrap" : false, + "NeverUseForceTry" : false, + "NeverUseImplicitlyUnwrappedOptionals" : false, + "NoAccessLevelOnExtensionDeclaration" : true, + "NoAssignmentInExpressions" : true, + "NoBlockComments" : true, + "NoCasesWithOnlyFallthrough" : true, + "NoEmptyLinesOpeningClosingBraces" : false, + "NoEmptyTrailingClosureParentheses" : true, + "NoLabelsInCasePatterns" : true, + "NoLeadingUnderscores" : false, + "NoParensAroundConditions" : true, + "NoPlaygroundLiterals" : true, + "NoVoidReturnOnFunctionSignature" : true, + "OmitExplicitReturns" : false, + "OneCasePerLine" : true, + "OneVariableDeclarationPerLine" : true, + "OnlyOneTrailingClosureArgument" : true, + "OrderedImports" : true, + "ReplaceForEachWithForLoop" : true, + "ReturnVoidInsteadOfEmptyTuple" : true, + "TypeNamesShouldBeCapitalized" : true, + "UseEarlyExits" : false, + "UseExplicitNilCheckInConditions" : true, + "UseLetInEveryBoundCaseVariable" : true, + "UseShorthandTypeNames" : true, + "UseSingleLinePropertyGetter" : true, + "UseSynthesizedInitializer" : true, + "UseTripleSlashForDocumentationComments" : true, + "UseWhereClausesInForLoops" : false, + "ValidateDocumentationComments" : false + }, + "spacesAroundRangeFormationOperators" : false, + "spacesBeforeEndOfLineComments" : 2, + "tabWidth" : 8, + "version" : 1 +} diff --git a/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist new file mode 100644 index 0000000..8a863ea --- /dev/null +++ b/.swiftpm/xcode/package.xcworkspace/xcshareddata/IDETemplateMacros.plist @@ -0,0 +1,14 @@ + + + + + FILEHEADER + +// +// ___FILENAME___ +// ___TARGET___ +// +// Copyright (c) ___YEAR___ Jacob Fielding +// + + diff --git a/Package.resolved b/Package.resolved new file mode 100644 index 0000000..12a4d87 --- /dev/null +++ b/Package.resolved @@ -0,0 +1,42 @@ +{ + "originHash" : "379216a46373fe68e8600e1417c96c5c29baa125ab10b04ce6d850ef2b01baa2", + "pins" : [ + { + "identity" : "swift-async-algorithms", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-async-algorithms.git", + "state" : { + "revision" : "2971dd5d9f6e0515664b01044826bcea16e59fac", + "version" : "1.1.2" + } + }, + { + "identity" : "swift-collections", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-collections.git", + "state" : { + "revision" : "7b847a3b7008b2dc2f47ca3110d8c782fb2e5c7e", + "version" : "1.3.0" + } + }, + { + "identity" : "swift-concurrency-extras", + "kind" : "remoteSourceControl", + "location" : "https://github.com/pointfreeco/swift-concurrency-extras.git", + "state" : { + "revision" : "5a3825302b1a0d744183200915a47b508c828e6f", + "version" : "1.3.2" + } + }, + { + "identity" : "swift-numerics", + "kind" : "remoteSourceControl", + "location" : "https://github.com/apple/swift-numerics.git", + "state" : { + "revision" : "0c0290ff6b24942dadb83a929ffaaa1481df04a2", + "version" : "1.1.1" + } + } + ], + "version" : 3 +} diff --git a/Package.swift b/Package.swift index 9fe6585..bedce55 100644 --- a/Package.swift +++ b/Package.swift @@ -5,22 +5,60 @@ import PackageDescription let package = Package( name: "ObservationKit", + platforms: [ + .iOS(.v17), + .macCatalyst(.v17), + .macOS(.v14), + .tvOS(.v17), + .visionOS(.v1), + .watchOS(.v10) + ], products: [ - // Products define the executables and libraries a package produces, making them visible to other packages. .library( - name: "ObservationKit", - targets: ["ObservationKit"] + name: "ObservationTesting", + targets: ["ObservationTesting"] + ), + .library( + name: "ObservationShim", + targets: ["ObservationShim"] ), ], + dependencies: [ + .package(url: "https://github.com/apple/swift-async-algorithms.git", .upToNextMajor(from: "1.1.2")), + .package(url: "https://github.com/pointfreeco/swift-concurrency-extras.git", .upToNextMajor(from: "1.3.2")), + + // Testing + // TODO: Make this conditional + .package(url: "https://github.com/apple/swift-numerics.git", .upToNextMajor(from: "1.1.1")) + ], targets: [ - // Targets are the basic building blocks of a package, defining a module or a test suite. - // Targets can depend on other targets in this package and products from dependencies. .target( - name: "ObservationKit" + name: "ObservationTesting", + dependencies: [ + .product(name: "AsyncAlgorithms", package: "swift-async-algorithms"), + .product(name: "ConcurrencyExtras", package: "swift-concurrency-extras") + ] + ), + .target( + name: "ObservationShim" + ), + + // MARK: Testing + + .testTarget( + name: "ObservationTestingTests", + dependencies: [ + "ObservationTesting", + .product(name: "Numerics", package: "swift-numerics") + ] ), .testTarget( - name: "ObservationKitTests", - dependencies: ["ObservationKit"] + name: "ObservationShimTests", + dependencies: [ + "ObservationShim", + "ObservationTesting" + ] ), - ] + ], + swiftLanguageModes: [.v6] ) diff --git a/README.md b/README.md new file mode 100644 index 0000000..c5cc401 --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# ObservationKit + +A small library that aims to make it possible for me to adopt the Observation framework now, instead of years in the future. + +## ObservationShim + +This is a copy of SwiftLang's [`Observations.swift`](https://github.com/phausler/ObservationSequence/blob/main/Sources/ObservationSequence/Observations.swift) +with some small tweaks to enable iOS 17+ compatibility. There are endless online discussions and dozens of projects that aim to enable the use of `AsyncSequence` +and modern concurrency based streaming tools in favor of `Combine`. This one clicked thanks to this Swift Forums post +[iOS 18 support for the Observations struct is being dropped before release?](https://forums.swift.org/t/ios-18-support-for-the-observations-struct-is-being-dropped-before-release/81942/5). + +> [!IMPORTANT] +> This is not aimed at replacing `Observations`. It's simply a shim you can use to start making real +> use of the Apple Observations framework in favor of a bunch of bridges to other data sources like `Combine` + +This example shows how you'd effectively erase the official and shimmed backport into an +`AsyncStream` to use outside of SwiftUI. Note Pointfree Co's [`ConcurrencyExtras`](https://github.com/pointfreeco/swift-concurrency-extras) +was used to allow erasure into a clean `AsyncStream`. + +```swift +func observableStream( + _ emit: @escaping @isolated(any) @Sendable () -> Element +) -> AsyncStream { + if #available(iOS 26.0, *) { + let official = Observations(emit) + return AsyncStream(official) + } else { + let backport = ObservationsShim(emit) + return AsyncStream(backport) + } +} +``` + +### References + +- @vanvoorden [iOS 18 support for the Observations struct is being dropped before release?](https://forums.swift.org/t/ios-18-support-for-the-observations-struct-is-being-dropped-before-release/81942/5) +- @phausler [iOS 18 support for the Observations struct is being dropped before release?](https://forums.swift.org/t/ios-18-support-for-the-observations-struct-is-being-dropped-before-release/81942/6) +- [`Observations.swift`](https://github.com/phausler/ObservationSequence/blob/main/Sources/ObservationSequence/Observations.swift) + +## ObservationTesting + +Swift Testing introduced a new paradigm for async confirmation in [Testing asyncronous code](https://developer.apple.com/documentation/testing/testing-asynchronous-code). +The swift tools are totally acceptable, but can be quite verbose and a bit tricky, especially testing outputs from `AsyncSequences`. +The `ObservationTesting` library enables concise unit tests for `AsyncSequences`. This also works really well with `Observations` and `ObservationsShim`. + +This includes verifying `Equatable` elements over time. + +```swift +@Test("A value is emitted from the stream") +func basic() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + continuation.yield("dogs") + continuation.yield("lizards") + } + + try await stream.fulfillment(of: "cats", "dogs", "lizards") +} +``` + +Or complex conditions that can't easily be represented as expected value(s). + +```swift +@Test("A condition is verified on the stream") +func conditionMet() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + try await stream.fulfillment { value in + value == "cats" + } +} +``` diff --git a/Sources/ObservationKit/ObservationKit.swift b/Sources/ObservationKit/ObservationKit.swift deleted file mode 100644 index 08b22b8..0000000 --- a/Sources/ObservationKit/ObservationKit.swift +++ /dev/null @@ -1,2 +0,0 @@ -// The Swift Programming Language -// https://docs.swift.org/swift-book diff --git a/Sources/ObservationShim/ManagedCriticalState.swift b/Sources/ObservationShim/ManagedCriticalState.swift new file mode 100644 index 0000000..73c42c3 --- /dev/null +++ b/Sources/ObservationShim/ManagedCriticalState.swift @@ -0,0 +1,25 @@ +// +// ManagedCriticalState.swift +// ObservationShim +// +// Copyright (c) 2026 Jacob Fielding +// + +import Foundation + +final class _ManagedCriticalState: @unchecked Sendable { + private let lock = NSRecursiveLock() + private var state: State + + init(_ initial: State) { + state = initial + } + + func withCriticalRegion( + _ critical: (inout State) throws -> R + ) rethrows -> R { + try lock.withLock { + try critical(&state) + } + } +} diff --git a/Sources/ObservationShim/ObservationsShim.swift b/Sources/ObservationShim/ObservationsShim.swift new file mode 100644 index 0000000..dc5b693 --- /dev/null +++ b/Sources/ObservationShim/ObservationsShim.swift @@ -0,0 +1,285 @@ +//===----------------------------------------------------------------------===// +// +// Originally based on Observations.swift from the Swift.org open source project +// Copyright (c) 2025 Apple Inc. and the Swift project authors +// Licensed under Apache License v2.0 with Runtime Library Exception +// See https://swift.org/LICENSE.txt for license information +// +// Modifications copyright (c) 2026 Jacob Fielding +// +//===----------------------------------------------------------------------===// + +import Observation +import _Concurrency + +@usableFromInline +@_silgen_name("swift_task_addCancellationHandler") +func _taskAddCancellationHandler(handler: () -> Void) -> UnsafeRawPointer /*CancellationNotificationStatusRecord*/ + +@usableFromInline +@_silgen_name("swift_task_removeCancellationHandler") +func _taskRemoveCancellationHandler( + record: UnsafeRawPointer /*CancellationNotificationStatusRecord*/ +) + +func withIsolatedTaskCancellationHandler( + operation: @isolated(any) () async throws -> T, + onCancel handler: @Sendable () -> Void, + isolation: isolated (any Actor)? = #isolation +) async rethrows -> T { + // unconditionally add the cancellation record to the task. + // if the task was already cancelled, it will be executed right away. + let record = _taskAddCancellationHandler(handler: handler) + defer { _taskRemoveCancellationHandler(record: record) } + + return try await operation() +} + +/// An asychronous sequence generated from a closure that tracks the transactional changes of `@Observable` types. +/// +/// `Observations` conforms to `AsyncSequence`, providing a intutive and safe mechanism to track changes to +/// types that are marked as `@Observable` by using Swift Concurrency to indicate transactional boundaries +/// starting from the willSet of the first mutation to the next suspension point of the safe access. +public struct ObservationsShim: AsyncSequence, Sendable { + public enum Iteration: Sendable { + case next(Element) + case finish + } + + struct State { + enum Continuation { + case cancelled + case active(UnsafeContinuation) + func resume() { + switch self { + case .cancelled: break + case .active(let continuation): continuation.resume() + } + } + } + var id = 0 + var continuations: [Int: Continuation] = [:] + var dirty = false + + // create a generation id for the unique identification of the continuations + // this allows the shared awaiting of the willSets. + // Most likely, there wont be more than a handful of active iterations + // so this only needs to be unique for those active iterations + // that are in the process of calling next. + static func generation(_ state: _ManagedCriticalState) -> Int { + state.withCriticalRegion { state in + defer { state.id &+= 1 } + return state.id + } + } + + // the cancellation of awaiting on willSet only ferries in resuming early + // it is the responsability of the caller to check if the task is actually + // cancelled after awaiting the willSet to act accordingly. + static func cancel(_ state: _ManagedCriticalState, id: Int) { + state.withCriticalRegion { state in + guard let continuation = state.continuations.removeValue(forKey: id) else { + // if there was no continuation yet active (e.g. it was cancelled at + // the start of the invocation, then put a tombstone in to gate that + // resuming later + state.continuations[id] = .cancelled + return nil as Continuation? + } + return continuation + }?.resume() + } + + // fire off ALL awaiting willChange continuations such that they are no + // longer pending. + static func emitWillChange(_ state: _ManagedCriticalState) { + let continuations = state.withCriticalRegion { state in + // if there are no continuations present then we have to set the state as dirty + // else if this is uncondiitonally set the state might produce duplicate events + // one for the dirty and one for the continuation. + if state.continuations.count == 0 { + state.dirty = true + } + defer { + state.continuations.removeAll() + } + return state.continuations.values + } + for continuation in continuations { + continuation.resume() + } + } + + // install a willChange continuation into the set of continuations + // this must take a locally unique id (to the active calls of next) + static func willChange(isolation iterationIsolation: isolated (any Actor)? = #isolation, state: _ManagedCriticalState, id: Int) async { + return await withUnsafeContinuation(isolation: iterationIsolation) { continuation in + state.withCriticalRegion { state in + defer { + state.dirty = false + } + switch state.continuations[id] { + case .cancelled: + return continuation as UnsafeContinuation? + case .active: + // the Iterator itself cannot be shared across isolations so any call to next that may share an id is a misbehavior + // or an internal book-keeping failure + fatalError("Iterator incorrectly shared across task isolations") + case .none: + if state.dirty { + return continuation + } else { + state.continuations[id] = .active(continuation) + return nil + } + } + }?.resume() + } + } + } + + // @isolated(any) closures cannot be composed and retain or forward their isolation + // this basically would be replaced with `{ .next(elementProducer()) }` if that + // were to become possible. + enum Emit { + case iteration(@isolated(any) @Sendable () throws(Failure) -> Iteration) + case element(@isolated(any) @Sendable () throws(Failure) -> Element) + + var isolation: (any Actor)? { + switch self { + case .iteration(let closure): closure.isolation + case .element(let closure): closure.isolation + } + } + } + + let state: _ManagedCriticalState + let emit: Emit + + // internal funnel method for initialziation + internal init(emit: Emit) { + self.emit = emit + self.state = _ManagedCriticalState(State()) + } + + /// Constructs an asynchronous sequence for a given closure by tracking changes of `@Observable` types. + /// + /// The emit closure is responsible for extracting a value out of a single or many `@Observable` types. + /// + /// - Parameters: + /// - isolation: The concurrency isolation domain of the caller. + /// - emit: A closure to generate an element for the sequence. + public init( + @_inheritActorContext _ emit: @escaping @isolated(any) @Sendable () throws(Failure) -> Element + ) { + self.init(emit: .element(emit)) + } + + /// Constructs an asynchronous sequence for a given closure by tracking changes of `@Observable` types. + /// + /// The emit closure is responsible for extracting a value out of a single or many `@Observable` types. This method + /// continues to be invoked until the .finished option is returned or an error is thrown. + /// + /// - Parameters: + /// - isolation: The concurrency isolation domain of the caller. + /// - emit: A closure to generate an element for the sequence. + public static func untilFinished( + @_inheritActorContext _ emit: @escaping @isolated(any) @Sendable () throws(Failure) -> Iteration + ) -> ObservationsShim { + .init(emit: .iteration(emit)) + } + + public struct Iterator: AsyncIteratorProtocol { + // the state ivar serves two purposes: + // 1) to store a critical region of state of the mutations + // 2) to idenitify the termination of _this_ sequence + var state: _ManagedCriticalState? + let emit: Emit + var started = false + + // this is the primary implementation of the tracking + // it is bound to be called on the specified isolation of the construction + fileprivate static func trackEmission(isolation trackingIsolation: isolated (any Actor)?, state: _ManagedCriticalState, emit: Emit) throws(Failure) -> Iteration { + // this ferries in an intermediate form with Result to skip over `withObservationTracking` not handling errors being thrown + // particularly this case is that the error is also an iteration state transition data point (it terminates the sequence) + // so we need to hold that to get a chance to catch and clean-up + let result = withObservationTracking { + switch emit { + case .element(let element): + Result(catching: element).map { Iteration.next($0) } + case .iteration(let iteration): + Result(catching: iteration) + } + } onChange: { [state] in + // resume all cases where the awaiting continuations are awaiting a willSet + State.emitWillChange(state) + } + return try result.get() + } + + fileprivate mutating func terminate(throwing failure: Failure? = nil, id: Int) throws(Failure) -> Element? { + // this is purely defensive to any leaking out of iteration generation ids + state?.withCriticalRegion { state in + state.continuations.removeValue(forKey: id) + }?.resume() + // flag the sequence as terminal by nil'ing out the state + state = nil + if let failure { + throw failure + } else { + return nil + } + } + + fileprivate mutating func trackEmission(isolation iterationIsolation: isolated (any Actor)?, state: _ManagedCriticalState, id: Int) async throws(Failure) -> Element? { + guard !Task.isCancelled else { + // the task was cancelled while awaiting a willChange so ensure a proper termination + return try terminate(id: id) + } + // start by directly tracking the emission via a withObservation tracking on the isolation specified fro mthe init + switch try await Iterator.trackEmission(isolation: emit.isolation, state: state, emit: emit) { + case .finish: return try terminate(id: id) + case .next(let element): return element + } + } + + public mutating func next() async throws -> Element? { + try await next(isolation: #isolation) + } + + public mutating func next(isolation iterationIsolation: isolated (any Actor)?) async throws(Failure) -> Element? { + // early exit if the sequence is terminal already + guard let state else { return nil } + // set up an id for this generation + let id = State.generation(state) + do { + // there are two versions; + // either the tracking has never yet started at all and we need to prime the pump for this specific iterator + // or the tracking has already started and we are going to await a change + if !started { + started = true + return try await trackEmission(isolation: iterationIsolation, state: state, id: id) + } else { + // wait for the willChange (and NOT the value itself) + // since this is going to be on the isolation of the object (e.g. the isolation specified in the initialization) + // this will mean our next await for the emission will ensure the suspension return of the willChange context + // back to the trailing edges of the mutations. In short, this enables the transactionality bounded by the + // isolation of the mutation. + await withIsolatedTaskCancellationHandler(operation: { + await State.willChange(isolation: iterationIsolation, state: state, id: id) + }, onCancel: { + // ensure to clean out our continuation uon cancellation + State.cancel(state, id: id) + }, isolation: iterationIsolation) + return try await trackEmission(isolation: iterationIsolation, state: state, id: id) + } + } catch { + // the user threw a failure in the closure so propigate that outwards and terminate the sequence + return try terminate(throwing: error, id: id) + } + } + } + + public func makeAsyncIterator() -> Iterator { + Iterator(state: state, emit: emit) + } +} diff --git a/Sources/ObservationTesting/FulfillmentError.swift b/Sources/ObservationTesting/FulfillmentError.swift new file mode 100644 index 0000000..e89fe12 --- /dev/null +++ b/Sources/ObservationTesting/FulfillmentError.swift @@ -0,0 +1,29 @@ +// +// FulfillmentError.swift +// ObservationTesting +// +// Copyright (c) 2026 Jacob Fielding +// + +import Foundation + +public enum FulfillmentError: Error & Equatable where Value: Sendable & Equatable { + case timedOut(remaining: [Value]) + case timedOutStrict(remaining: [Value]) + case timedOutWith(condition: String) +} + +extension FulfillmentError: LocalizedError { + public var errorDescription: String? { + switch self { + case let .timedOut(remaining): + let remainingStr = remaining.map({ "\($0)" }).joined(separator: ", ") + return "Timed out before `\(remainingStr)` was fulfilled." + case let .timedOutStrict(remaining): + let remainingStr = remaining.map({ "\($0)" }).joined(separator: ", ") + return "Timed out before `\(remainingStr)` was not fulfilled strictly at expected order." + case let .timedOutWith(condition): + return "Timed out because condition at \(condition) unmet" + } + } +} diff --git a/Sources/ObservationTesting/FulfillmentOfCondition.swift b/Sources/ObservationTesting/FulfillmentOfCondition.swift new file mode 100644 index 0000000..4f44ab2 --- /dev/null +++ b/Sources/ObservationTesting/FulfillmentOfCondition.swift @@ -0,0 +1,58 @@ +// +// FulfillmentOfCondition.swift +// ObservationTesting +// +// Copyright (c) 2026 Jacob Fielding +// + +public extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { + + /// Fulfill a specific condition. + /// + /// - Parameters: + /// - condition: + /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. + /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. + func fulfillment( + condition: @Sendable @isolated(any) @escaping (Element?) async throws -> Bool, + timeout: Duration? = nil, + execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {}, + file: StaticString = #file, + line: UInt = #line + ) async throws { + let worker = FulfillmentWorker( + timeout: timeout, + check: { + try await processCondition(condition: condition, file: file, line: line) + }, + testBehavior: testBehavior + ) + + try await worker.run() + } +} + +extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { + + func processCondition( + condition: @Sendable @isolated(any) @escaping (Element?) async throws -> Bool, + file: StaticString = #file, + line: UInt = #line + ) async throws { + var iterator = self.makeAsyncIterator() + + while !Task.isCancelled { + let value = try await iterator.next() + + if try await condition(value) { + return + } + } + + do { + try Task.checkCancellation() + } catch { + throw FulfillmentError.timedOutWith(condition: "\(file):\(line)") + } + } +} diff --git a/Sources/ObservationTesting/FulfillmentOfValues.swift b/Sources/ObservationTesting/FulfillmentOfValues.swift new file mode 100644 index 0000000..faede45 --- /dev/null +++ b/Sources/ObservationTesting/FulfillmentOfValues.swift @@ -0,0 +1,85 @@ +// +// FulfillmentOfValues.swift +// ObservationTesting +// +// Copyright (c) 2026 Jacob Fielding +// + +public extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { + + /// Fulfill one or more values. + /// + /// - Parameters: + /// - values: One or values that must be emitted by the stream to succeed. + /// - strict: If true, values must emitted in order. + /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. + /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. + func fulfillment( + of values: Element..., + strict: Bool = false, + timeout: Duration? = nil, + execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {} + ) async throws { + let worker = FulfillmentWorker( + timeout: timeout, + check: { + try await processFulfillment(values: values, strict: strict) + }, + testBehavior: testBehavior + ) + + try await worker.run() + } + + /// Fulfill an array of values. + /// + /// - Parameters: + /// - values: One or values that must be emitted by the stream to succeed. + /// - strict: If true, values must emitted in order. + /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. + /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. + func fulfillment( + values: [Element], + strict: Bool = false, + timeout: Duration? = nil, + execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {} + ) async throws { + let worker = FulfillmentWorker( + timeout: timeout, + check: { + try await processFulfillment(values: values, strict: strict) + }, + testBehavior: testBehavior + ) + + try await worker.run() + } +} + +extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { + + func processFulfillment(values: [Element], strict: Bool) async throws { + var remainingValues = values + var iterator = self.makeAsyncIterator() + + while !Task.isCancelled, !remainingValues.isEmpty { + let value = try await iterator.next() + + if strict && remainingValues.first == value { + remainingValues.remove(at: 0) + } else if !strict, let index = remainingValues.firstIndex(where: { $0 == value }) { + remainingValues.remove(at: index) + } + } + + do { + try Task.checkCancellation() + } catch { + if strict { + throw FulfillmentError.timedOutStrict(remaining: remainingValues) + } else { + throw FulfillmentError.timedOut(remaining: remainingValues) + } + } + } +} diff --git a/Sources/ObservationTesting/FulfillmentWorker.swift b/Sources/ObservationTesting/FulfillmentWorker.swift new file mode 100644 index 0000000..1b93bb0 --- /dev/null +++ b/Sources/ObservationTesting/FulfillmentWorker.swift @@ -0,0 +1,44 @@ +// +// FulfillmentWorker.swift +// ObservationTesting +// +// Copyright (c) 2026 Jacob Fielding +// + +struct FulfillmentWorker { + + private let timeout: Duration? + private let check: @Sendable () async throws -> Void + private let testBehavior: @Sendable () async throws -> Void + + init( + timeout: Duration?, + check: @Sendable @escaping () async throws -> Void, + testBehavior: @Sendable @escaping () async throws -> Void + ) { + self.timeout = timeout + self.check = check + self.testBehavior = testBehavior + } + + func run() async throws { + let checkTask = Task { + try await check() + } + + if let timeout { + Task.detached(priority: .high) { + try await Task.sleep(for: timeout) + checkTask.cancel() + } + } + + let behaviorTask = Task.detached(priority: .high) { + try await Task.sleep(for: .nanoseconds(50)) + try await self.testBehavior() + } + + try await behaviorTask.value + try await checkTask.value + } +} diff --git a/Tests/ObservationKitTests/ObservationKitTests.swift b/Tests/ObservationKitTests/ObservationKitTests.swift deleted file mode 100644 index 601a204..0000000 --- a/Tests/ObservationKitTests/ObservationKitTests.swift +++ /dev/null @@ -1,6 +0,0 @@ -import Testing -@testable import ObservationKit - -@Test func example() async throws { - // Write your test here and use APIs like `#expect(...)` to check expected conditions. -} diff --git a/Tests/ObservationShimTests/TestObservationShim.swift b/Tests/ObservationShimTests/TestObservationShim.swift new file mode 100644 index 0000000..10185a7 --- /dev/null +++ b/Tests/ObservationShimTests/TestObservationShim.swift @@ -0,0 +1,49 @@ +// +// TestObservationShim.swift +// ObservationShimTests +// +// Copyright (c) 2026 Jacob Fielding +// + +import Observation +import Testing +import ObservationTesting +@testable import ObservationShim + +@MainActor +@Observable +final class Foo { + var bar: String? + var baz: Int? + + func set(bar: String) { + self.bar = bar + } + + func set(baz: Int) { + self.baz = baz + } +} + +struct TestObservationShim { + + @MainActor + @Test("A basic legacy stream on iOS 17") + func basic() async throws { + let foo = Foo() + + let stream = ObservationsShim { + foo.bar + } + + Task.detached { + await foo.set(bar: "cats") + try await Task.sleep(for: .microseconds(500)) + await foo.set(bar: "dogs") + try await Task.sleep(for: .milliseconds(500)) + await foo.set(bar: "lizards") + } + + try await stream.fulfillment(of: "cats", "dogs", "lizards") + } +} diff --git a/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift b/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift new file mode 100644 index 0000000..e09a3b7 --- /dev/null +++ b/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift @@ -0,0 +1,53 @@ +// +// TestFulfillmentOfConditions.swift +// ObservationTestingTests +// +// Copyright (c) 2026 Jacob Fielding +// + +import Foundation +import Testing +import Numerics +@testable import ObservationTesting + +struct TestFillmentOfConditions { + + @Test("A condition is verified on the stream") + func conditionMet() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + try await stream.fulfillment { value in + value == "cats" + } + } + + @Test("A value is not emitted from the stream") + func conditionNotMet() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment( + condition: { value in + value == "dogs" + }, + timeout: .seconds(1) + ) + + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOutWith(condition: "ObservationTestingTests/TestFulfillmentOfConditions.swift:28")) + #expect(error.localizedDescription == "Timed out because condition at ObservationTestingTests/TestFulfillmentOfConditions.swift:28 unmet") + + let duration = Date().timeIntervalSince(executionStart) + #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift b/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift new file mode 100644 index 0000000..2252357 --- /dev/null +++ b/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift @@ -0,0 +1,78 @@ +// +// TestFulfillmentOfValues.swift +// ObservationTestingTests +// +// Copyright (c) 2026 Jacob Fielding +// + +import Foundation +import Testing +import Numerics +@testable import ObservationTesting + +struct TestFulfillmentOfValues { + + @Test("A value is emitted from the stream") + func basic() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + try await stream.fulfillment(of: "cats") + } + + @Test("A value is not emitted from the stream") + func failure() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment(of: "dogs", timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOut(remaining: ["dogs"])) + #expect(error.localizedDescription == "Timed out before `dogs` was fulfilled.") + + let duration = Date().timeIntervalSince(executionStart) + #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("A value is emitted in the stream in strict order") + func strict() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") + } + + try await stream.fulfillment(of: "dogs", "cats", strict: true, timeout: .seconds(1)) + } + + @Test("Strictly checked fails when order out of alignment") + func strictlyOutOfOrder() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment(of: "cats", "dogs", strict: true, timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOutStrict(remaining: ["dogs"])) + #expect(error.localizedDescription == "Timed out before `dogs` was not fulfilled strictly at expected order.") + + let duration = Date().timeIntervalSince(executionStart) + #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} diff --git a/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift b/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift new file mode 100644 index 0000000..f17d359 --- /dev/null +++ b/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift @@ -0,0 +1,79 @@ +// +// TestFulfillmentOfValuesByArray.swift +// ObservationTestingTests +// +// Copyright (c) 2026 Jacob Fielding +// + + +import Foundation +import Testing +import Numerics +@testable import ObservationTesting + +struct TestFulfillmentOfValuesByArray { + + @Test("A value is emitted from the stream") + func basic() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + try await stream.fulfillment(values: ["cats"]) + } + + @Test("A value is not emitted from the stream") + func failure() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment(values: ["dogs"], timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOut(remaining: ["dogs"])) + #expect(error.localizedDescription == "Timed out before `dogs` was fulfilled.") + + let duration = Date().timeIntervalSince(executionStart) + #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } + + @Test("A value is emitted in the stream in strict order") + func strict() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") + } + + try await stream.fulfillment(values: ["dogs", "cats"], strict: true, timeout: .seconds(1)) + } + + @Test("Strictly checked fails when order out of alignment") + func strictlyOutOfOrder() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment(values: ["cats", "dogs"], strict: true, timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOutStrict(remaining: ["dogs"])) + #expect(error.localizedDescription == "Timed out before `dogs` was not fulfilled strictly at expected order.") + + let duration = Date().timeIntervalSince(executionStart) + #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") + } + } +} From bda9f394c70846c8eca2e7c561f00ba96f8721ac Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 24 Feb 2026 19:42:57 -0800 Subject: [PATCH 3/7] feat: init repo --- .github/workflows/ci.yml | 17 +-- .github/workflows/release.yml | 24 ++++ LICENSE | 10 ++ .../ManagedCriticalState.swift | 26 ++-- .../ObservationShim/ObservationsShim.swift | 40 ++++-- .../ObservationTesting/FulfillmentError.swift | 28 ++-- .../FulfillmentOfCondition.swift | 90 ++++++------ .../FulfillmentOfValues.swift | 130 +++++++++--------- .../FulfillmentWorker.swift | 66 ++++----- .../TestObservationShim.swift | 61 ++++---- .../TestFulfillmentOfConditions.swift | 81 ++++++----- .../TestFulfillmentOfValues.swift | 125 +++++++++-------- .../TestFulfillmentOfValuesByArray.swift | 126 +++++++++-------- 13 files changed, 443 insertions(+), 381 deletions(-) create mode 100644 .github/workflows/release.yml diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 62f74d5..a191f2b 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -13,19 +13,16 @@ concurrency: jobs: format: name: Swift Format - runs-on: macos-15 + runs-on: macos-26 steps: - uses: actions/checkout@v4 - - name: Select Xcode 26.2 - run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer - - name: Check Formatting run: swift format lint --strict --parallel --recursive Sources Tests build-and-test: name: ${{ matrix.name }} - runs-on: macos-15 + runs-on: macos-26 strategy: fail-fast: false matrix: @@ -51,14 +48,6 @@ jobs: steps: - uses: actions/checkout@v4 - - name: Select Xcode 26.2 - run: sudo xcode-select -s /Applications/Xcode_26.2.app/Contents/Developer - - name: Build & Test - env: - NSUnbufferedIO: "YES" run: | - set -o pipefail - xcodebuild ${{ matrix.action }} \ - -scheme ObservationKit \ - -destination '${{ matrix.destination }}' + xcodebuild ${{ matrix.action }} -scheme ObservationKit -destination '${{ matrix.destination }}' diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000..a0bc9bf --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,24 @@ +name: release + +on: + workflow_dispatch: + inputs: + bump_version_scheme: + type: choice + description: "Bump version scheme" + required: true + default: "patch" + options: + - "patch" + - "minor" + - "major" + +jobs: + release-on-push: + runs-on: ubuntu-latest + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + steps: + - uses: rymndhng/release-on-push-action@master + with: + bump_version_scheme: ${{ github.event_name == 'push' && github.ref == 'refs/heads/main' && 'patch' || inputs.bump_version_scheme }} diff --git a/LICENSE b/LICENSE index 89f3bb7..ae2d156 100644 --- a/LICENSE +++ b/LICENSE @@ -26,3 +26,13 @@ SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + +--- + +Portions of this project are derived from the Swift.org open source project +(https://github.com/swiftlang/swift), which is licensed under the +Apache License, Version 2.0, with Runtime Library Exception. + +The full text of the Apache License 2.0 is included in LICENSE-APACHE. +See https://github.com/swiftlang/swift/blob/main/LICENSE.txt for the original license including the +Runtime Library Exception. diff --git a/Sources/ObservationShim/ManagedCriticalState.swift b/Sources/ObservationShim/ManagedCriticalState.swift index 73c42c3..5627c21 100644 --- a/Sources/ObservationShim/ManagedCriticalState.swift +++ b/Sources/ObservationShim/ManagedCriticalState.swift @@ -8,18 +8,18 @@ import Foundation final class _ManagedCriticalState: @unchecked Sendable { - private let lock = NSRecursiveLock() - private var state: State - - init(_ initial: State) { - state = initial - } - - func withCriticalRegion( - _ critical: (inout State) throws -> R - ) rethrows -> R { - try lock.withLock { - try critical(&state) - } + private let lock = NSRecursiveLock() + private var state: State + + init(_ initial: State) { + state = initial + } + + func withCriticalRegion( + _ critical: (inout State) throws -> R + ) rethrows -> R { + try lock.withLock { + try critical(&state) } + } } diff --git a/Sources/ObservationShim/ObservationsShim.swift b/Sources/ObservationShim/ObservationsShim.swift index dc5b693..1d00c62 100644 --- a/Sources/ObservationShim/ObservationsShim.swift +++ b/Sources/ObservationShim/ObservationsShim.swift @@ -14,7 +14,8 @@ import _Concurrency @usableFromInline @_silgen_name("swift_task_addCancellationHandler") -func _taskAddCancellationHandler(handler: () -> Void) -> UnsafeRawPointer /*CancellationNotificationStatusRecord*/ +func _taskAddCancellationHandler(handler: () -> Void) + -> UnsafeRawPointer /*CancellationNotificationStatusRecord*/ @usableFromInline @_silgen_name("swift_task_removeCancellationHandler") @@ -111,7 +112,10 @@ public struct ObservationsShim: AsyncSequence // install a willChange continuation into the set of continuations // this must take a locally unique id (to the active calls of next) - static func willChange(isolation iterationIsolation: isolated (any Actor)? = #isolation, state: _ManagedCriticalState, id: Int) async { + static func willChange( + isolation iterationIsolation: isolated (any Actor)? = #isolation, + state: _ManagedCriticalState, id: Int + ) async { return await withUnsafeContinuation(isolation: iterationIsolation) { continuation in state.withCriticalRegion { state in defer { @@ -198,7 +202,10 @@ public struct ObservationsShim: AsyncSequence // this is the primary implementation of the tracking // it is bound to be called on the specified isolation of the construction - fileprivate static func trackEmission(isolation trackingIsolation: isolated (any Actor)?, state: _ManagedCriticalState, emit: Emit) throws(Failure) -> Iteration { + fileprivate static func trackEmission( + isolation trackingIsolation: isolated (any Actor)?, state: _ManagedCriticalState, + emit: Emit + ) throws(Failure) -> Iteration { // this ferries in an intermediate form with Result to skip over `withObservationTracking` not handling errors being thrown // particularly this case is that the error is also an iteration state transition data point (it terminates the sequence) // so we need to hold that to get a chance to catch and clean-up @@ -216,7 +223,9 @@ public struct ObservationsShim: AsyncSequence return try result.get() } - fileprivate mutating func terminate(throwing failure: Failure? = nil, id: Int) throws(Failure) -> Element? { + fileprivate mutating func terminate(throwing failure: Failure? = nil, id: Int) throws(Failure) + -> Element? + { // this is purely defensive to any leaking out of iteration generation ids state?.withCriticalRegion { state in state.continuations.removeValue(forKey: id) @@ -230,7 +239,10 @@ public struct ObservationsShim: AsyncSequence } } - fileprivate mutating func trackEmission(isolation iterationIsolation: isolated (any Actor)?, state: _ManagedCriticalState, id: Int) async throws(Failure) -> Element? { + fileprivate mutating func trackEmission( + isolation iterationIsolation: isolated (any Actor)?, state: _ManagedCriticalState, + id: Int + ) async throws(Failure) -> Element? { guard !Task.isCancelled else { // the task was cancelled while awaiting a willChange so ensure a proper termination return try terminate(id: id) @@ -246,7 +258,9 @@ public struct ObservationsShim: AsyncSequence try await next(isolation: #isolation) } - public mutating func next(isolation iterationIsolation: isolated (any Actor)?) async throws(Failure) -> Element? { + public mutating func next(isolation iterationIsolation: isolated (any Actor)?) + async throws(Failure) -> Element? + { // early exit if the sequence is terminal already guard let state else { return nil } // set up an id for this generation @@ -264,12 +278,14 @@ public struct ObservationsShim: AsyncSequence // this will mean our next await for the emission will ensure the suspension return of the willChange context // back to the trailing edges of the mutations. In short, this enables the transactionality bounded by the // isolation of the mutation. - await withIsolatedTaskCancellationHandler(operation: { - await State.willChange(isolation: iterationIsolation, state: state, id: id) - }, onCancel: { - // ensure to clean out our continuation uon cancellation - State.cancel(state, id: id) - }, isolation: iterationIsolation) + await withIsolatedTaskCancellationHandler( + operation: { + await State.willChange(isolation: iterationIsolation, state: state, id: id) + }, + onCancel: { + // ensure to clean out our continuation uon cancellation + State.cancel(state, id: id) + }, isolation: iterationIsolation) return try await trackEmission(isolation: iterationIsolation, state: state, id: id) } } catch { diff --git a/Sources/ObservationTesting/FulfillmentError.swift b/Sources/ObservationTesting/FulfillmentError.swift index e89fe12..f2bb6ea 100644 --- a/Sources/ObservationTesting/FulfillmentError.swift +++ b/Sources/ObservationTesting/FulfillmentError.swift @@ -8,22 +8,22 @@ import Foundation public enum FulfillmentError: Error & Equatable where Value: Sendable & Equatable { - case timedOut(remaining: [Value]) - case timedOutStrict(remaining: [Value]) - case timedOutWith(condition: String) + case timedOut(remaining: [Value]) + case timedOutStrict(remaining: [Value]) + case timedOutWith(condition: String) } extension FulfillmentError: LocalizedError { - public var errorDescription: String? { - switch self { - case let .timedOut(remaining): - let remainingStr = remaining.map({ "\($0)" }).joined(separator: ", ") - return "Timed out before `\(remainingStr)` was fulfilled." - case let .timedOutStrict(remaining): - let remainingStr = remaining.map({ "\($0)" }).joined(separator: ", ") - return "Timed out before `\(remainingStr)` was not fulfilled strictly at expected order." - case let .timedOutWith(condition): - return "Timed out because condition at \(condition) unmet" - } + public var errorDescription: String? { + switch self { + case .timedOut(let remaining): + let remainingStr = remaining.map({ "\($0)" }).joined(separator: ", ") + return "Timed out before `\(remainingStr)` was fulfilled." + case .timedOutStrict(let remaining): + let remainingStr = remaining.map({ "\($0)" }).joined(separator: ", ") + return "Timed out before `\(remainingStr)` was not fulfilled strictly at expected order." + case .timedOutWith(let condition): + return "Timed out because condition at \(condition) unmet" } + } } diff --git a/Sources/ObservationTesting/FulfillmentOfCondition.swift b/Sources/ObservationTesting/FulfillmentOfCondition.swift index 4f44ab2..ebf3079 100644 --- a/Sources/ObservationTesting/FulfillmentOfCondition.swift +++ b/Sources/ObservationTesting/FulfillmentOfCondition.swift @@ -5,54 +5,54 @@ // Copyright (c) 2026 Jacob Fielding // -public extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { - - /// Fulfill a specific condition. - /// - /// - Parameters: - /// - condition: - /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. - /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. - func fulfillment( - condition: @Sendable @isolated(any) @escaping (Element?) async throws -> Bool, - timeout: Duration? = nil, - execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {}, - file: StaticString = #file, - line: UInt = #line - ) async throws { - let worker = FulfillmentWorker( - timeout: timeout, - check: { - try await processCondition(condition: condition, file: file, line: line) - }, - testBehavior: testBehavior - ) - - try await worker.run() - } +extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { + + /// Fulfill a specific condition. + /// + /// - Parameters: + /// - condition: + /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. + /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. + public func fulfillment( + condition: @Sendable @isolated(any) @escaping (Element?) async throws -> Bool, + timeout: Duration? = nil, + execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {}, + file: StaticString = #file, + line: UInt = #line + ) async throws { + let worker = FulfillmentWorker( + timeout: timeout, + check: { + try await processCondition(condition: condition, file: file, line: line) + }, + testBehavior: testBehavior + ) + + try await worker.run() + } } extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { - func processCondition( - condition: @Sendable @isolated(any) @escaping (Element?) async throws -> Bool, - file: StaticString = #file, - line: UInt = #line - ) async throws { - var iterator = self.makeAsyncIterator() - - while !Task.isCancelled { - let value = try await iterator.next() - - if try await condition(value) { - return - } - } - - do { - try Task.checkCancellation() - } catch { - throw FulfillmentError.timedOutWith(condition: "\(file):\(line)") - } + func processCondition( + condition: @Sendable @isolated(any) @escaping (Element?) async throws -> Bool, + file: StaticString = #file, + line: UInt = #line + ) async throws { + var iterator = self.makeAsyncIterator() + + while !Task.isCancelled { + let value = try await iterator.next() + + if try await condition(value) { + return + } + } + + do { + try Task.checkCancellation() + } catch { + throw FulfillmentError.timedOutWith(condition: "\(file):\(line)") } + } } diff --git a/Sources/ObservationTesting/FulfillmentOfValues.swift b/Sources/ObservationTesting/FulfillmentOfValues.swift index faede45..2404589 100644 --- a/Sources/ObservationTesting/FulfillmentOfValues.swift +++ b/Sources/ObservationTesting/FulfillmentOfValues.swift @@ -5,81 +5,81 @@ // Copyright (c) 2026 Jacob Fielding // -public extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { +extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { - /// Fulfill one or more values. - /// - /// - Parameters: - /// - values: One or values that must be emitted by the stream to succeed. - /// - strict: If true, values must emitted in order. - /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. - /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. - func fulfillment( - of values: Element..., - strict: Bool = false, - timeout: Duration? = nil, - execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {} - ) async throws { - let worker = FulfillmentWorker( - timeout: timeout, - check: { - try await processFulfillment(values: values, strict: strict) - }, - testBehavior: testBehavior - ) + /// Fulfill one or more values. + /// + /// - Parameters: + /// - values: One or values that must be emitted by the stream to succeed. + /// - strict: If true, values must emitted in order. + /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. + /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. + public func fulfillment( + of values: Element..., + strict: Bool = false, + timeout: Duration? = nil, + execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {} + ) async throws { + let worker = FulfillmentWorker( + timeout: timeout, + check: { + try await processFulfillment(values: values, strict: strict) + }, + testBehavior: testBehavior + ) - try await worker.run() - } + try await worker.run() + } - /// Fulfill an array of values. - /// - /// - Parameters: - /// - values: One or values that must be emitted by the stream to succeed. - /// - strict: If true, values must emitted in order. - /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. - /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. - func fulfillment( - values: [Element], - strict: Bool = false, - timeout: Duration? = nil, - execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {} - ) async throws { - let worker = FulfillmentWorker( - timeout: timeout, - check: { - try await processFulfillment(values: values, strict: strict) - }, - testBehavior: testBehavior - ) + /// Fulfill an array of values. + /// + /// - Parameters: + /// - values: One or values that must be emitted by the stream to succeed. + /// - strict: If true, values must emitted in order. + /// - timeout: An optional timeout that will kill the function if fulfillment is unsuccessful by then. + /// - testBehavior: A test behavior to execute. Use this to execute state updates _after_ the stream is being listened to. + public func fulfillment( + values: [Element], + strict: Bool = false, + timeout: Duration? = nil, + execute testBehavior: @Sendable @isolated(any) @escaping () async throws -> Void = {} + ) async throws { + let worker = FulfillmentWorker( + timeout: timeout, + check: { + try await processFulfillment(values: values, strict: strict) + }, + testBehavior: testBehavior + ) - try await worker.run() - } + try await worker.run() + } } extension AsyncSequence where Self: Sendable, Element: Sendable & Equatable { - func processFulfillment(values: [Element], strict: Bool) async throws { - var remainingValues = values - var iterator = self.makeAsyncIterator() + func processFulfillment(values: [Element], strict: Bool) async throws { + var remainingValues = values + var iterator = self.makeAsyncIterator() - while !Task.isCancelled, !remainingValues.isEmpty { - let value = try await iterator.next() + while !Task.isCancelled, !remainingValues.isEmpty { + let value = try await iterator.next() - if strict && remainingValues.first == value { - remainingValues.remove(at: 0) - } else if !strict, let index = remainingValues.firstIndex(where: { $0 == value }) { - remainingValues.remove(at: index) - } - } + if strict && remainingValues.first == value { + remainingValues.remove(at: 0) + } else if !strict, let index = remainingValues.firstIndex(where: { $0 == value }) { + remainingValues.remove(at: index) + } + } - do { - try Task.checkCancellation() - } catch { - if strict { - throw FulfillmentError.timedOutStrict(remaining: remainingValues) - } else { - throw FulfillmentError.timedOut(remaining: remainingValues) - } - } + do { + try Task.checkCancellation() + } catch { + if strict { + throw FulfillmentError.timedOutStrict(remaining: remainingValues) + } else { + throw FulfillmentError.timedOut(remaining: remainingValues) + } } + } } diff --git a/Sources/ObservationTesting/FulfillmentWorker.swift b/Sources/ObservationTesting/FulfillmentWorker.swift index 1b93bb0..7feb510 100644 --- a/Sources/ObservationTesting/FulfillmentWorker.swift +++ b/Sources/ObservationTesting/FulfillmentWorker.swift @@ -6,39 +6,39 @@ // struct FulfillmentWorker { - - private let timeout: Duration? - private let check: @Sendable () async throws -> Void - private let testBehavior: @Sendable () async throws -> Void - - init( - timeout: Duration?, - check: @Sendable @escaping () async throws -> Void, - testBehavior: @Sendable @escaping () async throws -> Void - ) { - self.timeout = timeout - self.check = check - self.testBehavior = testBehavior + + private let timeout: Duration? + private let check: @Sendable () async throws -> Void + private let testBehavior: @Sendable () async throws -> Void + + init( + timeout: Duration?, + check: @Sendable @escaping () async throws -> Void, + testBehavior: @Sendable @escaping () async throws -> Void + ) { + self.timeout = timeout + self.check = check + self.testBehavior = testBehavior + } + + func run() async throws { + let checkTask = Task { + try await check() } - - func run() async throws { - let checkTask = Task { - try await check() - } - - if let timeout { - Task.detached(priority: .high) { - try await Task.sleep(for: timeout) - checkTask.cancel() - } - } - - let behaviorTask = Task.detached(priority: .high) { - try await Task.sleep(for: .nanoseconds(50)) - try await self.testBehavior() - } - - try await behaviorTask.value - try await checkTask.value + + if let timeout { + Task.detached(priority: .high) { + try await Task.sleep(for: timeout) + checkTask.cancel() + } } + + let behaviorTask = Task.detached(priority: .high) { + try await Task.sleep(for: .nanoseconds(50)) + try await self.testBehavior() + } + + try await behaviorTask.value + try await checkTask.value + } } diff --git a/Tests/ObservationShimTests/TestObservationShim.swift b/Tests/ObservationShimTests/TestObservationShim.swift index 10185a7..d59ebb0 100644 --- a/Tests/ObservationShimTests/TestObservationShim.swift +++ b/Tests/ObservationShimTests/TestObservationShim.swift @@ -6,44 +6,45 @@ // import Observation -import Testing import ObservationTesting +import Testing + @testable import ObservationShim @MainActor @Observable final class Foo { - var bar: String? - var baz: Int? - - func set(bar: String) { - self.bar = bar - } - - func set(baz: Int) { - self.baz = baz - } + var bar: String? + var baz: Int? + + func set(bar: String) { + self.bar = bar + } + + func set(baz: Int) { + self.baz = baz + } } struct TestObservationShim { - - @MainActor - @Test("A basic legacy stream on iOS 17") - func basic() async throws { - let foo = Foo() - - let stream = ObservationsShim { - foo.bar - } - - Task.detached { - await foo.set(bar: "cats") - try await Task.sleep(for: .microseconds(500)) - await foo.set(bar: "dogs") - try await Task.sleep(for: .milliseconds(500)) - await foo.set(bar: "lizards") - } - - try await stream.fulfillment(of: "cats", "dogs", "lizards") + + @MainActor + @Test("A basic legacy stream on iOS 17") + func basic() async throws { + let foo = Foo() + + let stream = ObservationsShim { + foo.bar } + + Task.detached { + await foo.set(bar: "cats") + try await Task.sleep(for: .microseconds(500)) + await foo.set(bar: "dogs") + try await Task.sleep(for: .milliseconds(500)) + await foo.set(bar: "lizards") + } + + try await stream.fulfillment(of: "cats", "dogs", "lizards") + } } diff --git a/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift b/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift index e09a3b7..aa7330b 100644 --- a/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift +++ b/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift @@ -6,48 +6,57 @@ // import Foundation -import Testing import Numerics +import Testing + @testable import ObservationTesting struct TestFillmentOfConditions { - @Test("A condition is verified on the stream") - func conditionMet() async throws { - let stream = AsyncStream { continuation in - continuation.yield("cats") - } - - try await stream.fulfillment { value in - value == "cats" - } + @Test("A condition is verified on the stream") + func conditionMet() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + try await stream.fulfillment { value in + value == "cats" } + } + + @Test("A value is not emitted from the stream") + func conditionNotMet() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment( + condition: { value in + value == "dogs" + }, + timeout: .seconds(1) + ) + + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect( + error + == .timedOutWith( + condition: "ObservationTestingTests/TestFulfillmentOfConditions.swift:28")) + #expect( + error.localizedDescription + == "Timed out because condition at ObservationTestingTests/TestFulfillmentOfConditions.swift:28 unmet" + ) - @Test("A value is not emitted from the stream") - func conditionNotMet() async throws { - let stream = AsyncStream { continuation in - continuation.yield("cats") - } - - let executionStart = Date() - - do { - try await stream.fulfillment( - condition: { value in - value == "dogs" - }, - timeout: .seconds(1) - ) - - Issue.record("Function should fail with a throw") - } catch let error as FulfillmentError { - #expect(error == .timedOutWith(condition: "ObservationTestingTests/TestFulfillmentOfConditions.swift:28")) - #expect(error.localizedDescription == "Timed out because condition at ObservationTestingTests/TestFulfillmentOfConditions.swift:28 unmet") - - let duration = Date().timeIntervalSince(executionStart) - #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") - } catch { - Issue.record("Unexpected error type: \(error)") - } + let duration = Date().timeIntervalSince(executionStart) + #expect( + duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), + "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") } + } } diff --git a/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift b/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift index 2252357..a9de316 100644 --- a/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift +++ b/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift @@ -6,73 +6,80 @@ // import Foundation -import Testing import Numerics +import Testing + @testable import ObservationTesting struct TestFulfillmentOfValues { - @Test("A value is emitted from the stream") - func basic() async throws { - let stream = AsyncStream { continuation in - continuation.yield("cats") - } - - try await stream.fulfillment(of: "cats") + @Test("A value is emitted from the stream") + func basic() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") } - @Test("A value is not emitted from the stream") - func failure() async throws { - let stream = AsyncStream { continuation in - continuation.yield("cats") - } - - let executionStart = Date() - - do { - try await stream.fulfillment(of: "dogs", timeout: .seconds(1)) - Issue.record("Function should fail with a throw") - } catch let error as FulfillmentError { - #expect(error == .timedOut(remaining: ["dogs"])) - #expect(error.localizedDescription == "Timed out before `dogs` was fulfilled.") - - let duration = Date().timeIntervalSince(executionStart) - #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") - } catch { - Issue.record("Unexpected error type: \(error)") - } + try await stream.fulfillment(of: "cats") + } + + @Test("A value is not emitted from the stream") + func failure() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") } - - @Test("A value is emitted in the stream in strict order") - func strict() async throws { - let stream = AsyncStream { continuation in - continuation.yield("dogs") - continuation.yield("cats") - } - - try await stream.fulfillment(of: "dogs", "cats", strict: true, timeout: .seconds(1)) + + let executionStart = Date() + + do { + try await stream.fulfillment(of: "dogs", timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOut(remaining: ["dogs"])) + #expect(error.localizedDescription == "Timed out before `dogs` was fulfilled.") + + let duration = Date().timeIntervalSince(executionStart) + #expect( + duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), + "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") } - - @Test("Strictly checked fails when order out of alignment") - func strictlyOutOfOrder() async throws { - let stream = AsyncStream { continuation in - continuation.yield("dogs") - continuation.yield("cats") - } - - let executionStart = Date() - - do { - try await stream.fulfillment(of: "cats", "dogs", strict: true, timeout: .seconds(1)) - Issue.record("Function should fail with a throw") - } catch let error as FulfillmentError { - #expect(error == .timedOutStrict(remaining: ["dogs"])) - #expect(error.localizedDescription == "Timed out before `dogs` was not fulfilled strictly at expected order.") - - let duration = Date().timeIntervalSince(executionStart) - #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") - } catch { - Issue.record("Unexpected error type: \(error)") - } + } + + @Test("A value is emitted in the stream in strict order") + func strict() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") + } + + try await stream.fulfillment(of: "dogs", "cats", strict: true, timeout: .seconds(1)) + } + + @Test("Strictly checked fails when order out of alignment") + func strictlyOutOfOrder() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment(of: "cats", "dogs", strict: true, timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOutStrict(remaining: ["dogs"])) + #expect( + error.localizedDescription + == "Timed out before `dogs` was not fulfilled strictly at expected order.") + + let duration = Date().timeIntervalSince(executionStart) + #expect( + duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), + "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") } + } } diff --git a/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift b/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift index f17d359..ba126e1 100644 --- a/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift +++ b/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift @@ -5,75 +5,81 @@ // Copyright (c) 2026 Jacob Fielding // - import Foundation -import Testing import Numerics +import Testing + @testable import ObservationTesting struct TestFulfillmentOfValuesByArray { - @Test("A value is emitted from the stream") - func basic() async throws { - let stream = AsyncStream { continuation in - continuation.yield("cats") - } - - try await stream.fulfillment(values: ["cats"]) + @Test("A value is emitted from the stream") + func basic() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + try await stream.fulfillment(values: ["cats"]) + } + + @Test("A value is not emitted from the stream") + func failure() async throws { + let stream = AsyncStream { continuation in + continuation.yield("cats") + } + + let executionStart = Date() + + do { + try await stream.fulfillment(values: ["dogs"], timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOut(remaining: ["dogs"])) + #expect(error.localizedDescription == "Timed out before `dogs` was fulfilled.") + + let duration = Date().timeIntervalSince(executionStart) + #expect( + duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), + "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") } + } - @Test("A value is not emitted from the stream") - func failure() async throws { - let stream = AsyncStream { continuation in - continuation.yield("cats") - } - - let executionStart = Date() - - do { - try await stream.fulfillment(values: ["dogs"], timeout: .seconds(1)) - Issue.record("Function should fail with a throw") - } catch let error as FulfillmentError { - #expect(error == .timedOut(remaining: ["dogs"])) - #expect(error.localizedDescription == "Timed out before `dogs` was fulfilled.") - - let duration = Date().timeIntervalSince(executionStart) - #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") - } catch { - Issue.record("Unexpected error type: \(error)") - } + @Test("A value is emitted in the stream in strict order") + func strict() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") } - - @Test("A value is emitted in the stream in strict order") - func strict() async throws { - let stream = AsyncStream { continuation in - continuation.yield("dogs") - continuation.yield("cats") - } - - try await stream.fulfillment(values: ["dogs", "cats"], strict: true, timeout: .seconds(1)) + + try await stream.fulfillment(values: ["dogs", "cats"], strict: true, timeout: .seconds(1)) + } + + @Test("Strictly checked fails when order out of alignment") + func strictlyOutOfOrder() async throws { + let stream = AsyncStream { continuation in + continuation.yield("dogs") + continuation.yield("cats") } - - @Test("Strictly checked fails when order out of alignment") - func strictlyOutOfOrder() async throws { - let stream = AsyncStream { continuation in - continuation.yield("dogs") - continuation.yield("cats") - } - - let executionStart = Date() - - do { - try await stream.fulfillment(values: ["cats", "dogs"], strict: true, timeout: .seconds(1)) - Issue.record("Function should fail with a throw") - } catch let error as FulfillmentError { - #expect(error == .timedOutStrict(remaining: ["dogs"])) - #expect(error.localizedDescription == "Timed out before `dogs` was not fulfilled strictly at expected order.") - - let duration = Date().timeIntervalSince(executionStart) - #expect(duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), "timeout is close to expected runtime") - } catch { - Issue.record("Unexpected error type: \(error)") - } + + let executionStart = Date() + + do { + try await stream.fulfillment(values: ["cats", "dogs"], strict: true, timeout: .seconds(1)) + Issue.record("Function should fail with a throw") + } catch let error as FulfillmentError { + #expect(error == .timedOutStrict(remaining: ["dogs"])) + #expect( + error.localizedDescription + == "Timed out before `dogs` was not fulfilled strictly at expected order.") + + let duration = Date().timeIntervalSince(executionStart) + #expect( + duration.isApproximatelyEqual(to: 1.0, relativeTolerance: 0.2), + "timeout is close to expected runtime") + } catch { + Issue.record("Unexpected error type: \(error)") } + } } From 2bbb4a4245d35953f7911b08525c8f5256dec521 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 24 Feb 2026 19:45:43 -0800 Subject: [PATCH 4/7] feat: init repo --- Sources/ObservationShim/ObservationsShim.swift | 4 ++-- .../ObservationTestingTests/TestFulfillmentOfConditions.swift | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/Sources/ObservationShim/ObservationsShim.swift b/Sources/ObservationShim/ObservationsShim.swift index 1d00c62..a1f5f2d 100644 --- a/Sources/ObservationShim/ObservationsShim.swift +++ b/Sources/ObservationShim/ObservationsShim.swift @@ -15,12 +15,12 @@ import _Concurrency @usableFromInline @_silgen_name("swift_task_addCancellationHandler") func _taskAddCancellationHandler(handler: () -> Void) - -> UnsafeRawPointer /*CancellationNotificationStatusRecord*/ + -> UnsafeRawPointer @usableFromInline @_silgen_name("swift_task_removeCancellationHandler") func _taskRemoveCancellationHandler( - record: UnsafeRawPointer /*CancellationNotificationStatusRecord*/ + record: UnsafeRawPointer ) func withIsolatedTaskCancellationHandler( diff --git a/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift b/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift index aa7330b..5793fa7 100644 --- a/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift +++ b/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift @@ -45,10 +45,10 @@ struct TestFillmentOfConditions { #expect( error == .timedOutWith( - condition: "ObservationTestingTests/TestFulfillmentOfConditions.swift:28")) + condition: "ObservationTestingTests/TestFulfillmentOfConditions.swift:36")) #expect( error.localizedDescription - == "Timed out because condition at ObservationTestingTests/TestFulfillmentOfConditions.swift:28 unmet" + == "Timed out because condition at ObservationTestingTests/TestFulfillmentOfConditions.swift:36 unmet" ) let duration = Date().timeIntervalSince(executionStart) From 945d639831625f44a943d5fa73a0e38b3d1abd7e Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 24 Feb 2026 19:47:07 -0800 Subject: [PATCH 5/7] feat: init repo --- .github/workflows/ci.yml | 9 ++------- 1 file changed, 2 insertions(+), 7 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index a191f2b..ca3bd9d 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -29,25 +29,20 @@ jobs: include: - name: "iOS" destination: "platform=iOS Simulator,name=iPhone 16,OS=26.2" - action: test - name: "macOS" destination: "platform=macOS" - action: test - name: "Mac Catalyst" destination: "platform=macOS,variant=Mac Catalyst" - action: test - name: "tvOS" destination: "platform=tvOS Simulator,name=Apple TV,OS=26.2" - action: test - name: "visionOS" destination: "platform=visionOS Simulator,name=Apple Vision Pro,OS=26.2" - action: test - name: "watchOS" destination: "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm),OS=26.2" - action: build + steps: - uses: actions/checkout@v4 - name: Build & Test run: | - xcodebuild ${{ matrix.action }} -scheme ObservationKit -destination '${{ matrix.destination }}' + xcodebuild ${{ matrix.action }} -scheme ObservationKit-Package -destination '${{ matrix.destination }}' From 1b161c43ff2404cbc09053707ca3f870334f3c1f Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 24 Feb 2026 19:50:01 -0800 Subject: [PATCH 6/7] feat: init repo --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index ca3bd9d..1dff488 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -38,7 +38,7 @@ jobs: - name: "visionOS" destination: "platform=visionOS Simulator,name=Apple Vision Pro,OS=26.2" - name: "watchOS" - destination: "platform=watchOS Simulator,name=Apple Watch Series 10 (46mm),OS=26.2" + destination: "platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.2" steps: - uses: actions/checkout@v4 From f92c86289b3a3b5db9926587ef9b0748763180f4 Mon Sep 17 00:00:00 2001 From: Jacob Fielding Date: Tue, 24 Feb 2026 19:52:07 -0800 Subject: [PATCH 7/7] feat: init repo --- .github/workflows/ci.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 1dff488..d03199a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -28,7 +28,7 @@ jobs: matrix: include: - name: "iOS" - destination: "platform=iOS Simulator,name=iPhone 16,OS=26.2" + destination: "platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2" - name: "macOS" destination: "platform=macOS" - name: "Mac Catalyst"