Skip to content

Commit

Permalink
Add support for visionOS and macOS (#20)
Browse files Browse the repository at this point in the history
# Optimize text input on visionOS

## ♻️ Current situation & Problem
The current approach for textfield selection does not work on visionOS.
This PR addresses the issue by replacing the current solution via a
simple "tap" on visionOS. Our automatic text input correction should
catch the case where the tap will set the cursor in the middle of the
text.

This resolves #19.

Further, keyboards and alerts are in separate processes in visionOS.
This PR accounts for that and updates the respective implementations.


## ⚙️ Release Notes 
* Fix smaller issues with visionOS.


## 📚 Documentation
Minor adjustments.


## ✅ Testing
* Added a new test case that explicitly checks the use case of entering
text in a text field, prefilled with long text.
* Added CI support for visionOS.


### Code of Conduct & Contributing Guidelines 

By submitting creating this pull request, you agree to follow our [Code
of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md):
- [x] I agree to follow the [Code of
Conduct](https://github.com/StanfordBDHG/.github/blob/main/CODE_OF_CONDUCT.md)
and [Contributing
Guidelines](https://github.com/StanfordBDHG/.github/blob/main/CONTRIBUTING.md).
  • Loading branch information
Supereg authored Feb 16, 2024
1 parent fb7fcee commit e29d29d
Show file tree
Hide file tree
Showing 15 changed files with 358 additions and 146 deletions.
55 changes: 47 additions & 8 deletions .github/workflows/build-and-test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,24 +16,63 @@ on:
workflow_dispatch:

jobs:
buildandtest:
name: Build and Test Swift Package
buildandtest_ios:
name: Build and Test Swift Package iOS
uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
with:
artifactname: XCTestExtensions-Package.xcresult
runsonlabels: '["macOS", "self-hosted"]'
scheme: XCTestExtensions-Package
buildandtestuitests:
name: Build and Test UI Tests
resultBundle: XCTestExtensions-iOS.xcresult
artifactname: XCTestExtensions-iOS.xcresult
buildandtest_watchos:
name: Build and Test Swift Package watchOS
uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
with:
runsonlabels: '["macOS", "self-hosted"]'
scheme: XCTestExtensions-Package
destination: 'platform=watchOS Simulator,name=Apple Watch Series 9 (45mm)'
resultBundle: XCTestExtensions-watchOS.xcresult
artifactname: XCTestExtensions-watchOS.xcresult
buildandtest_visionos:
name: Build and Test Swift Package visionOS
uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
with:
runsonlabels: '["macOS", "self-hosted"]'
scheme: XCTestExtensions-Package
destination: 'platform=visionOS Simulator,name=Apple Vision Pro'
resultBundle: XCTestExtensions-visionOS.xcresult
artifactname: XCTestExtensions-visionOS.xcresult
buildandtest_macos:
name: Build and Test Swift Package macOS
uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
with:
runsonlabels: '["macOS", "self-hosted"]'
scheme: XCTestExtensions-Package
destination: 'platform=macOS,arch=arm64'
resultBundle: XCTestExtensions-macOS.xcresult
artifactname: XCTestExtensions-macOS.xcresult
buildandtestuitests_ios:
name: Build and Test UI Tests iOS
uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
with:
runsonlabels: '["macOS", "self-hosted"]'
path: Tests/UITests
scheme: TestApp
resultBundle: TestApp-iOS.xcresult
artifactname: TestApp-iOS.xcresult
buildandtestuitests_visionos:
name: Build and Test UI Tests visionOS
uses: StanfordBDHG/.github/.github/workflows/xcodebuild-or-fastlane.yml@v2
with:
artifactname: TestApp.xcresult
runsonlabels: '["macOS", "self-hosted"]'
path: Tests/UITests
scheme: TestApp
destination: 'platform=visionOS Simulator,name=Apple Vision Pro'
resultBundle: TestApp-visionOS.xcresult
artifactname: TestApp-visionOS.xcresult
uploadcoveragereport:
name: Upload Coverage Report
needs: [buildandtest, buildandtestuitests]
needs: [buildandtest_ios, buildandtest_visionos, buildandtest_watchos, buildandtest_macos, buildandtestuitests_ios, buildandtestuitests_visionos]
uses: StanfordBDHG/.github/.github/workflows/create-and-upload-coverage-report.yml@v2
with:
coveragereports: XCTestExtensions-Package.xcresult TestApp.xcresult
coveragereports: XCTestExtensions-iOS.xcresult XCTestExtensions-watchOS.xcresult XCTestExtensions-visionOS.xcresult XCTestExtensions-macOS.xcresult TestApp-iOS.xcresult TestApp-visionOS.xcresult
7 changes: 5 additions & 2 deletions Package.swift
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
// swift-tools-version:5.7
// swift-tools-version:5.9

//
// This source file is part of the Stanford XCTestExtensions open-source project
Expand All @@ -14,7 +14,10 @@ import PackageDescription
let package = Package(
name: "XCTestExtensions",
platforms: [
.iOS(.v16)
.iOS(.v16),
.watchOS(.v9),
.visionOS(.v1),
.macOS(.v13)
],
products: [
.library(name: "XCTestApp", targets: ["XCTestApp"]),
Expand Down
2 changes: 1 addition & 1 deletion Sources/XCTestApp/TestAppTestCase.swift
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@
//


/// A collection of test cases that can be exectured in an ``TestAppView``.
/// A collection of test cases that can be executed in an ``TestAppView``.
public protocol TestAppTestCase: Identifiable {
/// Implement this method to run all the tests that should be executed.
func runTests() async throws
Expand Down
28 changes: 23 additions & 5 deletions Sources/XCTestApp/TestAppTestsView.swift
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@ import SwiftUI

/// Allows the definition of an enum of different test cases in your test application that are associated with SwiftUI views
public struct TestAppTestsView<Tests: TestAppTests>: View {
private let showCloseButton: Bool

@Environment(\.dismiss)
private var dismiss

@State private var path = NavigationPath()



public var body: some View {
NavigationStack(path: $path) {
List(Array(Tests.allCases)) { test in
Expand All @@ -23,10 +27,24 @@ public struct TestAppTestsView<Tests: TestAppTests>: View {
test.view(withNavigationPath: $path)
}
.navigationTitle(String(describing: Tests.self))
.toolbar {
if showCloseButton {
ToolbarItem(placement: .cancellationAction) {
Button("Close") {
dismiss()
}
}
}
}
}
}


/// - Parameter tests: The ``TestAppTests`` type that should be associated with the ``TestAppTestsView``
public init(tests: Tests.Type = Tests.self) { }

/// Create a new tests view.
/// - Parameters:
/// - tests: The ``TestAppTests`` type that should be associated with the ``TestAppTestsView``
/// - showCloseButton: Show an optional close button. Helpful if this view is presented within a modal view.
public init(tests: Tests.Type = Tests.self, showCloseButton: Bool = false) {
self.showCloseButton = showCloseButton
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -20,15 +20,19 @@ extension XCTestCase {
///
/// > Warning: While this workaround worked well until 17.2, we experienced a crash of the passwords section in the IOS 17.2 passwords app on the iOS simulator, which no longer allows us to use this workaround.
/// We recommend using a custom setup script to skip password-related functionality in your UI tests until there is a better workaround. Plase inspect the logic to setup simulators in the [xcodebuild-or-fastlane.yml](https://github.com/StanfordBDHG/.github/blob/main/.github/workflows/xcodebuild-or-fastlane.yml) workflow and be sure to `setupSimulators: true` if you use the GitHub action as a reusable workflow.
@available(iOS, deprecated: 17.2, message: "Plase use a custom setup script in your CI environment to disable password autofill.")
@available(iOS, deprecated: 17.2, message: "Please use a custom setup script in your CI environment to disable password autofill.")
@available(watchOS, unavailable)
@available(macOS, unavailable)
@available(tvOS, unavailable)
@available(visionOS, unavailable)
public func disablePasswordAutofill() throws {
let settingsApp = XCUIApplication(bundleIdentifier: "com.apple.Preferences")
settingsApp.terminate()
settingsApp.launch()

XCTAssert(settingsApp.staticTexts["PASSWORDS"].waitForExistence(timeout: 5.0))
settingsApp.staticTexts["PASSWORDS"].tap()

if #available(iOS 17.2, *) {
sleep(2)

Expand All @@ -40,7 +44,7 @@ extension XCTestCase {
XCTAssert(settingsApp.navigationBars.staticTexts["Passwords"].waitForExistence(timeout: 2.0))
}

let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")
let springboard = XCUIApplication(bundleIdentifier: XCUIApplication.homeScreenBundle)
if springboard.secureTextFields["Passcode field"].waitForExistence(timeout: 30.0) {
let passcodeInput = springboard.secureTextFields["Passcode field"]
passcodeInput.tap()
Expand Down
49 changes: 41 additions & 8 deletions Sources/XCTestExtensions/XCUIApplication+DeleteAndLaunch.swift
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,37 @@ import XCTest


extension XCUIApplication {
/// The bundle identifier for the home screen for the specific platform.
///
/// E.g., `com.apple.springboard` on iOS.
@available(macOS, unavailable)
@available(watchOS, unavailable)
public static var homeScreenBundle: String {
#if os(visionOS)
"com.apple.RealityLauncher"
#elseif os(tvOS)
"com.apple.pineboard"
#elseif os(iOS)
"com.apple.springboard"
#else
preconditionFailure("Unsupported platform.")
#endif
}

/// Deletes the application from the iOS springboard (iOS home screen) and launches it after it has been deleted and reinstalled.
/// - Parameter appName: The name of the application as displayed on the springboard (iOS home screen).
@available(macOS, unavailable)
@available(watchOS, unavailable)
public func deleteAndLaunch(withSpringboardAppName appName: String) {
self.terminate()

let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard")

let springboard = XCUIApplication(bundleIdentifier: Self.homeScreenBundle)
#if os(visionOS)
springboard.launch() // springboard is in `runningBackgroundSuspended` state on visionOS. So we need to launch it not just activate
#else
springboard.activate()

#endif

if springboard.icons[appName].firstMatch.waitForExistence(timeout: 10.0) {
if !springboard.icons[appName].firstMatch.isHittable {
springboard.swipeLeft()
Expand All @@ -37,12 +60,22 @@ extension XCUIApplication {

XCTAssertTrue(springboard.collectionViews.buttons["Remove App"].waitForExistence(timeout: 10.0))
}

springboard.buttons["Remove App"].tap()

springboard.collectionViews.buttons["Remove App"].tap()
XCTAssertTrue(springboard.alerts["Remove “\(appName)”?"].scrollViews.otherElements.buttons["Delete App"].waitForExistence(timeout: 10.0))
springboard.alerts["Remove “\(appName)”?"].scrollViews.otherElements.buttons["Delete App"].tap()
XCTAssertTrue(springboard.alerts["Delete “\(appName)”?"].scrollViews.otherElements.buttons["Delete"].waitForExistence(timeout: 10.0))
springboard.alerts["Delete “\(appName)”?"].scrollViews.otherElements.buttons["Delete"].tap()
#if os(visionOS)
// alerts are running in their own process on visionOS (lol). Took me literally 3 hours.
let notifications = visionOSNotifications

XCTAssert(notifications.staticTexts["Delete “\(appName)”?"].waitForExistence(timeout: 5.0))
XCTAssert(notifications.buttons["Delete"].waitForExistence(timeout: 2.0))
notifications.buttons["Delete"].tap() // currently no better way of hitting some "random" delete button.
#else
XCTAssertTrue(springboard.alerts["Remove “\(appName)”?"].buttons["Delete App"].waitForExistence(timeout: 10.0))
springboard.alerts["Remove “\(appName)”?"].buttons["Delete App"].tap()
XCTAssertTrue(springboard.alerts["Delete “\(appName)”?"].buttons["Delete"].waitForExistence(timeout: 10.0))
springboard.alerts["Delete “\(appName)”?"].buttons["Delete"].tap()
#endif
}

// Wait for 5 Seconds for the application to be deleted and removed.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -24,11 +24,21 @@ private enum SubmitLabels: String, CaseIterable {
case send = "Send"
}


extension XCUIApplication {
/// Dismisses the keyboard if it is currently displayed.
public func dismissKeyboard() {
#if os(visionOS)
let keyboard = visionOSKeyboard
#else
let keyboard = keyboards.firstMatch
#endif

#if os(macOS)
return // we cannot dismiss a keyboard in macOS
#endif

// on vision os this check always succeed. So dismissing a keyboard in visionOS when it isn't launched is a problem!
guard keyboard.exists else {
return
}
Expand Down
62 changes: 62 additions & 0 deletions Sources/XCTestExtensions/XCUIApplication+VisionOS.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
//
// This source file is part of the Stanford Spezi open-source project
//
// SPDX-FileCopyrightText: 2024 Stanford University and the project authors (see CONTRIBUTORS.md)
//
// SPDX-License-Identifier: MIT
//

import XCTest

// Use `xcrun simctl spawn booted launchctl list` to check for all bundle identifiers running on visionOS simulator.
// If you are in search for an application bundle identifier you have a good chance of finding it there.


extension XCUIApplication {
/// Get access to the visionOS keyboard application.
@available(macOS, unavailable)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@available(visionOS 1, *)
public static var visionOSKeyboard: XCUIApplication {
XCUIApplication(bundleIdentifier: "com.apple.RealityKeyboard")
}

/// Get access to the visionOS keyboard application.
@available(macOS, unavailable)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@available(visionOS 1, *)
public var visionOSKeyboard: XCUIApplication {
Self.visionOSKeyboard
}
}


extension XCUIApplication {
/// Get access to the visionOS notifications application.
///
/// This is the applications that is responsible for drawing all
@available(macOS, unavailable)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@available(visionOS 1, *)
public static var visionOSNotifications: XCUIApplication {
XCUIApplication(bundleIdentifier: "com.apple.RealityNotifications")
}

/// Get access to the visionOS notifications application.
///
/// This is the applications that is responsible for drawing all
@available(macOS, unavailable)
@available(iOS, unavailable)
@available(tvOS, unavailable)
@available(watchOS, unavailable)
@available(visionOS 1, *)
public var visionOSNotifications: XCUIApplication {
Self.visionOSNotifications
}
}
Loading

0 comments on commit e29d29d

Please sign in to comment.