diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..d03199a
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,48 @@
+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-26
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Check Formatting
+ run: swift format lint --strict --parallel --recursive Sources Tests
+
+ build-and-test:
+ name: ${{ matrix.name }}
+ runs-on: macos-26
+ strategy:
+ fail-fast: false
+ matrix:
+ include:
+ - name: "iOS"
+ destination: "platform=iOS Simulator,name=iPhone 17 Pro Max,OS=26.2"
+ - name: "macOS"
+ destination: "platform=macOS"
+ - name: "Mac Catalyst"
+ destination: "platform=macOS,variant=Mac Catalyst"
+ - name: "tvOS"
+ destination: "platform=tvOS Simulator,name=Apple TV,OS=26.2"
+ - name: "visionOS"
+ destination: "platform=visionOS Simulator,name=Apple Vision Pro,OS=26.2"
+ - name: "watchOS"
+ destination: "platform=watchOS Simulator,name=Apple Watch Series 11 (46mm),OS=26.2"
+
+ steps:
+ - uses: actions/checkout@v4
+
+ - name: Build & Test
+ run: |
+ xcodebuild ${{ matrix.action }} -scheme ObservationKit-Package -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/.gitignore b/.gitignore
new file mode 100644
index 0000000..ae43ed6
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,9 @@
+.DS_Store
+/.build
+/Packages
+xcuserdata/
+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/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/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
new file mode 100644
index 0000000..bedce55
--- /dev/null
+++ b/Package.swift
@@ -0,0 +1,64 @@
+// 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",
+ platforms: [
+ .iOS(.v17),
+ .macCatalyst(.v17),
+ .macOS(.v14),
+ .tvOS(.v17),
+ .visionOS(.v1),
+ .watchOS(.v10)
+ ],
+ products: [
+ .library(
+ 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: [
+ .target(
+ 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: "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/ObservationShim/ManagedCriticalState.swift b/Sources/ObservationShim/ManagedCriticalState.swift
new file mode 100644
index 0000000..5627c21
--- /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..a1f5f2d
--- /dev/null
+++ b/Sources/ObservationShim/ObservationsShim.swift
@@ -0,0 +1,301 @@
+//===----------------------------------------------------------------------===//
+//
+// 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
+
+@usableFromInline
+@_silgen_name("swift_task_removeCancellationHandler")
+func _taskRemoveCancellationHandler(
+ record: UnsafeRawPointer
+)
+
+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..f2bb6ea
--- /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 .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
new file mode 100644
index 0000000..ebf3079
--- /dev/null
+++ b/Sources/ObservationTesting/FulfillmentOfCondition.swift
@@ -0,0 +1,58 @@
+//
+// FulfillmentOfCondition.swift
+// ObservationTesting
+//
+// Copyright (c) 2026 Jacob Fielding
+//
+
+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)")
+ }
+ }
+}
diff --git a/Sources/ObservationTesting/FulfillmentOfValues.swift b/Sources/ObservationTesting/FulfillmentOfValues.swift
new file mode 100644
index 0000000..2404589
--- /dev/null
+++ b/Sources/ObservationTesting/FulfillmentOfValues.swift
@@ -0,0 +1,85 @@
+//
+// FulfillmentOfValues.swift
+// ObservationTesting
+//
+// Copyright (c) 2026 Jacob Fielding
+//
+
+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.
+ 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()
+ }
+
+ /// 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()
+ }
+}
+
+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..7feb510
--- /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/ObservationShimTests/TestObservationShim.swift b/Tests/ObservationShimTests/TestObservationShim.swift
new file mode 100644
index 0000000..d59ebb0
--- /dev/null
+++ b/Tests/ObservationShimTests/TestObservationShim.swift
@@ -0,0 +1,50 @@
+//
+// TestObservationShim.swift
+// ObservationShimTests
+//
+// Copyright (c) 2026 Jacob Fielding
+//
+
+import Observation
+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
+ }
+}
+
+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..5793fa7
--- /dev/null
+++ b/Tests/ObservationTestingTests/TestFulfillmentOfConditions.swift
@@ -0,0 +1,62 @@
+//
+// TestFulfillmentOfConditions.swift
+// ObservationTestingTests
+//
+// Copyright (c) 2026 Jacob Fielding
+//
+
+import Foundation
+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 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:36"))
+ #expect(
+ error.localizedDescription
+ == "Timed out because condition at ObservationTestingTests/TestFulfillmentOfConditions.swift:36 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..a9de316
--- /dev/null
+++ b/Tests/ObservationTestingTests/TestFulfillmentOfValues.swift
@@ -0,0 +1,85 @@
+//
+// TestFulfillmentOfValues.swift
+// ObservationTestingTests
+//
+// Copyright (c) 2026 Jacob Fielding
+//
+
+import Foundation
+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 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..ba126e1
--- /dev/null
+++ b/Tests/ObservationTestingTests/TestFulfillmentOfValuesByArray.swift
@@ -0,0 +1,85 @@
+//
+// TestFulfillmentOfValuesByArray.swift
+// ObservationTestingTests
+//
+// Copyright (c) 2026 Jacob Fielding
+//
+
+import Foundation
+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 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)")
+ }
+ }
+}