Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
47 changes: 47 additions & 0 deletions Documentation/SwiftUIHostingTests.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
# SwiftUI hosting smoke tests

`KMPObservableBridgeSwiftUIHostingTests` mounts two independent projected
fields in real `UIHostingController` or `NSHostingController` view graphs. The
suite records each subtree's body evaluation and keeps the two fields in
separate `View` identities.

The smoke tests cover:

- field B invalidation without reevaluating the field A-only subtree on iOS
17+ and macOS 14+;
- global invalidation of both projected-field subtrees;
- the production `ObservableObject` fallback forced on a current runtime;
- the real runtime fallback when the same test bundle runs on iOS 15 or iOS
16; and
- one shared observation setup across the two identities plus deterministic
cancellation after the hosted content is removed.

The tests use positive render and cancellation signals. Timeouts are failure
bounds only; the suite does not use inverted expectations or sleeps to infer
that an unrelated body was not evaluated.

## Toolchain and destinations

The package supports Xcode 15 or newer because its baseline manifest requires
Swift 5.9. The hosting suite is currently verified with Xcode 26.5.

Run the macOS host through the package's required validation command:

```sh
swift test \
--filter KMPObservableBridgeSwiftUIHostingTests \
-Xswiftc -strict-concurrency=complete \
-Xswiftc -warnings-as-errors
```

For an iOS simulator, open `Package.swift` in the supported Xcode version,
select the `KMPObservableBridge-Package` scheme, choose an iOS simulator, and
run `KMPObservableBridgeSwiftUIHostingTests`.

- Use an iOS 17 or newer destination for the field-level Observation path.
- Use an iOS 15 or iOS 16 destination for the real runtime fallback contract.

The forced-fallback test runs on current macOS and iOS runtimes, so the
fallback rendering contract remains covered even when an iOS 15/16 runtime is
not installed. The real-runtime fallback test reports an explicit skip on
Observation-capable destinations.
5 changes: 5 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,11 @@ swift test \
./Scripts/check-package-manifests.sh
```

The Apple-platform
[SwiftUI hosting smoke tests](Documentation/SwiftUIHostingTests.md) document
their Xcode requirement, simulator destinations, runtime fallback coverage,
render-count probes, and teardown contract.

Build the real integration fixture without forcing the macro target onto the
iOS SDK:

Expand Down
77 changes: 77 additions & 0 deletions Tests/KMPObservableBridgeTests/AppleHostingHarness.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
#if os(iOS) || os(macOS)
import SwiftUI

#if os(iOS)
import UIKit
#elseif os(macOS)
import AppKit
#endif

@MainActor
final class AppleHostingHarness {
#if os(iOS)
private let window: UIWindow
private let controller: UIHostingController<AnyView>
#elseif os(macOS)
private let window: NSWindow
private let controller: NSHostingController<AnyView>
#endif

init<Content: View>(rootView: Content) {
#if os(iOS)
controller = UIHostingController(rootView: AnyView(rootView))
window = UIWindow(
frame: CGRect(x: 0, y: 0, width: 320, height: 480)
)
window.rootViewController = controller
window.makeKeyAndVisible()
layoutIfNeeded()
#elseif os(macOS)
_ = NSApplication.shared
controller = NSHostingController(rootView: AnyView(rootView))
window = NSWindow(
contentRect: NSRect(x: 0, y: 0, width: 320, height: 480),
styleMask: [.borderless],
backing: .buffered,
defer: false
)
window.contentViewController = controller
window.orderFrontRegardless()
layoutIfNeeded()
#endif
}

func flushPendingUpdates() async {
await withCheckedContinuation { continuation in
DispatchQueue.main.async { [self] in
layoutIfNeeded()
DispatchQueue.main.async {
continuation.resume()
}
}
}
}

func removeContent() {
controller.rootView = AnyView(EmptyView())
layoutIfNeeded()
#if os(iOS)
window.rootViewController = nil
window.isHidden = true
#elseif os(macOS)
window.contentViewController = nil
window.orderOut(nil)
#endif
}

private func layoutIfNeeded() {
#if os(iOS)
controller.view.setNeedsLayout()
controller.view.layoutIfNeeded()
#elseif os(macOS)
controller.view.needsLayout = true
controller.view.layoutSubtreeIfNeeded()
#endif
}
}
#endif
157 changes: 157 additions & 0 deletions Tests/KMPObservableBridgeTests/HostingObservationFixture.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,157 @@
#if os(iOS) || os(macOS)
import Foundation
@testable import KMPObservableBridge

enum HostedField: Hashable {
case first
case second
}

enum HostingObservationMode {
case runtime
case forcedFallback
}

@MainActor
struct HostingContext {
let model: HostingModel
let probe: HostingRenderProbe
let harness: AppleHostingHarness
}

@MainActor
final class HostingRenderProbe {
private var counts: [HostedField: Int] = [:]
private var nextRender: [HostedField: () -> Void] = [:]

func renderCount(for field: HostedField) -> Int {
counts[field, default: 0]
}

func onNextRender(
of field: HostedField,
perform action: @escaping () -> Void
) {
precondition(nextRender[field] == nil)
nextRender[field] = action
}

func record(_ field: HostedField, value: Int) -> String {
counts[field, default: 0] += 1
let action = nextRender.removeValue(forKey: field)
action?()
return "\(value)"
}
}

final class HostingSignal:
KMPValueProperty,
@unchecked Sendable
{
private let lock = NSLock()
private var currentValue: Int
@MainActor private var observers: [UInt: KMPObservationNotify] = [:]
@MainActor private var nextObserverID: UInt = 0
@MainActor private(set) var startedCount = 0
@MainActor private(set) var stoppedCount = 0
@MainActor var onStop: (() -> Void)?

var value: Int {
lock.withLock {
currentValue
}
}

@MainActor
var activeObserverCount: Int {
observers.count
}

init(_ value: Int) {
currentValue = value
}

@MainActor
func observe(
_ notify: @escaping KMPObservationNotify
) -> KMPObservation {
nextObserverID &+= 1
let observerID = nextObserverID
observers[observerID] = notify
startedCount += 1

return KMPObservation { [weak self] in
MainActor.assumeIsolated {
self?.removeObserver(observerID)
}
}
}

@MainActor
func emit(_ value: Int) {
lock.withLock {
currentValue = value
}
let callbacks = Array(observers.values)
callbacks.forEach { $0() }
}

@MainActor
private func removeObserver(_ observerID: UInt) {
guard observers.removeValue(forKey: observerID) != nil else {
return
}
stoppedCount += 1
let action = onStop
onStop = nil
action?()
}
}

@MainActor
final class HostingModel: KMPStaticallyObservable {
let first = HostingSignal(0)
let second = HostingSignal(0)
let global = HostingSignal(0)

static var kmpObservationPlan: KMPObservationPlan<HostingModel> {
KMPObservationPlan(
KMPState(dependency: .field(\HostingModel.first)) {
model, notify, _ in
model.first.observe(notify)
},
KMPState(dependency: .field(\HostingModel.second)) {
model, notify, _ in
model.second.observe(notify)
},
KMPState(dependency: .global) { model, notify, _ in
model.global.observe(notify)
}
)
}

static func kmpStartObservation(
on model: HostingModel,
notify: @escaping KMPObservationNotify,
reportError: @escaping KMPObservationErrorHandler
) -> KMPObservation {
kmpObservationPlan.startObservation(
on: model,
notify: notify,
reportError: reportError
)
}

static func kmpStartObservation(
on model: HostingModel,
notifyDependency: @escaping KMPObservationDependencyNotify,
reportError: @escaping KMPObservationErrorHandler
) -> KMPObservation {
kmpObservationPlan.observeDependencies(
on: model,
notifyDependency: notifyDependency,
reportError: reportError
)
}
}
#endif
Loading
Loading