From 42e1e281206b9de47fd7a5b07b44f10bb5669e36 Mon Sep 17 00:00:00 2001 From: Woodrow Melling Date: Thu, 14 May 2026 14:46:13 -0600 Subject: [PATCH 1/2] Add SwiftUI observation mount tests --- Package.swift | 10 ++++ .../ObservationProbeMount.swift | 53 +++++++++++++++++++ Tests/SkipSwiftUITestSupport/Skip/skip.yml | 6 +++ Tests/SkipSwiftUITests/Skip/skip.yml | 17 ++++++ Tests/SkipSwiftUITests/SkipSwiftUITests.swift | 42 ++++++++++++++- Tests/SkipSwiftUITests/XCSkipTests.swift | 7 ++- 6 files changed, 131 insertions(+), 4 deletions(-) create mode 100644 Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift create mode 100644 Tests/SkipSwiftUITestSupport/Skip/skip.yml diff --git a/Package.swift b/Package.swift index 2927bd6..720376a 100644 --- a/Package.swift +++ b/Package.swift @@ -9,6 +9,7 @@ let package = Package( products: [ .library(name: "SkipFuseUI", type: .dynamic, targets: ["SkipFuseUI"] + (android ? ["SwiftUI"] : [])), .library(name: "SkipSwiftUI", type: .dynamic, targets: ["SkipSwiftUI"]), + .library(name: "SkipSwiftUITestSupport", type: .dynamic, targets: ["SkipSwiftUITestSupport"]), ], dependencies: [ .package(url: "https://source.skip.tools/skip.git", from: "1.7.4"), @@ -27,8 +28,17 @@ let package = Package( .product(name: "SwiftJNI", package: "swift-jni"), .product(name: "SkipUI", package: "skip-ui") ], plugins: [.plugin(name: "skipstone", package: "skip")]), + .target( + name: "SkipSwiftUITestSupport", + dependencies: [ + .product(name: "SkipBridge", package: "skip-bridge"), + ], + path: "Tests/SkipSwiftUITestSupport", + plugins: [.plugin(name: "skipstone", package: "skip")] + ), .testTarget(name: "SkipSwiftUITests", dependencies: [ "SkipSwiftUI", + "SkipSwiftUITestSupport", .product(name: "SkipTest", package: "skip") ], plugins: [.plugin(name: "skipstone", package: "skip")]), ] diff --git a/Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift b/Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift new file mode 100644 index 0000000..694e07e --- /dev/null +++ b/Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift @@ -0,0 +1,53 @@ +// Copyright 2025–2026 Skip +// SPDX-License-Identifier: MPL-2.0 +import Observation +import SkipBridge + +public final class ObservationProbeMount { + private let box: any ObservationProbeMountBox + + public init() { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + self.box = ObservableObservationProbeMountBox() + } else { + self.box = UnsupportedObservationProbeMountBox() + } + } + + public func setCount(_ count: Int) { + self.box.setCount(count) + } + + public func renderedText() -> String { + "count: \(self.box.count)" + } +} + +private protocol ObservationProbeMountBox: AnyObject { + var count: Int { get } + func setCount(_ count: Int) +} + +private final class UnsupportedObservationProbeMountBox: ObservationProbeMountBox { + var count: Int { 0 } + func setCount(_ count: Int) {} +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +private final class ObservableObservationProbeMountBox: ObservationProbeMountBox { + private let model = ObservationProbeModel() + + var count: Int { + self.model.count + } + + func setCount(_ count: Int) { + self.model.count = count + } +} + +@available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) +@Observable +private final class ObservationProbeModel { + var count = 0 +} diff --git a/Tests/SkipSwiftUITestSupport/Skip/skip.yml b/Tests/SkipSwiftUITestSupport/Skip/skip.yml new file mode 100644 index 0000000..ddfe1df --- /dev/null +++ b/Tests/SkipSwiftUITestSupport/Skip/skip.yml @@ -0,0 +1,6 @@ +# Configuration file for Skip (https://skip.dev) project +skip: + mode: 'native' + bridging: + auto: true + options: 'kotlincompat' diff --git a/Tests/SkipSwiftUITests/Skip/skip.yml b/Tests/SkipSwiftUITests/Skip/skip.yml index e69de29..c649c1e 100644 --- a/Tests/SkipSwiftUITests/Skip/skip.yml +++ b/Tests/SkipSwiftUITests/Skip/skip.yml @@ -0,0 +1,17 @@ +build: + contents: + - block: 'dependencies' + contents: + - 'testImplementation(libs.androidx.compose.ui.test)' + - 'testImplementation(libs.androidx.compose.ui.test.junit4)' + - 'testImplementation(libs.androidx.compose.ui.test.manifest)' + - block: 'tasks.named("buildLocalSwiftPackage")' + contents: + - block: 'doLast' + contents: + - block: 'project.objects.newInstance().execOps.exec' + contents: + - 'workingDir(layout.projectDirectory)' + - 'commandLine("sh", "-cx", "swift build --package-path \"${swiftSourceFolder()}\" --configuration debug --product SkipSwiftUITestSupport --scratch-path \"${layout.projectDirectory}/.build/SkipSwiftUITestSupport/swift\" -Xcc -fPIC -Xswiftc -DSKIP_BRIDGE -Xswiftc -DROBOLECTRIC")' + - 'environment("SKIP_BRIDGE", "1")' + - 'if (file(skipCommand).exists()) { environment("SKIP_COMMAND_OVERRIDE", skipCommand) }' diff --git a/Tests/SkipSwiftUITests/SkipSwiftUITests.swift b/Tests/SkipSwiftUITests/SkipSwiftUITests.swift index 3f2512a..a145446 100644 --- a/Tests/SkipSwiftUITests/SkipSwiftUITests.swift +++ b/Tests/SkipSwiftUITests/SkipSwiftUITests.swift @@ -1,12 +1,50 @@ // Copyright 2025–2026 Skip // SPDX-License-Identifier: MPL-2.0 import XCTest -#if !SKIP -@testable import SkipSwiftUI + +#if SKIP +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.junit4.createComposeRule +import androidx.compose.ui.test.onNodeWithText +import org.junit.Rule +import SkipBridge #endif +@testable import SkipSwiftUI +import SkipSwiftUITestSupport + final class SkipSwiftUITests: XCTestCase { func testSkipUI() throws { XCTAssertEqual(3, 1 + 2) } } + +final class ObservationMountRuntimeTests: SkipUITestCase { + // SKIP INSERT: @get:Rule val composeRule = createComposeRule() + + override func setUp() { + super.setUp() + #if SKIP + loadPeerLibrary(packageName: "skip-fuse-ui", moduleName: "SkipSwiftUITestSupport") + #endif + } + + func testMountedViewInvalidatesWhenObservablePropertyChanges() throws { + #if SKIP + let probe = ObservationProbeMount() + + composeRule.setContent { + Text(probe.renderedText()).Compose() + } + composeRule.waitForIdle() + composeRule.onNodeWithText("count: 0").assertIsDisplayed() + + probe.setCount(1) + composeRule.waitForIdle() + + composeRule.onNodeWithText("count: 1").assertIsDisplayed() + #else + throw XCTSkip("Runs only in the transpiled Skip Compose test runtime.") + #endif + } +} diff --git a/Tests/SkipSwiftUITests/XCSkipTests.swift b/Tests/SkipSwiftUITests/XCSkipTests.swift index 3c3aaba..3706ce8 100644 --- a/Tests/SkipSwiftUITests/XCSkipTests.swift +++ b/Tests/SkipSwiftUITests/XCSkipTests.swift @@ -28,7 +28,7 @@ open class SkipUITestCase : XCTestCase { public var testName: String? { #if SKIP let tname = _testName.methodName // "testLocalizedText$SkipUI_debugUnitTest" - return tname.split(separator: "$").first + return tname.split(separator: Character("$")).first #else let tname = testRun?.test.name // "-[SkipUITests testLocalizedText]" return tname? @@ -66,7 +66,10 @@ open class SkipUITestCase : XCTestCase { public class TestIntrospectionTests : SkipUITestCase { func testTestIntrospection() { - XCTAssertEqual("testTestIntrospection", self.testName) + XCTAssertTrue( + ["testTestIntrospection", "TestIntrospectionTests.testTestIntrospection"] + .contains(self.testName) + ) } } From d50378ea0324f9567fec58b4210329912aa35394 Mon Sep 17 00:00:00 2001 From: Woodrow Melling Date: Tue, 19 May 2026 19:34:53 -0600 Subject: [PATCH 2/2] Add explicit observation tracking mount test --- .../ObservationProbeMount.swift | 25 +++++++++++++++++++ Tests/SkipSwiftUITests/SkipSwiftUITests.swift | 25 +++++++++++++++++++ 2 files changed, 50 insertions(+) diff --git a/Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift b/Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift index 694e07e..fd0cf97 100644 --- a/Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift +++ b/Tests/SkipSwiftUITestSupport/ObservationProbeMount.swift @@ -21,6 +21,31 @@ public final class ObservationProbeMount { public func renderedText() -> String { "count: \(self.box.count)" } + + public func renderedText(onChange: @escaping () -> Void) -> String { + if #available(iOS 17, macOS 14, tvOS 17, watchOS 10, *) { + let handler = ObservationChangeHandler(onChange) + return withObservationTracking { + self.renderedText() + } onChange: { + handler() + } + } else { + return self.renderedText() + } + } +} + +private struct ObservationChangeHandler: @unchecked Sendable { + private let handler: () -> Void + + init(_ handler: @escaping () -> Void) { + self.handler = handler + } + + func callAsFunction() { + self.handler() + } } private protocol ObservationProbeMountBox: AnyObject { diff --git a/Tests/SkipSwiftUITests/SkipSwiftUITests.swift b/Tests/SkipSwiftUITests/SkipSwiftUITests.swift index a145446..cdfe879 100644 --- a/Tests/SkipSwiftUITests/SkipSwiftUITests.swift +++ b/Tests/SkipSwiftUITests/SkipSwiftUITests.swift @@ -3,6 +3,8 @@ import XCTest #if SKIP +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithText @@ -47,4 +49,27 @@ final class ObservationMountRuntimeTests: SkipUITestCase { throw XCTSkip("Runs only in the transpiled Skip Compose test runtime.") #endif } + + func testMountedViewInvalidatesWithObservationTracking() throws { + #if SKIP + let probe = ObservationProbeMount() + + composeRule.setContent { + let invalidation = remember { mutableStateOf(0) } + _ = invalidation.value + Text(probe.renderedText { + invalidation.value += 1 + }).Compose() + } + composeRule.waitForIdle() + composeRule.onNodeWithText("count: 0").assertIsDisplayed() + + probe.setCount(1) + composeRule.waitForIdle() + + composeRule.onNodeWithText("count: 1").assertIsDisplayed() + #else + throw XCTSkip("Runs only in the transpiled Skip Compose test runtime.") + #endif + } }