diff --git a/.github/workflows/ios.yml b/.github/workflows/ios.yml index aa510cd..7ddda67 100644 --- a/.github/workflows/ios.yml +++ b/.github/workflows/ios.yml @@ -8,10 +8,13 @@ on: jobs: parallel-testing: name: Cloned simulator - runs-on: self-hosted + runs-on: self-hosted steps: - name: Checkout uses: actions/checkout@v3 + # - uses: maxim-lobanov/setup-xcode@v1.6.0 + # with: + # xcode-version: latest-stable - name: Build & Test run: | make test-parallel @@ -41,10 +44,13 @@ jobs: # path: Client/logfile.txt single-device-testing: name: Normal simulator - runs-on: self-hosted + runs-on: self-hosted steps: - name: Checkout uses: actions/checkout@v3 + # - uses: maxim-lobanov/setup-xcode@v1.6.0 + # with: + # xcode-version: latest-stable - name: Build & Test timeout-minutes: 20 run: | diff --git a/.gitignore b/.gitignore index 0cfe5db..6ffa675 100644 --- a/.gitignore +++ b/.gitignore @@ -49,7 +49,8 @@ playground.xcworkspace # .swiftpm .build/ - +**/.uiUnitTest/ +*.xcresult/ # CocoaPods # # We recommend against adding the Pods directory to your .gitignore. However diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..11cdf65 --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,24 @@ +# AGENTS.md - UIUnitTest Project Guide + +## Build & Test Commands +- **Run all tests**: `make test` (uses iPhone 16, iOS 18.2 simulator) +- **Run parallel tests**: `make test-parallel` (2 workers, faster execution) +- **Run single test**: `xcodebuild -project Client/Client.xcodeproj -scheme Client -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' -only-testing:ClientTests/TapTests/testTap test` +- **Clean build**: `make clean-up` (removes build artifacts) +- **Reset simulators**: `make reset-simulators` + +## Code Style Guidelines +- **Swift version**: 6.0 with strict concurrency +- **Imports**: Group by framework (Foundation, SwiftUI, XCTest, then project imports) +- **Naming**: camelCase for variables/functions, PascalCase for types +- **Async/await**: Prefer async/await over completion handlers, mark test classes `@MainActor` +- **Error handling**: Use `try await` for async operations, `XCTExpectFailure` for expected test failures +- **SwiftLint**: Configured with mandatory trailing commas, excludes short identifiers (x, y, id, up) +- **Test structure**: Both async (`try await`) and sync versions of tests, use `showView()` helper +- **Accessibility**: Use accessibility labels for UI elements, support accessibility audits + +## Project Structure +- **Client/**: iOS test app with UI components +- **Server/**: Test server for UI automation +- **Lib/**: Core UIUnitTest library and CLI +- **Tests**: Located in `ClientTests/` with parallel execution support \ No newline at end of file diff --git a/Client/Client.xcodeproj/project.pbxproj b/Client/Client.xcodeproj/project.pbxproj index 26dea21..8281c14 100644 --- a/Client/Client.xcodeproj/project.pbxproj +++ b/Client/Client.xcodeproj/project.pbxproj @@ -7,7 +7,6 @@ objects = { /* Begin PBXBuildFile section */ - 1D096EC62CE217B00070874E /* UIUnitTest in Frameworks */ = {isa = PBXBuildFile; productRef = 1D096EC52CE217B00070874E /* UIUnitTest */; }; 1D136B7029B45A4600465FB6 /* ClientApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D136B6F29B45A4600465FB6 /* ClientApp.swift */; }; 1D136B7229B45A4600465FB6 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D136B7129B45A4600465FB6 /* ContentView.swift */; }; 1D136B7429B45A4600465FB6 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 1D136B7329B45A4600465FB6 /* Assets.xcassets */; }; @@ -16,12 +15,13 @@ 1D2B65E02AF5F1EB00E5618D /* UIUnitTest in Frameworks */ = {isa = PBXBuildFile; productRef = 1D2B65DF2AF5F1EB00E5618D /* UIUnitTest */; }; 1D2B65E22AF5F29D00E5618D /* UIUnitTest in Frameworks */ = {isa = PBXBuildFile; productRef = 1D2B65E12AF5F29D00E5618D /* UIUnitTest */; }; 1D3D8E3E2CF7F4EB007AC85C /* SomethingView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3D8E3A2CF7F4EB007AC85C /* SomethingView.swift */; }; - 1D3D8E3F2CF7F4EB007AC85C /* TextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3D8E3D2CF7F4EB007AC85C /* TextFieldView.swift */; }; 1D3D8E402CF7F4EB007AC85C /* AccessibilityAuditView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3D8E382CF7F4EB007AC85C /* AccessibilityAuditView.swift */; }; 1D3D8E412CF7F4EB007AC85C /* MySettingsTable.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3D8E392CF7F4EB007AC85C /* MySettingsTable.swift */; }; - 1D3D8E422CF7F4EB007AC85C /* StepperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3D8E3B2CF7F4EB007AC85C /* StepperView.swift */; }; - 1D3D8E432CF7F4EB007AC85C /* StringView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D3D8E3C2CF7F4EB007AC85C /* StringView.swift */; }; 1D435E9129BB48250027CD1D /* SwipeView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D435E9029BB48250027CD1D /* SwipeView.swift */; }; + 1D5308912CDF591D002DB5FC /* UIUnitTest in Frameworks */ = {isa = PBXBuildFile; productRef = 1D5308902CDF591D002DB5FC /* UIUnitTest */; }; + 1D5B0A2D2CDC6CD6008C64A4 /* UIUnitTest in Frameworks */ = {isa = PBXBuildFile; productRef = 1D5B0A2C2CDC6CD6008C64A4 /* UIUnitTest */; }; + 1D60D5852C23D8E600F0B0FF /* SwiftTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D60D5842C23D8E300F0B0FF /* SwiftTesting.swift */; }; + 1D60D5862C23D8E600F0B0FF /* SwiftTesting.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D60D5842C23D8E300F0B0FF /* SwiftTesting.swift */; }; 1DE0F32E29BD9E2A0044AFFC /* PressAndHoldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE0F32D29BD9E2A0044AFFC /* PressAndHoldView.swift */; }; 1DE0F33429BDD5410044AFFC /* GoToBackgroundAndBackView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE0F33329BDD5410044AFFC /* GoToBackgroundAndBackView.swift */; }; 1DE0F33E29BF265D0044AFFC /* TapView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE0F33D29BF265D0044AFFC /* TapView.swift */; }; @@ -29,6 +29,11 @@ 1DE0F34829C533B00044AFFC /* RotateView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE0F34729C533B00044AFFC /* RotateView.swift */; }; 1DE0F35D29C58B4D0044AFFC /* ClientTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D136B8129B45A4600465FB6 /* ClientTests.swift */; }; 1DF4DBFE2BF60C560025FC8E /* WaitForExistenceView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DE0F32929BD89790044AFFC /* WaitForExistenceView.swift */; }; + 2E20EF8883E35EE011F4D203 /* StringView.swift in Sources */ = {isa = PBXBuildFile; fileRef = D0A984BE6E95431BFD07F508 /* StringView.swift */; }; + 8DB46B669B1CCD576E48F2B6 /* TapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588FB78BE242F975FFB24DC5 /* TapTests.swift */; }; + B2740E7D5A37E4C35221A39A /* StepperView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 941CF12B8E6EC3A48A73B863 /* StepperView.swift */; }; + B48F570095F67108C3CFDD2E /* TapTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 588FB78BE242F975FFB24DC5 /* TapTests.swift */; }; + D039E08E1166A315C7DF56CE /* TextFieldView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 7A99380EE944F5F88C2FE6DF /* TextFieldView.swift */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -65,6 +70,7 @@ 1D3D8E3C2CF7F4EB007AC85C /* StringView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StringView.swift; sourceTree = ""; }; 1D3D8E3D2CF7F4EB007AC85C /* TextFieldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldView.swift; sourceTree = ""; }; 1D435E9029BB48250027CD1D /* SwipeView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwipeView.swift; sourceTree = ""; }; + 1D60D5842C23D8E300F0B0FF /* SwiftTesting.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwiftTesting.swift; sourceTree = ""; }; 1DE0F32929BD89790044AFFC /* WaitForExistenceView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = WaitForExistenceView.swift; sourceTree = ""; }; 1DE0F32D29BD9E2A0044AFFC /* PressAndHoldView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PressAndHoldView.swift; sourceTree = ""; }; 1DE0F33329BDD5410044AFFC /* GoToBackgroundAndBackView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = GoToBackgroundAndBackView.swift; sourceTree = ""; }; @@ -75,6 +81,13 @@ 1DE0F34729C533B00044AFFC /* RotateView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = RotateView.swift; sourceTree = ""; }; 1DE0F35229C58B310044AFFC /* LocalTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = LocalTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 1DE1F7B52BF756BB00EBAAD8 /* ClientTests - Parallel.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ClientTests - Parallel.xctestplan"; sourceTree = ""; }; + 33144142238FA6925FB59B15 /* MySettingsTable.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = MySettingsTable.swift; sourceTree = ""; }; + 588FB78BE242F975FFB24DC5 /* TapTests.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TapTests.swift; sourceTree = ""; }; + 5CDFC03712E8774F8F5C1EDC /* SomethingView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = SomethingView.swift; sourceTree = ""; }; + 7A99380EE944F5F88C2FE6DF /* TextFieldView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = TextFieldView.swift; sourceTree = ""; }; + 8269C148A627E0706091ADDA /* AccessibilityAuditView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = AccessibilityAuditView.swift; sourceTree = ""; }; + 941CF12B8E6EC3A48A73B863 /* StepperView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StepperView.swift; sourceTree = ""; }; + D0A984BE6E95431BFD07F508 /* StringView.swift */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = sourcecode.swift; path = StringView.swift; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -89,7 +102,8 @@ isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1D096EC62CE217B00070874E /* UIUnitTest in Frameworks */, + 1D5B0A2D2CDC6CD6008C64A4 /* UIUnitTest in Frameworks */, + 1D5308912CDF591D002DB5FC /* UIUnitTest in Frameworks */, 1D2B65E02AF5F1EB00E5618D /* UIUnitTest in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; @@ -105,11 +119,19 @@ /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ + 1D01C92F29B9CC3600551803 /* Packages */ = { + isa = PBXGroup; + children = ( + ); + name = Packages; + sourceTree = ""; + }; 1D136B6329B45A4600465FB6 = { isa = PBXGroup; children = ( 1DE1F7B52BF756BB00EBAAD8 /* ClientTests - Parallel.xctestplan */, 1D2B65E62AF60B8900E5618D /* Client.xctestplan */, + 1D01C92F29B9CC3600551803 /* Packages */, 1D136B6E29B45A4600465FB6 /* Client */, 1D136B8029B45A4600465FB6 /* ClientTests */, 1D136B6D29B45A4600465FB6 /* Products */, @@ -149,6 +171,12 @@ 1DE0F33329BDD5410044AFFC /* GoToBackgroundAndBackView.swift */, 1DE0F33D29BF265D0044AFFC /* TapView.swift */, 1DE0F34429C52D4E0044AFFC /* PinchView.swift */, + 7A99380EE944F5F88C2FE6DF /* TextFieldView.swift */, + 8269C148A627E0706091ADDA /* AccessibilityAuditView.swift */, + 941CF12B8E6EC3A48A73B863 /* StepperView.swift */, + 33144142238FA6925FB59B15 /* MySettingsTable.swift */, + D0A984BE6E95431BFD07F508 /* StringView.swift */, + 5CDFC03712E8774F8F5C1EDC /* SomethingView.swift */, ); path = Client; sourceTree = ""; @@ -164,8 +192,10 @@ 1D136B8029B45A4600465FB6 /* ClientTests */ = { isa = PBXGroup; children = ( + 1D60D5842C23D8E300F0B0FF /* SwiftTesting.swift */, 1DE0F33829BDDBBC0044AFFC /* Info.plist */, 1D136B8129B45A4600465FB6 /* ClientTests.swift */, + 588FB78BE242F975FFB24DC5 /* TapTests.swift */, ); path = ClientTests; sourceTree = ""; @@ -213,7 +243,8 @@ name = ClientTests; packageProductDependencies = ( 1D2B65DF2AF5F1EB00E5618D /* UIUnitTest */, - 1D096EC52CE217B00070874E /* UIUnitTest */, + 1D5B0A2C2CDC6CD6008C64A4 /* UIUnitTest */, + 1D5308902CDF591D002DB5FC /* UIUnitTest */, ); productName = ClientTests; productReference = 1D136B7D29B45A4600465FB6 /* ClientTests.xctest */; @@ -248,7 +279,7 @@ attributes = { BuildIndependentTargetsInParallel = 1; LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1540; + LastUpgradeCheck = 1610; TargetAttributes = { 1D136B6B29B45A4600465FB6 = { CreatedOnToolsVersion = 14.2; @@ -273,7 +304,7 @@ ); mainGroup = 1D136B6329B45A4600465FB6; packageReferences = ( - 1D096EC42CE217B00070874E /* XCLocalSwiftPackageReference "UIUnitTest" */, + 1D53088F2CDF591D002DB5FC /* XCLocalSwiftPackageReference "UIUnitTest" */, ); productRefGroup = 1D136B6D29B45A4600465FB6 /* Products */; projectDirPath = ""; @@ -324,14 +355,14 @@ 1D136B7029B45A4600465FB6 /* ClientApp.swift in Sources */, 1DF4DBFE2BF60C560025FC8E /* WaitForExistenceView.swift in Sources */, 1D3D8E3E2CF7F4EB007AC85C /* SomethingView.swift in Sources */, - 1D3D8E3F2CF7F4EB007AC85C /* TextFieldView.swift in Sources */, 1D3D8E402CF7F4EB007AC85C /* AccessibilityAuditView.swift in Sources */, 1D3D8E412CF7F4EB007AC85C /* MySettingsTable.swift in Sources */, - 1D3D8E422CF7F4EB007AC85C /* StepperView.swift in Sources */, - 1D3D8E432CF7F4EB007AC85C /* StringView.swift in Sources */, 1DE0F34829C533B00044AFFC /* RotateView.swift in Sources */, 1DE0F33E29BF265D0044AFFC /* TapView.swift in Sources */, 1DE0F34529C52D4E0044AFFC /* PinchView.swift in Sources */, + D039E08E1166A315C7DF56CE /* TextFieldView.swift in Sources */, + B2740E7D5A37E4C35221A39A /* StepperView.swift in Sources */, + 2E20EF8883E35EE011F4D203 /* StringView.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -339,7 +370,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1D60D5862C23D8E600F0B0FF /* SwiftTesting.swift in Sources */, 1D136B8229B45A4600465FB6 /* ClientTests.swift in Sources */, + 8DB46B669B1CCD576E48F2B6 /* TapTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -347,7 +380,9 @@ isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( + 1D60D5852C23D8E600F0B0FF /* SwiftTesting.swift in Sources */, 1DE0F35D29C58B4D0044AFFC /* ClientTests.swift in Sources */, + B48F570095F67108C3CFDD2E /* TapTests.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -567,7 +602,6 @@ 1D136B9529B45A4600465FB6 /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -576,12 +610,13 @@ INFOPLIST_FILE = ClientTests/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MACOSX_DEPLOYMENT_TARGET = 13.1; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = bruno.mazzo.ClientTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -593,7 +628,6 @@ 1D136B9629B45A4600465FB6 /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; BUNDLE_LOADER = "$(TEST_HOST)"; CODE_SIGN_STYLE = Automatic; CURRENT_PROJECT_VERSION = 1; @@ -602,12 +636,13 @@ INFOPLIST_FILE = ClientTests/Info.plist; INFOPLIST_KEY_LSApplicationCategoryType = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MACOSX_DEPLOYMENT_TARGET = 13.1; + MACOSX_DEPLOYMENT_TARGET = 14.6; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = bruno.mazzo.ClientTests; PRODUCT_NAME = "$(TARGET_NAME)"; SDKROOT = auto; - SUPPORTED_PLATFORMS = "iphoneos iphonesimulator macosx"; + SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; + SUPPORTS_MACCATALYST = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_STRICT_CONCURRENCY = complete; SWIFT_VERSION = 5.0; @@ -699,22 +734,26 @@ /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 1D096EC42CE217B00070874E /* XCLocalSwiftPackageReference "UIUnitTest" */ = { + 1D53088F2CDF591D002DB5FC /* XCLocalSwiftPackageReference "UIUnitTest" */ = { isa = XCLocalSwiftPackageReference; relativePath = UIUnitTest; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1D096EC52CE217B00070874E /* UIUnitTest */ = { + 1D2B65DF2AF5F1EB00E5618D /* UIUnitTest */ = { isa = XCSwiftPackageProductDependency; productName = UIUnitTest; }; - 1D2B65DF2AF5F1EB00E5618D /* UIUnitTest */ = { + 1D2B65E12AF5F29D00E5618D /* UIUnitTest */ = { isa = XCSwiftPackageProductDependency; productName = UIUnitTest; }; - 1D2B65E12AF5F29D00E5618D /* UIUnitTest */ = { + 1D5308902CDF591D002DB5FC /* UIUnitTest */ = { + isa = XCSwiftPackageProductDependency; + productName = UIUnitTest; + }; + 1D5B0A2C2CDC6CD6008C64A4 /* UIUnitTest */ = { isa = XCSwiftPackageProductDependency; productName = UIUnitTest; }; diff --git a/Client/Client.xcodeproj/xcshareddata/xcschemes/Client.xcscheme b/Client/Client.xcodeproj/xcshareddata/xcschemes/Client.xcscheme index c8c1460..c5c3f56 100644 --- a/Client/Client.xcodeproj/xcshareddata/xcschemes/Client.xcscheme +++ b/Client/Client.xcodeproj/xcshareddata/xcschemes/Client.xcscheme @@ -1,6 +1,6 @@ + scriptText = "SourcePackagesPath="${PROJECT_DIR}/../SourcePackages" if [ -e "$PROJECT_DIR/UIUnitTest/start-server.sh" ]; then echo "Using parant folder" "${PROJECT_DIR}/UIUnitTest/start-server.sh" elif [ -d "$SourcePackagesPath/checkouts/UIUnitTest/start-server.sh" ]; then echo "Using SourcePackagesPath" "$SourcePackagesPath/checkouts/UIUnitTest/start-server.sh" else echo "Using Build directory" "$BUILD_DIR/../../SourcePackages/checkouts/UIUnitTest/start-server.sh" fi "> + scriptText = "SourcePackagesPath="${PROJECT_DIR}/../SourcePackages" if [ -e "$PROJECT_DIR/UIUnitTest/stop-server.sh" ]; then echo "Using parant folder" "${PROJECT_DIR}/UIUnitTest/stop-server.sh" elif [ -d "$SourcePackagesPath/checkouts/UIUnitTest/stop-server.sh" ]; then echo "Using SourcePackagesPath" "$SourcePackagesPath/checkouts/UIUnitTest/stop-server.sh" else echo "Using Build directory" "$BUILD_DIR/../../SourcePackages/checkouts/UIUnitTest/stop-server.sh" fi "> 0 { direction = .down } - })) + }) } } } diff --git a/Client/Client/TapView.swift b/Client/Client/TapView.swift index eb41651..c07bcd9 100644 --- a/Client/Client/TapView.swift +++ b/Client/Client/TapView.swift @@ -2,76 +2,277 @@ import Foundation import SwiftUI struct TapView: View { - @State var twoFingersTap = false @State var threeFingersTap = false var body: some View { - VStack { - Text("Two fingers tap") -// TODO: Investigate why it doesn't work on iOS 15 -// .overlay(TappableView(accessibilityIdentifier: "TwoFingersView") { gesture in -// twoFingersTap = true -// }) - - TappableView(accessibilityIdentifier: "TwoFingersView") { _ in - twoFingersTap = true - } - - if twoFingersTap { - Text("Two fingers tapped") - } - -// Text("Three fingers tap") -// .overlay(TappableView(numberOfTouches: 3, accessibilityIdentifier: "ThreeFingersView") { gesture in -// threeFingersTap = true -// }) - TappableView(numberOfTouches: 3, accessibilityIdentifier: "ThreeFingersView") { _ in - threeFingersTap = true - } - if threeFingersTap { - Text("Three fingers tapped") + ScrollView { + VStack(spacing: 40) { + // Header section + VStack(spacing: 12) { + Text("Multi-Touch Gesture Demo") + .font(.title2) + .fontWeight(.bold) + .foregroundColor(.primary) + + Text("Test different multi-touch gestures by tapping the areas below with the specified number of fingers") + .font(.subheadline) + .foregroundColor(.secondary) + .multilineTextAlignment(.center) + .padding(.horizontal, 10) + } + .padding(.top, 10) + + // Two finger tap section + VStack(spacing: 20) { + Text("Two Finger Tap") + .font(.headline) + .foregroundColor(.primary) + + TappableView( + numberOfTouches: 2, + accessibilityIdentifier: "TwoFingersView", + title: "Tap with 2 fingers", + subtitle: "Touch this area with two fingers simultaneously", + backgroundColor: .blue + ) { _ in + print("Two fingers tapped") + withAnimation(.easeInOut(duration: 0.3)) { + twoFingersTap = true + } + } + .frame(height: 120) + .padding(.horizontal, 4) + + if twoFingersTap { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Two fingers tapped successfully!") + .font(.subheadline) + .foregroundColor(.green) + } + .transition(.opacity.combined(with: .scale)) + } + } + + // Three finger tap section + VStack(spacing: 20) { + Text("Three Finger Tap") + .font(.headline) + .foregroundColor(.primary) + + TappableView( + numberOfTouches: 3, + accessibilityIdentifier: "ThreeFingersView", + title: "Tap with 3 fingers", + subtitle: "Touch this area with three fingers simultaneously", + backgroundColor: .purple + ) { _ in + withAnimation(.easeInOut(duration: 0.3)) { + threeFingersTap = true + } + } + .frame(height: 120) + .padding(.horizontal, 4) + + if threeFingersTap { + HStack(spacing: 8) { + Image(systemName: "checkmark.circle.fill") + .foregroundColor(.green) + Text("Three fingers tapped successfully!") + .font(.subheadline) + .foregroundColor(.green) + } + .transition(.opacity.combined(with: .scale)) + } + } + + // Reset button section + if twoFingersTap || threeFingersTap { + VStack(spacing: 16) { + Divider() + .padding(.horizontal, 20) + + Button(action: { + withAnimation(.easeInOut(duration: 0.3)) { + twoFingersTap = false + threeFingersTap = false + } + }) { + HStack(spacing: 8) { + Image(systemName: "arrow.clockwise") + Text("Reset All") + } + .font(.headline) + .foregroundColor(.white) + .padding(.horizontal, 24) + .padding(.vertical, 12) + .background(Color.gray) + .cornerRadius(8) + } + .accessibilityIdentifier("ResetButton") + } + .transition(.opacity.combined(with: .scale)) + } + + Spacer(minLength: 40) } + .padding(.horizontal, 20) + .padding(.vertical, 20) } + .navigationTitle("Multi-Touch Demo") + .navigationBarTitleDisplayMode(.inline) } } struct TappableView: UIViewRepresentable { var numberOfTouches: Int = 2 var accessibilityIdentifier: String + var title: String = "" + var subtitle: String = "" + var backgroundColor: Color = .blue var tapCallback: (UITapGestureRecognizer) -> Void typealias UIViewType = UIView func makeCoordinator() -> TappableView.Coordinator { - Coordinator(tapCallback: self.tapCallback) + Coordinator(tapCallback: tapCallback) } func makeUIView(context: Context) -> UIView { - let view = UIView() - view.accessibilityIdentifier = accessibilityIdentifier - let doubleTapGestureRecognizer = UITapGestureRecognizer(target: context.coordinator, action: #selector(Coordinator.handleTap(sender:))) - - /// Set number of touches. - doubleTapGestureRecognizer.numberOfTouchesRequired = numberOfTouches - - view.addGestureRecognizer(doubleTapGestureRecognizer) - return view + let containerView = UIView() + containerView.backgroundColor = UIColor(backgroundColor) + containerView.accessibilityIdentifier = accessibilityIdentifier + containerView.layer.cornerRadius = 12 + containerView.layer.shadowColor = UIColor.black.cgColor + containerView.layer.shadowOffset = CGSize(width: 0, height: 2) + containerView.layer.shadowOpacity = 0.1 + containerView.layer.shadowRadius = 4 + + // Accessibility configuration + containerView.isAccessibilityElement = true + containerView.accessibilityLabel = title.isEmpty ? "Tappable area" : title + containerView.accessibilityHint = subtitle.isEmpty ? "Tap with \(numberOfTouches) fingers" : subtitle + containerView.accessibilityTraits = .button + + // Create content stack view + let stackView = UIStackView() + stackView.axis = .vertical + stackView.alignment = .center + stackView.distribution = .equalCentering + stackView.spacing = 8 + stackView.translatesAutoresizingMaskIntoConstraints = false + stackView.isAccessibilityElement = false + + // Add title label + if !title.isEmpty { + let titleLabel = UILabel() + titleLabel.text = title + titleLabel.font = UIFont.systemFont(ofSize: 18, weight: .semibold) + titleLabel.textColor = .white + titleLabel.textAlignment = .center + titleLabel.isAccessibilityElement = false + stackView.addArrangedSubview(titleLabel) + } + + // Add subtitle label + if !subtitle.isEmpty { + let subtitleLabel = UILabel() + subtitleLabel.text = subtitle + subtitleLabel.font = UIFont.systemFont(ofSize: 14, weight: .regular) + subtitleLabel.textColor = UIColor.white.withAlphaComponent(0.9) + subtitleLabel.textAlignment = .center + subtitleLabel.numberOfLines = 0 + subtitleLabel.isAccessibilityElement = false + stackView.addArrangedSubview(subtitleLabel) + } + + // Add finger count indicator + let fingerCountLabel = UILabel() + fingerCountLabel.text = String(repeating: "👆", count: numberOfTouches) + fingerCountLabel.font = UIFont.systemFont(ofSize: 24) + fingerCountLabel.textAlignment = .center + fingerCountLabel.isAccessibilityElement = false + fingerCountLabel.accessibilityLabel = "\(numberOfTouches) fingers required" + stackView.addArrangedSubview(fingerCountLabel) + + containerView.addSubview(stackView) + + // Set up constraints + NSLayoutConstraint.activate([ + stackView.centerXAnchor.constraint(equalTo: containerView.centerXAnchor), + stackView.centerYAnchor.constraint(equalTo: containerView.centerYAnchor), + stackView.leadingAnchor.constraint(greaterThanOrEqualTo: containerView.leadingAnchor, constant: 16), + stackView.trailingAnchor.constraint(lessThanOrEqualTo: containerView.trailingAnchor, constant: -16) + ]) + + // Set up gesture recognizer + let tapGestureRecognizer = UITapGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleTap(sender:)) + ) + tapGestureRecognizer.numberOfTouchesRequired = numberOfTouches + tapGestureRecognizer.delegate = context.coordinator + containerView.addGestureRecognizer(tapGestureRecognizer) + + // Add visual feedback for touch + let longPressGesture = UILongPressGestureRecognizer( + target: context.coordinator, + action: #selector(Coordinator.handleLongPress(sender:)) + ) + longPressGesture.minimumPressDuration = 0.0 + longPressGesture.numberOfTouchesRequired = numberOfTouches + longPressGesture.delegate = context.coordinator + containerView.addGestureRecognizer(longPressGesture) + + // Add border for better visual definition + containerView.layer.borderWidth = 2 + containerView.layer.borderColor = UIColor.white.withAlphaComponent(0.3).cgColor + + return containerView } func updateUIView(_ uiView: UIView, context: Context) { - + uiView.backgroundColor = UIColor(backgroundColor) } - class Coordinator { + class Coordinator: NSObject, UIGestureRecognizerDelegate { var tapCallback: (UITapGestureRecognizer) -> Void init(tapCallback: @escaping (UITapGestureRecognizer) -> Void) { self.tapCallback = tapCallback } + func gestureRecognizer(_ gestureRecognizer: UIGestureRecognizer, shouldRecognizeSimultaneouslyWith otherGestureRecognizer: UIGestureRecognizer) -> Bool { + return true + } + @objc func handleTap(sender: UITapGestureRecognizer) { - self.tapCallback(sender) + Task { @MainActor in + // Add haptic feedback + let impactFeedback = UIImpactFeedbackGenerator(style: .medium) + impactFeedback.impactOccurred() + + self.tapCallback(sender) + } + } + + @objc func handleLongPress(sender: UILongPressGestureRecognizer) { + switch sender.state { + case .began: + UIView.animate(withDuration: 0.1) { + sender.view?.transform = CGAffineTransform(scaleX: 0.95, y: 0.95) + sender.view?.alpha = 0.8 + } + case .ended, .cancelled: + UIView.animate(withDuration: 0.1) { + sender.view?.transform = CGAffineTransform.identity + sender.view?.alpha = 1.0 + } + default: + break + } } } } diff --git a/Client/ClientTests/ClientTests.swift b/Client/ClientTests/ClientTests.swift index 3853ab6..b254f21 100644 --- a/Client/ClientTests/ClientTests.swift +++ b/Client/ClientTests/ClientTests.swift @@ -2,26 +2,12 @@ import UIUnitTest import XCTest -class ClientTests: XCTestCase { +@MainActor +class ClientTests: XCTestCase, @unchecked Sendable { override func setUp() async throws { - await UIView.setAnimationsEnabled(false) + UIView.setAnimationsEnabled(false) } - @MainActor - func testTap() async throws { - let app = try await App() - - showView(MySettingTable()) - - let somethingButton = try await app.buttons["Something"] - try await Assert(somethingButton.isHittable) - - try await somethingButton.tap() - - try await app.staticTexts["Something View"].assertElementExists() - } - - @MainActor func testExists() async throws { let app = try await App() @@ -36,18 +22,6 @@ class ClientTests: XCTestCase { try await app.staticTexts["Value: Hello world"].assertElementExists() } - @MainActor - func testDoubleTap() async throws { - let app = try await App() - - showView(MySettingTable()) - - try await app.staticTexts["Double tap"].assertElementExists().doubleTap() - - try await app.staticTexts["Value: Double tap"].assertElementExists() - } - - @MainActor func testEnterText() async throws { let app = try await App() @@ -62,7 +36,6 @@ class ClientTests: XCTestCase { try await app.staticTexts["Text value: Hello world"].assertElementExists() } - @MainActor func testSwipeActions() async throws { let app = try await App() @@ -96,7 +69,6 @@ class ClientTests: XCTestCase { try await app.staticTexts["Direction: Right"].assertElementExists() } - @MainActor func testWaitForExistence() async throws { let app = try await App() @@ -105,23 +77,10 @@ class ClientTests: XCTestCase { try await app.staticTexts["Hello world!"].assertElementDoesntExists() try await app.buttons["Show Message"].tap() - try await app.staticTexts["Hello world!"].assertElementExists() - } - - @MainActor - func testPressWithDuration() async throws { - let app = try await App() - - showView(PressAndHoldView()) - - try await app.staticTexts["Hello world!"].assertElementDoesntExists() - - try await app.staticTexts["Press and hold"].assertElementExists().press(forDuration: 2.5) try await app.staticTexts["Hello world!"].assertElementExists() } - @MainActor func testHomeButtonAndLaunch() async throws { let app = try await App() @@ -135,29 +94,6 @@ class ClientTests: XCTestCase { try await app.staticTexts["WasInBackground: true"].assertElementExists() } - @MainActor - func testTwoFingerTap() async throws { - let app = try await App() - - showView(TapView()) - - try await app.otherElements["TwoFingersView"].assertElementExists().twoFingerTap() - - try await app.staticTexts["Two fingers tapped"].assertElementExists() - } - - @MainActor - func testThreeFingerTap() async throws { - let app = try await App() - - showView(TapView()) - - try await app.otherElements["ThreeFingersView"].assertElementExists().tap(withNumberOfTaps: 1, numberOfTouches: 3) - - try await app.staticTexts["Three fingers tapped"].assertElementExists() - } - - @MainActor func testPinch() async throws { let app = try await App() @@ -170,7 +106,6 @@ class ClientTests: XCTestCase { try await app.staticTexts["Did scale? Yes"].assertElementExists() } - @MainActor func testRotate() async throws { let app = try await App() @@ -183,30 +118,16 @@ class ClientTests: XCTestCase { try await app.staticTexts["Did rotate? Yes"].assertElementExists() } - @MainActor func testMatchingWithPredicate() async throws { let app = try await App() showView(SomethingView()) - try await app.staticTexts.element(matching: "label == \"SomethingViewAccessbilityLabel\"").assertElementExists() - } - - @MainActor - func testTapSync() { - let app = App() - - showView(MySettingTable()) - - let somethingButton = app.buttons["Something"] - Assert(somethingButton.isHittable) - - somethingButton.tap() - - app.staticTexts["Something View"].assertElementExists(timeout: 2) + try await app.staticTexts + .element(matching: NSPredicate(format: "label == %@", "SomethingViewAccessbilityLabel")) + .assertElementExists() } - @MainActor func testExistsSync() { let app = App() @@ -217,18 +138,6 @@ class ClientTests: XCTestCase { app.staticTexts["Value: Hello world"].assertElementExists() } - @MainActor - func testDoubleTapSync() { - let app = App() - - showView(MySettingTable()) - - app.staticTexts["Double tap"].assertElementExists().doubleTap() - - app.staticTexts["Value: Double tap"].assertElementExists() - } - - @MainActor func testEnterTextSync() { let app = App() @@ -243,7 +152,6 @@ class ClientTests: XCTestCase { app.staticTexts["Text value: Hello world"].assertElementExists() } - @MainActor func testSwipeActionsSync() { let app = App() @@ -277,7 +185,6 @@ class ClientTests: XCTestCase { app.staticTexts["Direction: Right"].assertElementExists() } - @MainActor func testWaitForExistenceSync() { let app = App() @@ -290,20 +197,6 @@ class ClientTests: XCTestCase { app.staticTexts["Hello world!"].assertElementExists(timeout: 2) } - @MainActor - func testPressWithDurationSync() { - let app = App() - - showView(PressAndHoldView()) - - app.staticTexts["Hello world!"].assertElementDoesntExists() - - app.staticTexts["Press and hold"].press(forDuration: 2.5) - - app.staticTexts["Hello world!"].assertElementExists(timeout: 2) - } - - @MainActor func testHomeButtonAndLaunchSync() { let app = App() @@ -317,31 +210,6 @@ class ClientTests: XCTestCase { app.staticTexts["WasInBackground: true"].assertElementExists() } - @MainActor - func testTwoFingerTapSync() { - let app = App() - - showView(TapView()) - - app.otherElements["TwoFingersView"] - .assertElementExists() - .twoFingerTap() - - app.staticTexts["Two fingers tapped"].assertElementExists() - } - - @MainActor - func testThreeFingerTapSync() { - let app = App() - - showView(TapView()) - - app.otherElements["ThreeFingersView"].assertElementExists().tap(withNumberOfTaps: 1, numberOfTouches: 3) - - app.staticTexts["Three fingers tapped"].assertElementExists() - } - - @MainActor func testPinchSync() { let app = App() @@ -356,7 +224,6 @@ class ClientTests: XCTestCase { app.staticTexts["Did scale? Yes"].assertElementExists() } - @MainActor func testRotateSync() { let app = App() @@ -371,18 +238,17 @@ class ClientTests: XCTestCase { app.staticTexts["Did rotate? Yes"].assertElementExists() } - @MainActor func testMatchingWithPredicateAsync() { let app = App() showView(SomethingView()) - let somethingView = app.staticTexts.element(matching: "label == \"SomethingViewAccessbilityLabel\"") + let somethingView = app.staticTexts + .element(matching: NSPredicate(format: "label == %@", "SomethingViewAccessbilityLabel")) somethingView.assertElementExists() } - @MainActor func testEnterTestOnWrongElementFails() { XCTExpectFailure("Expecting failure when attempting to type text into a non-text field element.") @@ -394,7 +260,6 @@ class ClientTests: XCTestCase { } @available(iOS 17.0, *) - @MainActor func testAccessibilityInspection() throws { XCTExpectFailure("Expecting failure when performing an accessibility audit") @@ -406,11 +271,52 @@ class ClientTests: XCTestCase { } } -// Two classes to run in parallel using two simulators -class ClientTests2: ClientTests {} -class ClientTests3: ClientTests {} -class ClientTests4: ClientTests {} - -public func Assert(_ value: Bool, _ message: @Sendable @autoclosure () -> String = "", file: StaticString = #filePath, line: UInt = #line) { +public func Assert( + _ value: Bool, + _ message: @Sendable @autoclosure () -> String = "", + file: StaticString = #filePath, + line: UInt = #line +) { XCTAssert(value, message(), file: file, line: line) } + +extension Element { + @discardableResult + func assertElementExists2( + message: String? = nil, + timeout: TimeInterval = 1, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column + ) -> Element { + Executor.execute { + try await self.assertElementExists( + message: message, + timeout: timeout, + fileID: fileID, + filePath: filePath, + line: line, + column: column + ) + }.valueOrFailWithFallback(self) + } +} + +extension Result { + func valueOrFailWithFallback( + _ fallback: Success, + fileID _: StaticString = #fileID, + filePath _: StaticString = #filePath, + line _: UInt = #line, + column _: UInt = #column + ) -> Success { + switch self { + case let .success(result): + return result + case let .failure(error): + XCTFail(error.localizedDescription) + return fallback + } + } +} diff --git a/Client/ClientTests/SwiftTesting.swift b/Client/ClientTests/SwiftTesting.swift new file mode 100644 index 0000000..555a262 --- /dev/null +++ b/Client/ClientTests/SwiftTesting.swift @@ -0,0 +1,427 @@ +@testable import Client +import Testing +import UIKit +import UIUnitTest + +@Suite(.uiTest) +final class SwiftTesting { + @Test + func testTap() async throws { + let app = try await App() + + showView(MySettingTable()) + + let somethingButton = try await app.buttons["Something"] + + #expect(try await somethingButton.isHittable) + + try await somethingButton.tap() + + try await app.staticTexts["Something View"].assertElementExists() + } + + @Test + func testExists() async throws { + let app = try await App() + + showView(MySettingTable()) + + try await app.staticTexts["Hello world button"].assertElementExists() + + #expect(try await app.staticTexts["Hello world button"].isHittable) + + try await app.buttons["Hello world button"].tap() + + try await app.staticTexts["Value: Hello world"].assertElementExists() + } + + @Test + func testDoubleTap() async throws { + let app = try await App() + + showView(MySettingTable()) + + try await app.staticTexts["Double tap"].assertElementExists().doubleTap() + + try await app.staticTexts["Value: Double tap"].assertElementExists() + } + + @Test + func testEnterText() async throws { + let app = try await App() + + showView(MySettingTable()) + + try await app.buttons["TextField"].assertElementExists().tap() + + try await app.textFields["TextField-Default"].assertElementExists().tap() + + try await app.textFields["TextField-Default"].typeText("Hello world") + + try await app.staticTexts["Text value: Hello world"].assertElementExists() + } + + @Test + func testSwipeActions() async throws { + let app = try await App() + + showView(SwipeView()) + + try await app.staticTexts["Direction: No swipe detected"].assertElementExists() + + try await app.staticTexts["Swipe me"].swipeUp() + try await app.staticTexts["Direction: Up"].assertElementExists() + + try await app.staticTexts["Swipe me"].swipeDown() + try await app.staticTexts["Direction: Down"].assertElementExists() + + try await app.staticTexts["Swipe me"].swipeLeft() + try await app.staticTexts["Direction: Left"].assertElementExists() + + try await app.staticTexts["Swipe me"].swipeRight() + try await app.staticTexts["Direction: Right"].assertElementExists() + + try await app.staticTexts["Swipe me"].swipeUp(velocity: 100) + try await app.staticTexts["Direction: Up"].assertElementExists() + + try await app.staticTexts["Swipe me"].swipeDown(velocity: 100.5) + try await app.staticTexts["Direction: Down"].assertElementExists() + + let velocityCGFloat: CGFloat = 500 + try await app.staticTexts["Swipe me"].swipeLeft(velocity: GestureVelocity(velocityCGFloat)) + try await app.staticTexts["Direction: Left"].assertElementExists() + + try await app.staticTexts["Swipe me"].swipeRight(velocity: 600) + try await app.staticTexts["Direction: Right"].assertElementExists() + } + + @Test + func testWaitForExistence() async throws { + let app = try await App() + + showView(WaitForExistenceView()) + + try await app.staticTexts["Hello world!"].assertElementDoesntExists() + + try await app.buttons["Show Message"].tap() + + try await app.staticTexts["Hello world!"].assertElementExists() + } + + @Test + func testPressWithDuration() async throws { + let app = try await App() + + showView(PressAndHoldView()) + + try await app.staticTexts["Hello world!"].assertElementDoesntExists() + + try await app.staticTexts["Press and hold"].assertElementExists().press(forDuration: 2.5) + + try await app.staticTexts["Hello world!"].assertElementExists() + } + + @Test + func testHomeButtonAndLaunch() async throws { + let app = try await App() + + showView(GoToBackgroundAndBackView()) + + try await app.staticTexts["WasInBackground: false"].assertElementExists() + + try await app.pressHomeButton() + try await app.activate() + + try await app.staticTexts["WasInBackground: true"].assertElementExists() + } + + @Test + func testTwoFingerTap() async throws { + let app = try await App() + + showView(TapView()) + + try await app.buttons["TwoFingersView"].assertElementExists().twoFingerTap() + + try await app.staticTexts["Two fingers tapped successfully!"].assertElementExists() + } + + @Test + func testThreeFingerTap() async throws { + let app = try await App() + + showView(TapView()) + + try await app.buttons["ThreeFingersView"].assertElementExists() + .tap(withNumberOfTaps: 1, numberOfTouches: 3) + + try await app.staticTexts["Three fingers tapped successfully!"].assertElementExists() + + try await print(app.debugDescription) + } + + @Test + func testPinch() async throws { + let app = try await App() + + showView(PinchView()) + + try await app.staticTexts["Did scale? No"].assertElementExists() + + try await app.staticTexts["PinchContainer"].assertElementExists().pinch(withScale: 1.5, velocity: 1) + + try await app.staticTexts["Did scale? Yes"].assertElementExists() + } + + @Test + func testRotate() async throws { + let app = try await App() + + showView(RotateView()) + + try await app.staticTexts["Did rotate? No"].assertElementExists() + + try await app.staticTexts["Rotate me!"].assertElementExists().rotate(0.2, withVelocity: 1) + + try await app.staticTexts["Did rotate? Yes"].assertElementExists() + } + + @Test + func testMatchingWithPredicate() async throws { + let app = try await App() + + showView(SomethingView()) + + try await app.staticTexts.element(matching: NSPredicate(format: "label == %@", "SomethingViewAccessbilityLabel")) + .assertElementExists() + } + + @Test + func testTapSync() { + let app = App() + + showView(MySettingTable()) + + let somethingButton = app.buttons["Something"] + Assert(somethingButton.isHittable) + + somethingButton.tap() + + app.staticTexts["Something View"].assertElementExists(timeout: 2) + } + + @Test + func testExistsSync() { + let app = App() + + showView(MySettingTable()) + + app.buttons["Hello world button"].assertElementExists().tap() + + app.staticTexts["Value: Hello world"].assertElementExists() + } + + @Test + func testDoubleTapSync() { + let app = App() + + showView(MySettingTable()) + + app.staticTexts["Double tap"].assertElementExists().doubleTap() + + app.staticTexts["Value: Double tap"].assertElementExists() + } + + @Test + func testEnterTextSync() { + let app = App() + + showView(MySettingTable()) + + app.buttons["TextField"].assertElementExists().tap() + + app.textFields["TextField-Default"].assertElementExists().tap() + + app.textFields["TextField-Default"].typeText("Hello world") + + app.staticTexts["Text value: Hello world"].assertElementExists() + } + + @Test + func testSwipeActionsSync() { + let app = App() + + showView(SwipeView()) + + app.staticTexts["Direction: No swipe detected"].assertElementExists() + + app.staticTexts["Swipe me"].swipeUp() + app.staticTexts["Direction: Up"].assertElementExists() + + app.staticTexts["Swipe me"].swipeDown() + app.staticTexts["Direction: Down"].assertElementExists() + + app.staticTexts["Swipe me"].swipeLeft() + app.staticTexts["Direction: Left"].assertElementExists() + + app.staticTexts["Swipe me"].swipeRight() + app.staticTexts["Direction: Right"].assertElementExists() + + app.staticTexts["Swipe me"].swipeUp(velocity: 100) + app.staticTexts["Direction: Up"].assertElementExists() + + app.staticTexts["Swipe me"].swipeDown(velocity: 100.5) + app.staticTexts["Direction: Down"].assertElementExists() + + let velocityCGFloat: CGFloat = 500 + app.staticTexts["Swipe me"].swipeLeft(velocity: GestureVelocity(velocityCGFloat)) + app.staticTexts["Direction: Left"].assertElementExists() + + app.staticTexts["Swipe me"].swipeRight(velocity: 600) + app.staticTexts["Direction: Right"].assertElementExists() + } + + // Failing because it is blocking the main thread. + // No idea why it works if we test using XCTest. Ideally, I think I will deprecate the sync API + @Test + func testWaitForExistenceSync() { + let app = App() + + showView(WaitForExistenceView()) + + app.staticTexts["Hello world!"].assertElementDoesntExists() + + app.buttons["Show Message"].assertElementExists().tap() + + app.staticTexts["Hello world!"].assertElementExists(timeout: 3) + } + + @Test + func testPressWithDurationSync() { + let app = App() + + showView(PressAndHoldView()) + + app.staticTexts["Hello world!"].assertElementDoesntExists() + + app.staticTexts["Press and hold"].press(forDuration: 2.5) + + app.staticTexts["Hello world!"].assertElementExists(timeout: 2) + } + + @Test + func testHomeButtonAndLaunchSync() { + let app = App() + + showView(GoToBackgroundAndBackView()) + + app.staticTexts["WasInBackground: false"].assertElementExists() + + app.pressHomeButton() + app.activate() + + app.staticTexts["WasInBackground: true"].assertElementExists() + } + + @Test + func testTwoFingerTapSync() { + let app = App() + + showView(TapView()) + + app.buttons["TwoFingersView"] + .assertElementExists() + .twoFingerTap() + + app.staticTexts["Two fingers tapped successfully!"].assertElementExists() + } + + @Test + func testThreeFingerTapSync() { + let app = App() + + showView(TapView()) + + app.buttons["ThreeFingersView"].assertElementExists().tap(withNumberOfTaps: 1, numberOfTouches: 3) + + app.staticTexts["Three fingers tapped successfully!"].assertElementExists() + } + + @Test + func testPinchSync() { + let app = App() + + showView(PinchView()) + + app.staticTexts["Did scale? No"].assertElementExists() + + app.staticTexts["PinchContainer"] + .assertElementExists() + .pinch(withScale: 1.5, velocity: 1) + + app.staticTexts["Did scale? Yes"].assertElementExists() + } + + @Test + func testRotateSync() { + let app = App() + + showView(RotateView()) + + app.staticTexts["Did rotate? No"].assertElementExists() + + app.staticTexts["Rotate me!"] + .assertElementExists() + .rotate(0.2, withVelocity: 1) + + app.staticTexts["Did rotate? Yes"].assertElementExists() + } + + @Test + func testMatchingWithPredicateAsync() { + let app = App() + + showView(SomethingView()) + + let somethingView = app.staticTexts + .element(matching: NSPredicate(format: "label == %@", "SomethingViewAccessbilityLabel")) + + somethingView.assertElementExists() + } + + @Test + func testEnterTestOnWrongElementFails() { + withKnownIssue { + let app = App() + + showView(WaitForExistenceView()) + + app.buttons["Show Message"].typeText("Hello world") + } + } + + @available(iOS 17.0, *) + @Test + func testAccessibilityInspection() throws { + withKnownIssue { + let app = App() + + showView(AccessibilityAuditView()) + + try app.performAccessibilityAudit() + } + } +} + +// MARK: - + +public extension Trait where Self == ParallelizationTrait { + /// A trait that serializes the test to which it is applied. + /// + /// ## See Also + /// + /// - ``ParallelizationTrait`` + static var uiTest: Self { + .serialized + } +} diff --git a/Client/ClientTests/TapTests.swift b/Client/ClientTests/TapTests.swift new file mode 100644 index 0000000..ea8ec8c --- /dev/null +++ b/Client/ClientTests/TapTests.swift @@ -0,0 +1,121 @@ +@testable import Client +import UIUnitTest +import XCTest + +@MainActor +class TapTests: XCTestCase { + func testTap() async throws { + let app = try await App() + + showView(MySettingTable()) + + let somethingButton = try await app.buttons["Something"] + try await Assert(somethingButton.isHittable) + + try await somethingButton.tap() + + try await app.staticTexts["Something View"].assertElementExists() + } + + func testTapSync() { + let app = App() + + showView(MySettingTable()) + + let somethingButton = app.buttons["Something"] + Assert(somethingButton.isHittable) + + somethingButton.tap() + + app.staticTexts["Something View"].assertElementExists(timeout: 2) + } + + func testDoubleTap() async throws { + let app = try await App() + + showView(MySettingTable()) + + try await app.staticTexts["Double tap"].assertElementExists().doubleTap() + + try await app.staticTexts["Value: Double tap"].assertElementExists() + } + + func testDoubleTapSync() { + let app = App() + + showView(MySettingTable()) + + app.staticTexts["Double tap"].assertElementExists().doubleTap() + + app.staticTexts["Value: Double tap"].assertElementExists() + } + + func testPressWithDuration() async throws { + let app = try await App() + + showView(PressAndHoldView()) + + try await app.staticTexts["Hello world!"].assertElementDoesntExists() + + try await app.staticTexts["Press and hold"].assertElementExists().press(forDuration: 2.5) + + try await app.staticTexts["Hello world!"].assertElementExists() + } + + func testPressWithDurationSync() { + let app = App() + + showView(PressAndHoldView()) + + app.staticTexts["Hello world!"].assertElementDoesntExists() + + app.staticTexts["Press and hold"].press(forDuration: 2.5) + + app.staticTexts["Hello world!"].assertElementExists(timeout: 2) + } + + func testTwoFingerTap() async throws { + let app = try await App() + + showView(TapView()) + + try await app.buttons["TwoFingersView"].assertElementExists().twoFingerTap() + + try await app.staticTexts["Two fingers tapped successfully!"].assertElementExists() + } + + func testTwoFingerTapSync() { + let app = App() + + showView(TapView()) + + app.buttons["TwoFingersView"] + .assertElementExists() + .twoFingerTap() + + app.staticTexts["Two fingers tapped successfully!"].assertElementExists() + } + + func testThreeFingerTap() async throws { + let app = try await App() + + showView(TapView()) + + try await app.buttons["ThreeFingersView"] + .assertElementExists() + .tap(withNumberOfTaps: 1, numberOfTouches: 3) + + try await app.staticTexts["Three fingers tapped successfully!"].assertElementExists() + } + + func testThreeFingerTapSync() { + let app = App() + + showView(TapView()) + + app.buttons["ThreeFingersView"].assertElementExists() + .tap(withNumberOfTaps: 1, numberOfTouches: 3) + + app.staticTexts["Three fingers tapped successfully!"].assertElementExists() + } +} diff --git a/Lib/.swiftpm/xcode/xcshareddata/xcschemes/UIUnitTestCLI.xcscheme b/Lib/.swiftpm/xcode/xcshareddata/xcschemes/UIUnitTestCLI.xcscheme deleted file mode 100644 index c0b960a..0000000 --- a/Lib/.swiftpm/xcode/xcshareddata/xcschemes/UIUnitTestCLI.xcscheme +++ /dev/null @@ -1,81 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - diff --git a/Lib/Sources/UIUnitTest/AccessibilityAuditType.swift b/Lib/Sources/UIUnitTest/AccessibilityAuditType.swift index 35dfb40..0c8d239 100644 --- a/Lib/Sources/UIUnitTest/AccessibilityAuditType.swift +++ b/Lib/Sources/UIUnitTest/AccessibilityAuditType.swift @@ -1,11 +1,5 @@ -// -// File.swift -// -// -// Created by Bruno Mazzo on 7/5/2024. -// - import Foundation +import UIUnitTestAPI public struct AccessibilityAuditType: RawRepresentable, OptionSet, Codable, Sendable { @@ -15,27 +9,26 @@ public struct AccessibilityAuditType: RawRepresentable, OptionSet, Codable, Send self.rawValue = rawValue } - public static let contrast = AccessibilityAuditType(rawValue: 1 << 0) - public static let elementDetection = AccessibilityAuditType(rawValue: 1 << 1) - public static let hitRegion = AccessibilityAuditType(rawValue: 1 << 2) + public static let contrast = AccessibilityAuditType(rawValue: 1 << 0) + public static let elementDetection = AccessibilityAuditType(rawValue: 1 << 1) + public static let hitRegion = AccessibilityAuditType(rawValue: 1 << 2) public static let sufficientElementDescription = AccessibilityAuditType(rawValue: 1 << 3) -#if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_WATCH || TARGET_OS_SIMULATOR - // Types of audits supported on iOS, watchOS, and tvOS - public static let dynamicType = AccessibilityAuditType(rawValue: 1 << 16) - public static let textClipped = AccessibilityAuditType(rawValue: 1 << 17) - public static let trait = AccessibilityAuditType(rawValue: 1 << 18) - -#elseif TARGET_OS_OSX || TARGET_OS_MACCATALYST - // Types of audits supported on macOS - public static let action = AccessibilityAuditType(rawValue: 1 << 32) - public static let parentChild = AccessibilityAuditType(rawValue: 1 << 33) -#endif - public static let all = AccessibilityAuditType(rawValue: ~0) + #if TARGET_OS_IOS || TARGET_OS_TV || TARGET_OS_WATCH || TARGET_OS_SIMULATOR + // Types of audits supported on iOS, watchOS, and tvOS + public static let dynamicType = AccessibilityAuditType(rawValue: 1 << 16) + public static let textClipped = AccessibilityAuditType(rawValue: 1 << 17) + public static let trait = AccessibilityAuditType(rawValue: 1 << 18) + + #elseif TARGET_OS_OSX || TARGET_OS_MACCATALYST + // Types of audits supported on macOS + public static let action = AccessibilityAuditType(rawValue: 1 << 32) + public static let parentChild = AccessibilityAuditType(rawValue: 1 << 33) + #endif + public static let all = AccessibilityAuditType(rawValue: ~0) } public final class AccessibilityAuditIssue: Codable, Sendable { - /// The element associated with the issue. public let element: Element? @@ -73,12 +66,12 @@ public final class AccessibilityAuditIssue: Codable, Sendable { if let element = try container.decodeIfPresent(UUID.self, forKey: .element) { self.element = Element(serverId: element) } else { - self.element = nil + element = nil } - self.compactDescription = try container.decode(String.self, forKey: .compactDescription) - self.detailedDescription = try container.decode(String.self, forKey: .detailedDescription) - self.auditType = try container.decode(AccessibilityAuditType.self, forKey: .auditType) + compactDescription = try container.decode(String.self, forKey: .compactDescription) + detailedDescription = try container.decode(String.self, forKey: .detailedDescription) + auditType = try container.decode(AccessibilityAuditType.self, forKey: .auditType) } public func encode(to encoder: any Encoder) throws { diff --git a/Lib/Sources/UIUnitTest/App.swift b/Lib/Sources/UIUnitTest/App.swift index 8c2923a..90fa70f 100644 --- a/Lib/Sources/UIUnitTest/App.swift +++ b/Lib/Sources/UIUnitTest/App.swift @@ -2,20 +2,44 @@ import Foundation import UIUnitTestAPI import XCTest +/// Creates a synchronous API instance for UI testing. +/// +/// - Returns: A `SyncApi` instance ready for use in UI testing. +/// - Note: This is a non-async version of the App initializer. @available(*, noasync) public func App() -> SyncApi { return SyncApi() } +/// Creates an asynchronous API instance for UI testing. +/// +/// - Returns: An `AsyncApi` instance ready for use in UI testing. +/// - Throws: Any error that occurs during API initialization. public func App() async throws -> AsyncApi { return try await AsyncApi() } +/// Represents an asynchronous API for UI testing, providing methods to interact with and manage application state. +/// +/// This class provides async methods for creating, activating, and interacting with an application during UI testing. +/// It supports both iOS 17+ accessibility audits and general app interaction methods. +/// +/// - Note: This class is thread-safe and can be used concurrently. public class AsyncApi: Element, @unchecked Sendable { + /// The unique identifier for the application being tested. let appId: String + /// Provides a synchronous API interface for this asynchronous API instance. + /// + /// - Returns: A `SyncApi` wrapping the current `AsyncApi` instance. public var syncAPI: SyncApi { SyncApi(asyncApi: self) } + /// Initializes an `AsyncApi` instance for a specific application. + /// + /// - Parameters: + /// - appId: The bundle identifier of the application to test. Defaults to the main bundle identifier. + /// - activate: A boolean indicating whether to activate the application immediately. Defaults to `true`. + /// - Throws: An error if the application creation fails. public init(appId: String = Bundle.main.bundleIdentifier!, activate: Bool = true) async throws { self.appId = appId super.init(serverId: UUID()) @@ -23,6 +47,11 @@ public class AsyncApi: Element, @unchecked Sendable { try await create(activate: activate) } + /// Initializes a non-async `AsyncApi` instance for a specific application. + /// + /// - Parameters: + /// - appId: The bundle identifier of the application to test. Defaults to the main bundle identifier. + /// - activate: A boolean indicating whether to activate the application immediately. Defaults to `true`. @available(*, noasync) public init(appId: String = Bundle.main.bundleIdentifier!, activate: Bool = true) { self.appId = appId @@ -31,14 +60,23 @@ public class AsyncApi: Element, @unchecked Sendable { create(activate: activate) } + /// A required initializer that is not implemented. + /// + /// - Throws: A fatal error indicating that this initializer is not supported. required init(from _: Decoder) throws { fatalError("init(from:) has not been implemented") } + /// Simulates pressing the home button on the device. + /// + /// - Throws: An error if the home button press fails. public func pressHomeButton() async throws { let _: Bool = try await callServer(path: "HomeButton", request: HomeButtonRequest()) } + /// A non-async version of `pressHomeButton()`. + /// + /// - Note: This method uses an executor to handle the async operation. @available(*, noasync) public func pressHomeButton() { Executor.execute { @@ -46,12 +84,18 @@ public class AsyncApi: Element, @unchecked Sendable { }.valueOrFailWithFallback(()) } + /// Activates the application. + /// + /// - Throws: An error if the activation fails. public func activate() async throws { let activateRequestData = ActivateRequest(serverId: serverId) let _: Bool = try await callServer(path: "Activate", request: activateRequestData) } + /// A non-async version of `activate()`. + /// + /// - Note: This method uses an executor to handle the async operation. @available(*, noasync) public func activate() { Executor.execute { @@ -59,6 +103,12 @@ public class AsyncApi: Element, @unchecked Sendable { }.valueOrFailWithFallback(()) } + /// Creates and sets up the application for testing. + /// + /// - Parameters: + /// - activate: A boolean indicating whether to activate the application immediately. + /// - timeout: The maximum time to wait for application creation. Defaults to 30 seconds. + /// - Throws: An error if the application cannot be created within the specified timeout. public func create(activate: Bool, timeout: TimeInterval = 30_000_000_000) async throws { let start = Date() @@ -75,6 +125,12 @@ public class AsyncApi: Element, @unchecked Sendable { fail("Could not create server App") } + /// A non-async version of `create()`. + /// + /// - Parameters: + /// - activate: A boolean indicating whether to activate the application immediately. + /// - timeout: The maximum time to wait for application creation. Defaults to 30 seconds. + /// - Note: This method uses an executor to handle the async operation. @available(*, noasync) public func create(activate: Bool, timeout: TimeInterval = 30_000_000_000) { Executor.execute { @@ -82,6 +138,20 @@ public class AsyncApi: Element, @unchecked Sendable { }.valueOrFailWithFallback(()) } + /// Performs an accessibility audit on the application. + /// + /// This method checks the application for various accessibility issues based on the specified audit types. + /// + /// - Parameters: + /// - auditTypes: The types of accessibility audits to perform. Defaults to `.all`. + /// - issueHandler: An optional closure to handle individual accessibility issues. + /// - If the closure returns `true`, the issue is ignored. + /// - If the closure returns `false` or throws an error, the issue is considered a test failure. + /// - fileID: The file identifier for the test context. Defaults to the current file. + /// - filePath: The file path for the test context. Defaults to the current file path. + /// - line: The line number in the source code. Defaults to the current line. + /// - column: The column number in the source code. Defaults to the current column. + /// - Throws: An error if the accessibility audit fails or cannot be performed. @available(iOS 17.0, *) public func performAccessibilityAudit( for auditTypes: AccessibilityAuditType = .all, @@ -113,6 +183,18 @@ public class AsyncApi: Element, @unchecked Sendable { } } + /// A non-async version of `performAccessibilityAudit()`. + /// + /// - Parameters: + /// - auditTypes: The types of accessibility audits to perform. Defaults to `.all`. + /// - issueHandler: An optional closure to handle individual accessibility issues. + /// - If the closure returns `true`, the issue is ignored. + /// - If the closure returns `false` or throws an error, the issue is considered a test failure. + /// - fileID: The file identifier for the test context. Defaults to the current file. + /// - filePath: The file path for the test context. Defaults to the current file path. + /// - line: The line number in the source code. Defaults to the current line. + /// - column: The column number in the source code. Defaults to the current column. + /// - Throws: An error if the accessibility audit fails or cannot be performed. @available(iOS 17.0, *) public func performAccessibilityAudit( for auditTypes: AccessibilityAuditType = .all, @@ -135,12 +217,27 @@ public class AsyncApi: Element, @unchecked Sendable { } } +/// Represents a synchronous API for UI testing, providing methods to interact with and manage application state. +/// +/// This class provides synchronous methods for creating, activating, and interacting with an application during UI testing. +/// It serves as a non-async wrapper around the `AsyncApi` class, allowing easier use in synchronous testing contexts. +/// +/// - Note: This class is thread-safe and can be used concurrently. @available(*, noasync) public class SyncApi: SyncElement, @unchecked Sendable { + /// The underlying asynchronous API instance. let api: AsyncApi + /// Provides an asynchronous API interface for this synchronous API instance. + /// + /// - Returns: An `AsyncApi` wrapping the current `SyncApi` instance. public var asyncAPI: AsyncApi { api } + /// Initializes a synchronous API instance for a specific application. + /// + /// - Parameters: + /// - appId: The bundle identifier of the application to test. Defaults to the main bundle identifier. + /// - activate: A boolean indicating whether to activate the application immediately. Defaults to `true`. @available(*, noasync) public init(appId: String = Bundle.main.bundleIdentifier!, activate: Bool = true) { api = AsyncApi(appId: appId) @@ -148,33 +245,66 @@ public class SyncApi: SyncElement, @unchecked Sendable { create(activate: activate) } + /// Initializes a synchronous API instance from an existing asynchronous API. + /// + /// - Parameters: + /// - asyncApi: The `AsyncApi` instance to wrap. public init(asyncApi: AsyncApi) { api = asyncApi super.init(element: api) } + /// A required initializer that is not implemented. + /// + /// - Throws: A fatal error indicating that this initializer is not supported. required init(from _: Decoder) throws { fatalError("init(from:) has not been implemented") } + /// Simulates pressing the home button on the device. + /// + /// - Note: This method uses an executor to handle the async operation. public func pressHomeButton() { Executor.execute { try await self.api.pressHomeButton() }.valueOrFailWithFallback(()) } + /// Activates the application. + /// + /// - Note: This method uses an executor to handle the async operation. public func activate() { Executor.execute { try await self.api.activate() }.valueOrFailWithFallback(()) } + /// Creates and sets up the application for testing. + /// + /// - Parameters: + /// - activate: A boolean indicating whether to activate the application immediately. + /// - timeout: The maximum time to wait for application creation. Defaults to 30 seconds. + /// - Note: This method uses an executor to handle the async operation. public func create(activate: Bool, timeout: TimeInterval = 30_000_000_000) { Executor.execute { try await self.api.create(activate: activate, timeout: timeout) }.valueOrFailWithFallback(()) } + /// Performs an accessibility audit on the application. + /// + /// This method checks the application for various accessibility issues based on the specified audit types. + /// + /// - Parameters: + /// - auditTypes: The types of accessibility audits to perform. Defaults to `.all`. + /// - issueHandler: An optional closure to handle individual accessibility issues. + /// - If the closure returns `true`, the issue is ignored. + /// - If the closure returns `false` or throws an error, the issue is considered a test failure. + /// - fileID: The file identifier for the test context. Defaults to the current file. + /// - filePath: The file path for the test context. Defaults to the current file path. + /// - line: The line number in the source code. Defaults to the current line. + /// - column: The column number in the source code. Defaults to the current column. + /// - Throws: An error if the accessibility audit fails or cannot be performed. @available(iOS 17.0, *) public func performAccessibilityAudit( for auditTypes: AccessibilityAuditType = .all, diff --git a/Lib/Sources/UIUnitTest/CallServer.swift b/Lib/Sources/UIUnitTest/CallServer.swift index ed070e9..f37a2b9 100644 --- a/Lib/Sources/UIUnitTest/CallServer.swift +++ b/Lib/Sources/UIUnitTest/CallServer.swift @@ -1,104 +1,110 @@ import Foundation -#if canImport(UIKit) +import Synchronization import UIKit -#endif +import UIUnitTestAPI -@MainActor -func deviceId() -> Int { -#if canImport(UIKit) - let deviceName = UIDevice.current.name - var deviceId = 0 - let regulerExpression = try! NSRegularExpression(pattern: "Clone (\\d*) of .*") +final class ServerAPI: Sendable { + let port: Int - if let devicesNameMatch = regulerExpression.firstMatch(in: deviceName, range: NSRange(location: 0, length: deviceName.utf16.count)) { - if let swiftRange = Range(devicesNameMatch.range(at: 1), in: deviceName) { - let deviceIdString = deviceName[swiftRange] - deviceId = Int(deviceIdString) ?? 0 - } + static let shared = ServerAPI() + + init() { + port = 22087 + deviceId() } - return deviceId -#else - return 0 -#endif -} + func callServer( + path: String, + request: RequestData + ) async throws -> ResponseData { + let encoder = JSONEncoder() -@MainActor -internal func callServer( - path: String, - request: RequestData -) async throws -> ResponseData { - let encoder = JSONEncoder() + let activateUrl = URL(string: "http://localhost:\(port)/\(path)")! + var activateRequest = URLRequest(url: activateUrl) + activateRequest.httpMethod = "POST" + activateRequest.httpBody = try encoder.encode(request) - let port = 22087 + deviceId() + let (data, _) = try await URLSession.shared.data(for: activateRequest) - let activateUrl = URL(string: "http://localhost:\(port)/\(path)")! - var activateRequest = URLRequest(url: activateUrl) - activateRequest.httpMethod = "POST" - activateRequest.httpBody = try encoder.encode(request) + let decoder = JSONDecoder() - let (data, _) = try await URLSession.shared.data(for: activateRequest) + let result = try decoder.decode(UIResponse.self, from: data) - let decoder = JSONDecoder() + switch result.response { + case let .success(data: response): + return response + case let .error(error: error): + print(error.error) + throw NSError(domain: "Test", code: 1, userInfo: ["reason": error]) + } + } +} - let result = try decoder.decode(UIResponse.self, from: data) +final class SendableBox: @unchecked Sendable { + public var value: T? - switch result.response { - case .success(data: let response): - return response - case .error(error: let error): - print(error.error) - throw NSError(domain: "Test", code: 1, userInfo: ["reason": error]) + init(value: T? = nil) { + self.value = value } } -public enum Response { - case error(error: ErrorResponse) - case success(data: T) +func callServer( + path: String, + request: RequestData +) async throws -> ResponseData { + try await ServerAPI.shared.callServer(path: path, request: request) } -public struct ErrorResponse: Codable { - var error: String -} +let deviceNameMutex = Mutex(nil) + +func deviceId() -> Int { + let deviceName = syncFromMainActor { + UIDevice.current.name + } -public struct UIResponse: Codable { + var deviceId = 0 + guard let regulerExpression = try? NSRegularExpression(pattern: "Clone (\\d*) of .*") else { + fatalError("Invalid regular expression to match Clone simulators") + } - public let response: Response + let devicesNameMatch = regulerExpression.firstMatch( + in: deviceName, + range: NSRange(location: 0, length: deviceName.utf16.count) + ) - public init(response: T) { - self.response = .success(data: response) + guard let devicesNameMatch else { + return deviceId } - public init(error: String) { - self.response = .error(error: ErrorResponse(error: error)) + if let swiftRange = Range(devicesNameMatch.range(at: 1), in: deviceName) { + let deviceIdString = deviceName[swiftRange] + deviceId = Int(deviceIdString) ?? 0 } + return deviceId +} - enum CodingKeys: CodingKey { - case data - case error +// Ugly but let's try to remove @MainActor requirement from many of our functions +func syncFromMainActor(body: @MainActor @escaping () -> T) -> T { + // Objc/XCTest ignores Swift global actors + if Thread.isMainThread { + return MainActor.assumeIsolated { + body() + } } - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) + let deviceNameMutex = Mutex(nil) - if let error = try container.decodeIfPresent(ErrorResponse.self, forKey: .error) { - self.response = .error(error: error) - } else if let response = try container.decodeIfPresent(T.self, forKey: .data) { - self.response = .success(data: response) - } else { - fatalError("Invalid response") + Task { @MainActor in + deviceNameMutex.withLock { + $0 = body() } } - public func encode(to encoder: Encoder) throws { - var container = encoder.container(keyedBy: CodingKeys.self) - - switch self.response { - case .error(error: let error): - try container.encode(error, forKey: .error) - case .success(data: let data): - try container.encode(data, forKey: .data) + var deviceName: T! + while deviceName == nil { + deviceNameMutex.withLock { value in + deviceName = value } } + return deviceName } diff --git a/Lib/Sources/UIUnitTest/Element/Element.swift b/Lib/Sources/UIUnitTest/Element/Element.swift index ed8d225..ad3f1bc 100644 --- a/Lib/Sources/UIUnitTest/Element/Element.swift +++ b/Lib/Sources/UIUnitTest/Element/Element.swift @@ -2,11 +2,21 @@ import Foundation import UIUnitTestAPI import XCTest +/// A proxy representing a UI element in the target application. +/// +/// `Element` provides a set of methods and properties to interact with and query UI elements, +/// such as buttons, text fields, and labels. It communicates with a server running in the app +/// to perform actions and retrieve element information. public class Element: ElementTypeQueryProvider, @unchecked Sendable { + /// A special element representing an empty or non-existent element. public static let EmptyElement = Element(serverId: .zero) + /// The unique identifier for the server-side representation of this element. public let serverId: UUID + /// Initializes a new `Element` with a server ID. + /// + /// - Parameter serverId: The unique identifier for the element on the server. public init(serverId: UUID) { self.serverId = serverId } @@ -26,7 +36,9 @@ public class Element: ElementTypeQueryProvider, @unchecked Sendable { } } - // Need better way to represent any :c + /// The value of the element, such as the text of a text field or the value of a slider. + /// + /// - Note: The type of the value can vary depending on the element. public var value: String? { get async throws { let valueRequest = ElementPayload(serverId: serverId) @@ -49,64 +61,84 @@ public class Element: ElementTypeQueryProvider, @unchecked Sendable { return Query(serverId: queryResponse.serverId) } + /// Scrolls the element by a specified amount. + /// + /// - Parameters: + /// - deltaX: The horizontal distance to scroll. + /// - deltaY: The vertical distance to scroll. public func scroll(byDeltaX deltaX: CGFloat, deltaY: CGFloat) async throws { let activateRequestData = ScrollRequest(serverId: serverId, deltaX: deltaX, deltaY: deltaY) let _: Bool = try await callServer(path: "scroll", request: activateRequestData) } + /// Types the given text into the element. + /// + /// - Parameter text: The text to type. public func typeText(_ text: String) async throws { let activateRequestData = EnterTextRequest(serverId: serverId, textToEnter: text) let _: Bool = try await callServer(path: "typeText", request: activateRequestData) } + /// A string containing a detailed description of the element and its descendants. public var debugDescription: String { get async throws { try await callServer(path: "debugDescription", request: ElementPayload(serverId: serverId)) } } + /// The identifier of the element. public var identifier: String { get async throws { return try await callServer(path: "identifier", request: ElementPayload(serverId: serverId)) } } + /// The title of the element. public var title: String { get async throws { return try await callServer(path: "title", request: ElementPayload(serverId: serverId)) } } + /// The label of the element. public var label: String { get async throws { return try await callServer(path: "label", request: ElementPayload(serverId: serverId)) } } + /// The placeholder value of the element. public var placeholderValue: String? { get async throws { return try await callServer(path: "placeholderValue", request: ElementPayload(serverId: serverId)) } } + /// A Boolean value that indicates whether the element is selected. public var isSelected: Bool { get async throws { return try await callServer(path: "isSelected", request: ElementPayload(serverId: serverId)) } } + /// A Boolean value that indicates whether the element has keyboard focus. public var hasFocus: Bool { get async throws { return try await callServer(path: "hasFocus", request: ElementPayload(serverId: serverId)) } } + /// A Boolean value that indicates whether the element is enabled. public var isEnabled: Bool { get async throws { return try await callServer(path: "isEnabled", request: ElementPayload(serverId: serverId)) } } + /// Returns a coordinate within the element's frame. + /// + /// - Parameter normalizedOffset: A vector with values from 0.0 to 1.0, where (0.0, 0.0) is the top-left corner and (1.0, 1.0) is the bottom-right corner. + /// - Returns: A `Coordinate` object representing the point. public func coordinate(withNormalizedOffset normalizedOffset: CGVector) async throws -> Coordinate { let request = CoordinateRequest(serverId: serverId, normalizedOffset: normalizedOffset) let response: CoordinateResponse = try await callServer(path: "coordinate", request: request) @@ -117,30 +149,35 @@ public class Element: ElementTypeQueryProvider, @unchecked Sendable { ) } + /// The frame of the element in screen coordinates. public var frame: CGRect { get async throws { return try await callServer(path: "frame", request: ElementPayload(serverId: serverId)) } } + /// The horizontal size class of the element. public var horizontalSizeClass: SizeClass { get async throws { return try await callServer(path: "horizontalSizeClass", request: ElementPayload(serverId: serverId)) } } + /// The vertical size class of the element. public var verticalSizeClass: SizeClass { get async throws { return try await callServer(path: "verticalSizeClass", request: ElementPayload(serverId: serverId)) } } + /// The type of the element. public var elementType: ElementType { get async throws { return try await callServer(path: "elementType", request: ElementPayload(serverId: serverId)) } } + /// A query for all descendants of the element. public var any: Query { get async throws { try await descendants(matching: .any) @@ -149,6 +186,16 @@ public class Element: ElementTypeQueryProvider, @unchecked Sendable { } public extension Element { + /// Asserts that the element exists. + /// + /// - Parameters: + /// - message: An optional failure message. + /// - timeout: The amount of time to wait for the element to exist. + /// - fileID: The file ID of the test. + /// - filePath: The file path of the test. + /// - line: The line number of the test. + /// - column: The column number of the test. + /// - Returns: The element, if it exists. @discardableResult func assertElementExists( message: String? = nil, @@ -176,6 +223,16 @@ public extension Element { } } + /// Asserts that the element does not exist. + /// + /// - Parameters: + /// - message: An optional failure message. + /// - timeout: The amount of time to wait for the element to not exist. + /// - fileID: The file ID of the test. + /// - filePath: The file path of the test. + /// - line: The line number of the test. + /// - column: The column number of the test. + /// - Returns: The element, if it does not exist. @discardableResult func assertElementDoesntExists( message: String? = nil, @@ -203,21 +260,29 @@ public extension Element { } } +/// A synchronous wrapper for `Element`. public class SyncElement: SyncElementTypeQueryProvider, @unchecked Sendable { public var queryProvider: any ElementTypeQueryProvider { element } + /// A special element representing an empty or non-existent element. public static let EmptyElement = SyncElement(element: .EmptyElement) + /// The underlying asynchronous `Element`. public let element: Element + /// The unique identifier for the server-side representation of this element. public var serverId: UUID { element.serverId } + /// Initializes a new `SyncElement` with an `Element`. + /// + /// - Parameter element: The underlying asynchronous `Element`. public init(element: Element) { self.element = element } + /// A query for all descendants of the element. @available(*, noasync) public var any: SyncQuery { Executor.execute { @@ -227,6 +292,16 @@ public class SyncElement: SyncElementTypeQueryProvider, @unchecked Sendable { } public extension SyncElement { + /// Asserts that the element exists. + /// + /// - Parameters: + /// - message: An optional failure message. + /// - timeout: The amount of time to wait for the element to exist. + /// - fileID: The file ID of the test. + /// - filePath: The file path of the test. + /// - line: The line number of the test. + /// - column: The column number of the test. + /// - Returns: The element, if it exists. @discardableResult func assertElementExists( message: String? = nil, @@ -241,6 +316,16 @@ public extension SyncElement { }.valueOrFailWithFallback(self) } + /// Asserts that the element does not exist. + /// + /// - Parameters: + /// - message: An optional failure message. + /// - timeout: The amount of time to wait for the element to not exist. + /// - fileID: The file ID of the test. + /// - filePath: The file path of the test. + /// - line: The line number of the test. + /// - column: The column number of the test. + /// - Returns: The element, if it does not exist. @discardableResult func assertElementDoesntExists( message: String? = nil, diff --git a/Lib/Sources/UIUnitTest/Query/Query.swift b/Lib/Sources/UIUnitTest/Query/Query.swift index ef61c72..b3a29b6 100644 --- a/Lib/Sources/UIUnitTest/Query/Query.swift +++ b/Lib/Sources/UIUnitTest/Query/Query.swift @@ -49,7 +49,7 @@ public final class Query: ElementTypeQueryProvider, Sendable { } // Using autoclosure to erase the Sendable warning - public func element(matching predicate: @Sendable @autoclosure () -> String) async throws -> Element { + public func element(matching predicate: @Sendable @autoclosure () -> NSPredicate) async throws -> Element { let response: ElementPayload = try await callServer(path: "elementMatchingPredicate", request: PredicateRequest(serverId: serverId, predicate: predicate())) return Element(serverId: response.serverId) } @@ -83,7 +83,7 @@ public final class Query: ElementTypeQueryProvider, Sendable { return Query(serverId: queryResponse.serverId) } - public func matching(_ predicate: String) async throws -> Query { + public func matching(_ predicate: NSPredicate) async throws -> Query { let response: QueryResponse = try await callServer(path: "matchingPredicate", request: PredicateRequest(serverId: serverId, predicate: predicate)) return Query(serverId: response.serverId) } @@ -98,7 +98,7 @@ public final class Query: ElementTypeQueryProvider, Sendable { return Query(serverId: response.serverId) } - public func containing(_ predicate: String) async throws -> Query { + public func containing(_ predicate: NSPredicate) async throws -> Query { let response: QueryResponse = try await callServer(path: "containingPredicate", request: PredicateRequest(serverId: serverId, predicate: predicate)) return Query(serverId: response.serverId) } @@ -187,7 +187,7 @@ public final class SyncQuery: SyncElementTypeQueryProvider, Sendable { @available(*, noasync) // Using autoclosure to erase the Sendable warning - public func element(matching predicate: @Sendable @autoclosure @escaping () -> String) -> SyncElement { + public func element(matching predicate: @Sendable @autoclosure @escaping () -> NSPredicate) -> SyncElement { Executor.execute { try SyncElement(element: await self.query.element(matching: predicate())) }.valueOrFailWithFallback(.EmptyElement) @@ -229,9 +229,10 @@ public final class SyncQuery: SyncElementTypeQueryProvider, Sendable { } @available(*, noasync) - public func matching(_ predicate: String) -> SyncQuery { + public func matching(_ predicate: NSPredicate) -> SyncQuery { + let sendablebox = NSPredicateSendableBox(predicate: predicate) return Executor.execute { - try SyncQuery(query: await self.query.matching(predicate)) + try SyncQuery(query: await self.query.matching(sendablebox.predicate)) }.valueOrFailWithFallback(.EmptyQuery) } @@ -250,9 +251,10 @@ public final class SyncQuery: SyncElementTypeQueryProvider, Sendable { } @available(*, noasync) - public func containing(_ predicate: String) -> SyncQuery { + public func containing(_ predicate: NSPredicate) -> SyncQuery { + let sendablebox = NSPredicateSendableBox(predicate: predicate) return Executor.execute { - try SyncQuery(query: await self.query.containing(predicate)) + try SyncQuery(query: await self.query.containing(sendablebox.predicate)) }.valueOrFailWithFallback(.EmptyQuery) } diff --git a/Lib/Sources/UIUnitTest/XCTest+UI.swift b/Lib/Sources/UIUnitTest/XCTest+UI.swift index b64400f..c0e8b4f 100644 --- a/Lib/Sources/UIUnitTest/XCTest+UI.swift +++ b/Lib/Sources/UIUnitTest/XCTest+UI.swift @@ -1,42 +1,67 @@ import SwiftUI +#if canImport(Testing) + import Testing +#endif +import UIKit import XCTest -#if canImport(UIKit) - import UIKit - @MainActor - public func showView(_ view: some View) { - let window = getKeyWindow() - let hostingViewController = UIHostingController(rootView: view) - window.rootViewController = hostingViewController - } +@MainActor +private func showViewFromMainActor(_ view: some View) { + let window = getKeyWindow() + let hostingViewController = UIHostingController(rootView: view) + window.rootViewController = hostingViewController +} - @MainActor - public func showView(_ viewController: UIViewController) { - let window = getKeyWindow() - window.rootViewController = viewController +public func showView(_ view: T) { + let box = SendableBox(value: view) + _ = syncFromMainActor { + showViewFromMainActor(box.value!) + return true } +} - @MainActor - private func getKeyWindow() -> UIWindow { - return UIApplication.shared.connectedScenes.flatMap { scene -> [UIWindow] in - guard let windowScene = scene as? UIWindowScene else { - return [] - } +@MainActor +private func showViewFromMainActor(_ viewController: UIViewController) { + let window = getKeyWindow() + window.rootViewController = viewController +} - return windowScene.windows.filter { window in - window.isKeyWindow - } - }.first! +public func showView(_ view: UIViewController) { + let box = SendableBox(value: view) + _ = syncFromMainActor { + showViewFromMainActor(box.value!) + return true } +} - func fail( - _ message: String, - fileID _: StaticString = #fileID, - filePath: StaticString = #filePath, - line: UInt = #line, - column _: UInt = #column - ) { - XCTFail(message, file: filePath, line: line) - } +@MainActor +private func getKeyWindow() -> UIWindow { + return UIApplication.shared.connectedScenes.flatMap { scene -> [UIWindow] in + guard let windowScene = scene as? UIWindowScene else { + return [] + } -#endif + return windowScene.windows.filter { window in + window.isKeyWindow + } + }.first! +} + +func fail( + _ message: String, + fileID: StaticString = #fileID, + filePath: StaticString = #filePath, + line: UInt = #line, + column: UInt = #column +) { + #if canImport(Testing) + let sourceLocation = SourceLocation( + fileID: "\(fileID)", + filePath: "\(filePath)", + line: Int(line), + column: Int(column) + ) + Issue.record(Comment(stringLiteral: message), sourceLocation: sourceLocation) + #endif + XCTFail(message, file: filePath, line: line) +} diff --git a/Lib/Sources/UIUnitTestBuildPlugin/Plugin.swift b/Lib/Sources/UIUnitTestBuildPlugin/Plugin.swift index d65058b..d49837e 100644 --- a/Lib/Sources/UIUnitTestBuildPlugin/Plugin.swift +++ b/Lib/Sources/UIUnitTestBuildPlugin/Plugin.swift @@ -2,26 +2,25 @@ import Foundation import PackagePlugin #if canImport(XcodeProjectPlugin) -import XcodeProjectPlugin + import XcodeProjectPlugin -@main -struct MyPlugin: BuildToolPlugin, XcodeBuildToolPlugin { - func createBuildCommands(context: PackagePlugin.PluginContext, target: PackagePlugin.Target) async throws -> [PackagePlugin.Command] { - [] - } - - /// 👇 This entry point is called when operating on an Xcode project. - func createBuildCommands(context: XcodePluginContext, target: XcodeTarget) throws -> [Command] { + @main + struct MyPlugin: BuildToolPlugin, XcodeBuildToolPlugin { + func createBuildCommands(context _: PackagePlugin.PluginContext, target _: PackagePlugin.Target) async throws -> [PackagePlugin.Command] { + [] + } - return [ - .buildCommand( - displayName: "Start UI Test server", - executable: try context.tool(named: "UIUnitTestCLI").path, - arguments: [], - environment: [:], - outputFiles: [] - ), - ] + /// 👇 This entry point is called when operating on an Xcode project. + func createBuildCommands(context: XcodePluginContext, target _: XcodeTarget) throws -> [Command] { + return try [ + .buildCommand( + displayName: "Start UI Test server", + executable: context.tool(named: "UIUnitTestCLI").path, + arguments: [], + environment: [:], + outputFiles: [] + ), + ] + } } -} #endif diff --git a/Lib/Sources/UIUnitTestCLI/Resources/PreBuild.zip b/Lib/Sources/UIUnitTestCLI/Resources/PreBuild.zip index c15824d..59e39a8 100644 Binary files a/Lib/Sources/UIUnitTestCLI/Resources/PreBuild.zip and b/Lib/Sources/UIUnitTestCLI/Resources/PreBuild.zip differ diff --git a/Lib/Sources/UIUnitTestCLI/Resources/Server.zip b/Lib/Sources/UIUnitTestCLI/Resources/Server.zip index a8e8b63..fc22176 100644 Binary files a/Lib/Sources/UIUnitTestCLI/Resources/Server.zip and b/Lib/Sources/UIUnitTestCLI/Resources/Server.zip differ diff --git a/Package.resolved b/Package.resolved index 915ab58..e50e40b 100644 --- a/Package.resolved +++ b/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "7aabbac73f36b9bfa842ab7dade9880b2c77c129baf66e1c67c64d21dcd78c77", + "originHash" : "9696720fb1f7f05f0448a8b948f955684a459d1d059ed9cb58ed471939d007db", "pins" : [ { "identity" : "swift-argument-parser", "kind" : "remoteSourceControl", "location" : "https://github.com/apple/swift-argument-parser", "state" : { - "revision" : "46989693916f56d1186bd59ac15124caef896560", - "version" : "1.3.1" + "revision" : "41982a3656a71c768319979febd796c6fd111d5c", + "version" : "1.5.0" } } ], diff --git a/README.md b/README.md index a82fcea..851d893 100644 --- a/README.md +++ b/README.md @@ -1,60 +1,150 @@ # UIUnitTest -Run XCTest UI commands from your unit test. +## Overview -## How it works +UIUnitTest is a powerful testing framework that bridges the gap between unit testing and UI testing in Swift applications. It allows you to run UI commands directly from your unit tests, providing a more integrated and flexible testing approach. -When you run an UI test, Apple runs two processes: the UI test process and the app process. The UI test process is the one that runs your test code and the app process is the one that runs your full app. Because these two are different process, you lose the ability to modify your app state from your test code. UIUnitTest solves this problem by running your unit test instead of the app, and running the UI test process with a server to receives commands from your unit test code and execute them. +## Key Features + +- 🚀 Run UI commands within unit tests +- 🔍 Full access to app state during testing +- 🛠 Support for both SwiftUI and UIKit +- 🔬 Simplified testing workflow + +## How It Works + +Traditional UI testing in Apple's ecosystem involves two separate processes: +- UI Test Process: Runs test code +- App Process: Runs the full application + +This separation limits your ability to modify app state during testing. UIUnitTest solves this by: +- Running unit tests instead of the app +- Using a server to receive and execute commands from your test code + +## Prerequisites + +- Xcode 14.0+ +- Swift 5.7+ +- macOS 13.0+ +- iOS/iPadOS 16.0+ ## Installation -1. Install the package: +### Swift Package Manager + +1. In Xcode, go to `File` > `Add Packages...` +2. Enter the package URL: + ``` + https://github.com/BrunoMazzo/UIUnitTest.git + ``` +3. Select the version (recommended: latest stable) +4. Add to your unit test target + +### Manual Configuration + +1. Add the package to your `Package.swift`: + ```swift + .package(url: "https://github.com/BrunoMazzo/UIUnitTest.git", from: "0.4.0") + ``` + +## Setup + +### Test Scheme Configuration + +1. Open your Xcode scheme settings +2. Select your test target +3. Add pre-action script: + ```shell + $BUILD_DIR/../../SourcePackages/checkouts/UIUnitTest/start-server.sh + ``` +4. Add post-action script: + ```shell + $BUILD_DIR/../../SourcePackages/checkouts/UIUnitTest/stop-server.sh + ``` + +## Usage Examples + +### SwiftUI Testing ```swift -.package(url: "git@github.com:BrunoMazzo/UIUnitTest.git", from: "0.4.0") +import UIUnitTest + +class MyViewTests: XCTestCase { + @MainActor + func testButtonInteraction() { + let app = App() + + // Show a specific view + let loginView = LoginView() + showView(loginView) + + // Interact with UI elements + app.button(identifier: "loginButton").tap() + app.textField(identifier: "usernameField").enterText("testuser") + + // Make assertions + XCTAssertTrue(app.label(identifier: "welcomeLabel").exists) + } +} ``` -2. Add it to your Unit test target +### UIKit Testing -3. Add a server start on your test scheme pre action: +```swift +import UIUnitTest - 3.1 Select your test target on `Provide build settings from` - - 3.2 Add the command: - ```shell - - $BUILD_DIR/../../SourcePackages/checkouts/UIUnitTest/start-server.sh - ``` +class MyViewControllerTests: XCTestCase { + @MainActor + func testViewControllerFlow() { + let app = App() + + // Show a UIViewController + let profileVC = ProfileViewController() + showViewController(profileVC) + + // Interact with UI elements + app.button(identifier: "editProfileButton").tap() + app.textField(identifier: "nameField").enterText("John Doe") + } +} +``` - ![Pre action panel](docs/pre-action.png) +## Troubleshooting - 3.3 Add post action to stop the server: - ```shell - - $BUILD_DIR/../../SourcePackages/checkouts/UIUnitTest/stop-server.sh - ``` +### Common Issues -4. Start coding +- **Server Not Starting**: Ensure scripts have execute permissions +- **Command Not Found**: Verify Xcode build settings +- **Test Failures**: Check server logs in Xcode's report navigator +### Debugging -## Usage +1. Enable verbose logging in your test configuration +2. Check UIUnitTest server logs +3. Verify package installation -```swift +## Contributing -import UIUnitTest +Contributions are welcome! Please follow these steps: -... +1. Fork the repository +2. Create a feature branch +3. Make your changes +4. Write tests for new functionality +5. Ensure all tests pass +6. Submit a pull request -@MainActor -func testExample() { - let app = App() +## License - let viewYouWantToTest = YourSwiftUIView(...) // or UIViewController +This project is licensed under the MIT License. - showView(viewYouWantToTest) - - app.button(identifier: "some button identifier").tap() +## Support - ... -} -``` +- Open an issue on GitHub for bug reports +- Discussions are welcome in the GitHub Discussions section + +## Performance Tips + +- Keep tests focused and concise +- Use `@MainActor` for UI-related tests +- Minimize complex state mutations during tests diff --git a/Server/Server.xcodeproj/project.pbxproj b/Server/Server.xcodeproj/project.pbxproj index 5b8dcb6..3decad1 100644 --- a/Server/Server.xcodeproj/project.pbxproj +++ b/Server/Server.xcodeproj/project.pbxproj @@ -3,215 +3,187 @@ archiveVersion = 1; classes = { }; - objectVersion = 60; + objectVersion = 77; objects = { /* Begin PBXBuildFile section */ - 1D2C63902A3403B80012BB5A /* Cache.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1D2C638F2A3403B80012BB5A /* Cache.swift */; }; - 1D98A06F2CE3592300CDE6BC /* UIUnitTestAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 1D98A06E2CE3592300CDE6BC /* UIUnitTestAPI */; }; - 1DA1932D29B4260500322869 /* ServerApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA1932C29B4260500322869 /* ServerApp.swift */; }; - 1DA1932F29B4260500322869 /* ContentView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA1932E29B4260500322869 /* ContentView.swift */; }; - 1DA1935929B4267200322869 /* UIServer.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA1935729B4267200322869 /* UIServer.swift */; }; - 1DA1935A29B4267200322869 /* UITestServerTest.swift in Sources */ = {isa = PBXBuildFile; fileRef = 1DA1935829B4267200322869 /* UITestServerTest.swift */; }; - 1DA1935D29B4270F00322869 /* FlyingFox in Frameworks */ = {isa = PBXBuildFile; productRef = 1DA1935C29B4270F00322869 /* FlyingFox */; }; + 1D53088E2CDF5897002DB5FC /* UIUnitTestAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 1D53088D2CDF5897002DB5FC /* UIUnitTestAPI */; }; + 1D756F432CC8A041000563FE /* FlyingFox in Frameworks */ = {isa = PBXBuildFile; productRef = 1D756F422CC8A041000563FE /* FlyingFox */; }; + 1D756F462CC8A06D000563FE /* UIUnitTestAPI in Frameworks */ = {isa = PBXBuildFile; productRef = 1D756F452CC8A06D000563FE /* UIUnitTestAPI */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ - 1DA1934529B4260600322869 /* PBXContainerItemProxy */ = { + 1D756F292CC8A00D000563FE /* PBXContainerItemProxy */ = { isa = PBXContainerItemProxy; - containerPortal = 1DA1932129B4260500322869 /* Project object */; + containerPortal = 1D756F062CC8A00B000563FE /* Project object */; proxyType = 1; - remoteGlobalIDString = 1DA1932829B4260500322869; + remoteGlobalIDString = 1D756F0D2CC8A00B000563FE; remoteInfo = Server; }; /* End PBXContainerItemProxy section */ /* Begin PBXFileReference section */ - 1D01C92A29B88A0700551803 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; - 1D01C92C29B898E700551803 /* Server.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = Server.entitlements; sourceTree = ""; }; - 1D2C638F2A3403B80012BB5A /* Cache.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Cache.swift; sourceTree = ""; }; - 1D456A9729E1220A00A98076 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; - 1DA1932929B4260500322869 /* Server.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Server.app; sourceTree = BUILT_PRODUCTS_DIR; }; - 1DA1932C29B4260500322869 /* ServerApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ServerApp.swift; sourceTree = ""; }; - 1DA1932E29B4260500322869 /* ContentView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ContentView.swift; sourceTree = ""; }; - 1DA1934429B4260600322869 /* ServerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ServerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; - 1DA1935729B4267200322869 /* UIServer.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UIServer.swift; sourceTree = ""; }; - 1DA1935829B4267200322869 /* UITestServerTest.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = UITestServerTest.swift; sourceTree = ""; }; - 1DF4DBFD2BF60A100025FC8E /* ServerUITests - Debug.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = "ServerUITests - Debug.xctestplan"; sourceTree = ""; }; + 1D756F0E2CC8A00B000563FE /* Server.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Server.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 1D756F282CC8A00D000563FE /* ServerUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = ServerUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; + 1D85B43D2CC8A76300FB7C5B /* Server.xctestplan */ = {isa = PBXFileReference; lastKnownFileType = text; path = Server.xctestplan; sourceTree = ""; }; /* End PBXFileReference section */ +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 1D756F102CC8A00B000563FE /* Server */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = Server; + sourceTree = ""; + }; + 1D756F2B2CC8A00D000563FE /* ServerUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = ServerUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + /* Begin PBXFrameworksBuildPhase section */ - 1DA1932629B4260500322869 /* Frameworks */ = { + 1D756F0B2CC8A00B000563FE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 1DA1934129B4260600322869 /* Frameworks */ = { + 1D756F252CC8A00D000563FE /* Frameworks */ = { isa = PBXFrameworksBuildPhase; buildActionMask = 2147483647; files = ( - 1DA1935D29B4270F00322869 /* FlyingFox in Frameworks */, - 1D98A06F2CE3592300CDE6BC /* UIUnitTestAPI in Frameworks */, + 1D53088E2CDF5897002DB5FC /* UIUnitTestAPI in Frameworks */, + 1D756F432CC8A041000563FE /* FlyingFox in Frameworks */, + 1D756F462CC8A06D000563FE /* UIUnitTestAPI in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXFrameworksBuildPhase section */ /* Begin PBXGroup section */ - 1D52DE1B29C6B97F00B17758 /* Packages */ = { - isa = PBXGroup; - children = ( - ); - name = Packages; - sourceTree = ""; - }; - 1DA1932029B4260500322869 = { + 1D756F052CC8A00B000563FE = { isa = PBXGroup; children = ( - 1DF4DBFD2BF60A100025FC8E /* ServerUITests - Debug.xctestplan */, - 1D52DE1B29C6B97F00B17758 /* Packages */, - 1DA1932B29B4260500322869 /* Server */, - 1DA1934729B4260600322869 /* ServerUITests */, - 1DA1932A29B4260500322869 /* Products */, - 1DAF4FD729B42C0C0008AAC7 /* Frameworks */, + 1D85B43D2CC8A76300FB7C5B /* Server.xctestplan */, + 1D756F102CC8A00B000563FE /* Server */, + 1D756F2B2CC8A00D000563FE /* ServerUITests */, + 1D756F0F2CC8A00B000563FE /* Products */, ); sourceTree = ""; }; - 1DA1932A29B4260500322869 /* Products */ = { + 1D756F0F2CC8A00B000563FE /* Products */ = { isa = PBXGroup; children = ( - 1DA1932929B4260500322869 /* Server.app */, - 1DA1934429B4260600322869 /* ServerUITests.xctest */, + 1D756F0E2CC8A00B000563FE /* Server.app */, + 1D756F282CC8A00D000563FE /* ServerUITests.xctest */, ); name = Products; sourceTree = ""; }; - 1DA1932B29B4260500322869 /* Server */ = { - isa = PBXGroup; - children = ( - 1D01C92A29B88A0700551803 /* Info.plist */, - 1DA1932C29B4260500322869 /* ServerApp.swift */, - 1DA1932E29B4260500322869 /* ContentView.swift */, - 1D01C92C29B898E700551803 /* Server.entitlements */, - ); - path = Server; - sourceTree = ""; - }; - 1DA1934729B4260600322869 /* ServerUITests */ = { - isa = PBXGroup; - children = ( - 1DA1935729B4267200322869 /* UIServer.swift */, - 1DA1935829B4267200322869 /* UITestServerTest.swift */, - 1D2C638F2A3403B80012BB5A /* Cache.swift */, - ); - path = ServerUITests; - sourceTree = ""; - }; - 1DAF4FD729B42C0C0008AAC7 /* Frameworks */ = { - isa = PBXGroup; - children = ( - 1D456A9729E1220A00A98076 /* XCTest.framework */, - ); - name = Frameworks; - sourceTree = ""; - }; /* End PBXGroup section */ /* Begin PBXNativeTarget section */ - 1DA1932829B4260500322869 /* Server */ = { + 1D756F0D2CC8A00B000563FE /* Server */ = { isa = PBXNativeTarget; - buildConfigurationList = 1DA1934E29B4260600322869 /* Build configuration list for PBXNativeTarget "Server" */; + buildConfigurationList = 1D756F322CC8A00D000563FE /* Build configuration list for PBXNativeTarget "Server" */; buildPhases = ( - 1DA1932529B4260500322869 /* Sources */, - 1DA1932629B4260500322869 /* Frameworks */, - 1DA1932729B4260500322869 /* Resources */, + 1D756F0A2CC8A00B000563FE /* Sources */, + 1D756F0B2CC8A00B000563FE /* Frameworks */, + 1D756F0C2CC8A00B000563FE /* Resources */, ); buildRules = ( ); dependencies = ( ); + fileSystemSynchronizedGroups = ( + 1D756F102CC8A00B000563FE /* Server */, + ); name = Server; packageProductDependencies = ( ); productName = Server; - productReference = 1DA1932929B4260500322869 /* Server.app */; + productReference = 1D756F0E2CC8A00B000563FE /* Server.app */; productType = "com.apple.product-type.application"; }; - 1DA1934329B4260600322869 /* ServerUITests */ = { + 1D756F272CC8A00D000563FE /* ServerUITests */ = { isa = PBXNativeTarget; - buildConfigurationList = 1DA1935429B4260600322869 /* Build configuration list for PBXNativeTarget "ServerUITests" */; + buildConfigurationList = 1D756F382CC8A00D000563FE /* Build configuration list for PBXNativeTarget "ServerUITests" */; buildPhases = ( - 1DA1934029B4260600322869 /* Sources */, - 1DA1934129B4260600322869 /* Frameworks */, - 1DA1934229B4260600322869 /* Resources */, + 1D756F242CC8A00D000563FE /* Sources */, + 1D756F252CC8A00D000563FE /* Frameworks */, + 1D756F262CC8A00D000563FE /* Resources */, ); buildRules = ( ); dependencies = ( - 1DA1934629B4260600322869 /* PBXTargetDependency */, + 1D756F2A2CC8A00D000563FE /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 1D756F2B2CC8A00D000563FE /* ServerUITests */, ); name = ServerUITests; packageProductDependencies = ( - 1DA1935C29B4270F00322869 /* FlyingFox */, - 1D98A06E2CE3592300CDE6BC /* UIUnitTestAPI */, + 1D756F422CC8A041000563FE /* FlyingFox */, + 1D756F452CC8A06D000563FE /* UIUnitTestAPI */, + 1D53088D2CDF5897002DB5FC /* UIUnitTestAPI */, ); productName = ServerUITests; - productReference = 1DA1934429B4260600322869 /* ServerUITests.xctest */; + productReference = 1D756F282CC8A00D000563FE /* ServerUITests.xctest */; productType = "com.apple.product-type.bundle.ui-testing"; }; /* End PBXNativeTarget section */ /* Begin PBXProject section */ - 1DA1932129B4260500322869 /* Project object */ = { + 1D756F062CC8A00B000563FE /* Project object */ = { isa = PBXProject; attributes = { - BuildIndependentTargetsInParallel = YES; - LastSwiftUpdateCheck = 1420; - LastUpgradeCheck = 1530; + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 1600; + LastUpgradeCheck = 2600; TargetAttributes = { - 1DA1932829B4260500322869 = { - CreatedOnToolsVersion = 14.2; + 1D756F0D2CC8A00B000563FE = { + CreatedOnToolsVersion = 16.0; }; - 1DA1934329B4260600322869 = { - CreatedOnToolsVersion = 14.2; - LastSwiftMigration = 1510; + 1D756F272CC8A00D000563FE = { + CreatedOnToolsVersion = 16.0; + TestTargetID = 1D756F0D2CC8A00B000563FE; }; }; }; - buildConfigurationList = 1DA1932429B4260500322869 /* Build configuration list for PBXProject "Server" */; - compatibilityVersion = "Xcode 14.0"; + buildConfigurationList = 1D756F092CC8A00B000563FE /* Build configuration list for PBXProject "Server" */; developmentRegion = en; hasScannedForEncodings = 0; knownRegions = ( en, Base, ); - mainGroup = 1DA1932029B4260500322869; + mainGroup = 1D756F052CC8A00B000563FE; + minimizedProjectReferenceProxies = 1; packageReferences = ( - 1DA1935B29B4270F00322869 /* XCRemoteSwiftPackageReference "FlyingFox" */, - 1D98A06D2CE3592300CDE6BC /* XCLocalSwiftPackageReference "UIUnitTestAPI" */, + 1D756F412CC8A041000563FE /* XCRemoteSwiftPackageReference "FlyingFox" */, + 1D53088C2CDF5897002DB5FC /* XCLocalSwiftPackageReference "UIUnitTestAPI" */, ); - productRefGroup = 1DA1932A29B4260500322869 /* Products */; + preferredProjectObjectVersion = 77; + productRefGroup = 1D756F0F2CC8A00B000563FE /* Products */; projectDirPath = ""; projectRoot = ""; targets = ( - 1DA1932829B4260500322869 /* Server */, - 1DA1934329B4260600322869 /* ServerUITests */, + 1D756F0D2CC8A00B000563FE /* Server */, + 1D756F272CC8A00D000563FE /* ServerUITests */, ); }; /* End PBXProject section */ /* Begin PBXResourcesBuildPhase section */ - 1DA1932729B4260500322869 /* Resources */ = { + 1D756F0C2CC8A00B000563FE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( ); runOnlyForDeploymentPostprocessing = 0; }; - 1DA1934229B4260600322869 /* Resources */ = { + 1D756F262CC8A00D000563FE /* Resources */ = { isa = PBXResourcesBuildPhase; buildActionMask = 2147483647; files = ( @@ -221,43 +193,40 @@ /* End PBXResourcesBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ - 1DA1932529B4260500322869 /* Sources */ = { + 1D756F0A2CC8A00B000563FE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1DA1932F29B4260500322869 /* ContentView.swift in Sources */, - 1DA1932D29B4260500322869 /* ServerApp.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; - 1DA1934029B4260600322869 /* Sources */ = { + 1D756F242CC8A00D000563FE /* Sources */ = { isa = PBXSourcesBuildPhase; buildActionMask = 2147483647; files = ( - 1DA1935A29B4267200322869 /* UITestServerTest.swift in Sources */, - 1D2C63902A3403B80012BB5A /* Cache.swift in Sources */, - 1DA1935929B4267200322869 /* UIServer.swift in Sources */, ); runOnlyForDeploymentPostprocessing = 0; }; /* End PBXSourcesBuildPhase section */ /* Begin PBXTargetDependency section */ - 1DA1934629B4260600322869 /* PBXTargetDependency */ = { + 1D756F2A2CC8A00D000563FE /* PBXTargetDependency */ = { isa = PBXTargetDependency; - target = 1DA1932829B4260500322869 /* Server */; - targetProxy = 1DA1934529B4260600322869 /* PBXContainerItemProxy */; + target = 1D756F0D2CC8A00B000563FE /* Server */; + targetProxy = 1D756F292CC8A00D000563FE /* PBXContainerItemProxy */; }; /* End PBXTargetDependency section */ /* Begin XCBuildConfiguration section */ - 1DA1934C29B4260600322869 /* Debug */ = { + 1D756F302CC8A00D000563FE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; @@ -289,7 +258,7 @@ ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_C_LANGUAGE_STANDARD = gnu17; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; GCC_OPTIMIZATION_LEVEL = 0; @@ -303,22 +272,28 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; MTL_FAST_MATH = YES; ONLY_ACTIVE_ARCH = YES; - SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + SWIFT_STRICT_CONCURRENCY = complete; }; name = Debug; }; - 1DA1934D29B4260600322869 /* Release */ = { + 1D756F312CC8A00D000563FE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; CLANG_ANALYZER_NONNULL = YES; CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; CLANG_ENABLE_OBJC_WEAK = YES; CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; @@ -350,7 +325,7 @@ ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_USER_SCRIPT_SANDBOXING = YES; - GCC_C_LANGUAGE_STANDARD = gnu11; + GCC_C_LANGUAGE_STANDARD = gnu17; GCC_NO_COMMON_BLOCKS = YES; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; @@ -358,220 +333,187 @@ GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; GCC_WARN_UNUSED_FUNCTION = YES; GCC_WARN_UNUSED_VARIABLE = YES; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; + IPHONEOS_DEPLOYMENT_TARGET = 18.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; MTL_ENABLE_DEBUG_INFO = NO; MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + STRING_CATALOG_GENERATE_SYMBOLS = YES; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_OPTIMIZATION_LEVEL = "-O"; + SWIFT_STRICT_CONCURRENCY = complete; + VALIDATE_PRODUCT = YES; }; name = Release; }; - 1DA1934F29B4260600322869 /* Debug */ = { + 1D756F332CC8A00D000563FE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Server/Server.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = YES; + CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Server/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = UIUnitTestServer; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = bruno.mazzo.Server; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Debug; }; - 1DA1935029B4260600322869 /* Release */ = { + 1D756F342CC8A00D000563FE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; - CODE_SIGN_ENTITLEMENTS = Server/Server.entitlements; - CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_ASSET_PATHS = ""; - DEVELOPMENT_TEAM = ""; - ENABLE_HARDENED_RUNTIME = YES; + CURRENT_PROJECT_VERSION = 1; ENABLE_PREVIEWS = YES; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_FILE = Server/Info.plist; - INFOPLIST_KEY_CFBundleDisplayName = UIUnitTestServer; INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSceneManifest_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphoneos*]" = YES; - "INFOPLIST_KEY_UILaunchScreen_Generation[sdk=iphonesimulator*]" = YES; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphoneos*]" = UIStatusBarStyleDefault; - "INFOPLIST_KEY_UIStatusBarStyle[sdk=iphonesimulator*]" = UIStatusBarStyleDefault; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; - IPHONEOS_DEPLOYMENT_TARGET = 15.0; - LD_RUNPATH_SEARCH_PATHS = "@executable_path/Frameworks"; - "LD_RUNPATH_SEARCH_PATHS[sdk=macosx*]" = "@executable_path/../Frameworks"; - MACOSX_DEPLOYMENT_TARGET = 13.1; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = bruno.mazzo.Server; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; }; name = Release; }; - 1DA1935529B4260600322869 /* Debug */ = { + 1D756F392CC8A00D000563FE /* Debug */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = ""; + CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MACOSX_DEPLOYMENT_TARGET = 13.1; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = bruno.mazzo.ServerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; - SWIFT_OPTIMIZATION_LEVEL = "-Onone"; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Server; }; name = Debug; }; - 1DA1935629B4260600322869 /* Release */ = { + 1D756F3A2CC8A00D000563FE /* Release */ = { isa = XCBuildConfiguration; buildSettings = { - ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; - CODE_SIGN_IDENTITY = "Apple Development"; CODE_SIGN_STYLE = Automatic; - DEAD_CODE_STRIPPING = YES; - DEVELOPMENT_TEAM = ""; + CURRENT_PROJECT_VERSION = 1; GENERATE_INFOPLIST_FILE = YES; - INFOPLIST_KEY_LSApplicationCategoryType = ""; IPHONEOS_DEPLOYMENT_TARGET = 18.0; - MACOSX_DEPLOYMENT_TARGET = 13.1; MARKETING_VERSION = 1.0; PRODUCT_BUNDLE_IDENTIFIER = bruno.mazzo.ServerUITests; PRODUCT_NAME = "$(TARGET_NAME)"; - PROVISIONING_PROFILE = ""; - PROVISIONING_PROFILE_SPECIFIER = ""; - SDKROOT = auto; SUPPORTED_PLATFORMS = "iphoneos iphonesimulator"; SUPPORTS_MACCATALYST = NO; SUPPORTS_MAC_DESIGNED_FOR_IPHONE_IPAD = NO; + SUPPORTS_XR_DESIGNED_FOR_IPHONE_IPAD = NO; SWIFT_EMIT_LOC_STRINGS = NO; SWIFT_VERSION = 5.0; TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = Server; }; name = Release; }; /* End XCBuildConfiguration section */ /* Begin XCConfigurationList section */ - 1DA1932429B4260500322869 /* Build configuration list for PBXProject "Server" */ = { + 1D756F092CC8A00B000563FE /* Build configuration list for PBXProject "Server" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1DA1934C29B4260600322869 /* Debug */, - 1DA1934D29B4260600322869 /* Release */, + 1D756F302CC8A00D000563FE /* Debug */, + 1D756F312CC8A00D000563FE /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; - 1DA1934E29B4260600322869 /* Build configuration list for PBXNativeTarget "Server" */ = { + 1D756F322CC8A00D000563FE /* Build configuration list for PBXNativeTarget "Server" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1DA1934F29B4260600322869 /* Debug */, - 1DA1935029B4260600322869 /* Release */, + 1D756F332CC8A00D000563FE /* Debug */, + 1D756F342CC8A00D000563FE /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; - 1DA1935429B4260600322869 /* Build configuration list for PBXNativeTarget "ServerUITests" */ = { + 1D756F382CC8A00D000563FE /* Build configuration list for PBXNativeTarget "ServerUITests" */ = { isa = XCConfigurationList; buildConfigurations = ( - 1DA1935529B4260600322869 /* Debug */, - 1DA1935629B4260600322869 /* Release */, + 1D756F392CC8A00D000563FE /* Debug */, + 1D756F3A2CC8A00D000563FE /* Release */, ); defaultConfigurationIsVisible = 0; - defaultConfigurationName = Debug; + defaultConfigurationName = Release; }; /* End XCConfigurationList section */ /* Begin XCLocalSwiftPackageReference section */ - 1D98A06D2CE3592300CDE6BC /* XCLocalSwiftPackageReference "UIUnitTestAPI" */ = { + 1D53088C2CDF5897002DB5FC /* XCLocalSwiftPackageReference "UIUnitTestAPI" */ = { isa = XCLocalSwiftPackageReference; relativePath = UIUnitTestAPI; }; /* End XCLocalSwiftPackageReference section */ /* Begin XCRemoteSwiftPackageReference section */ - 1DA1935B29B4270F00322869 /* XCRemoteSwiftPackageReference "FlyingFox" */ = { + 1D756F412CC8A041000563FE /* XCRemoteSwiftPackageReference "FlyingFox" */ = { isa = XCRemoteSwiftPackageReference; repositoryURL = "https://github.com/swhitty/FlyingFox.git"; requirement = { - branch = main; - kind = branch; + kind = upToNextMajorVersion; + minimumVersion = 0.19.0; }; }; /* End XCRemoteSwiftPackageReference section */ /* Begin XCSwiftPackageProductDependency section */ - 1D98A06E2CE3592300CDE6BC /* UIUnitTestAPI */ = { + 1D53088D2CDF5897002DB5FC /* UIUnitTestAPI */ = { isa = XCSwiftPackageProductDependency; productName = UIUnitTestAPI; }; - 1DA1935C29B4270F00322869 /* FlyingFox */ = { + 1D756F422CC8A041000563FE /* FlyingFox */ = { isa = XCSwiftPackageProductDependency; - package = 1DA1935B29B4270F00322869 /* XCRemoteSwiftPackageReference "FlyingFox" */; + package = 1D756F412CC8A041000563FE /* XCRemoteSwiftPackageReference "FlyingFox" */; productName = FlyingFox; }; + 1D756F452CC8A06D000563FE /* UIUnitTestAPI */ = { + isa = XCSwiftPackageProductDependency; + productName = UIUnitTestAPI; + }; /* End XCSwiftPackageProductDependency section */ }; - rootObject = 1DA1932129B4260500322869 /* Project object */; + rootObject = 1D756F062CC8A00B000563FE /* Project object */; } diff --git a/Server/Server.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/Server/Server.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/Server/Server.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/Server/Server.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved b/Server/Server.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved index 79665dc..16601d2 100644 --- a/Server/Server.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ b/Server/Server.xcodeproj/project.xcworkspace/xcshareddata/swiftpm/Package.resolved @@ -1,13 +1,13 @@ { - "originHash" : "a66955abf74a8e71ccd360767ff6132c873aae852128f8b799e3ba1007558c0d", + "originHash" : "b54bad70a3d3bd645957e64ed4a63d0152d69db2aa03619d6e27486b13f5731d", "pins" : [ { "identity" : "flyingfox", "kind" : "remoteSourceControl", "location" : "https://github.com/swhitty/FlyingFox.git", "state" : { - "branch" : "main", - "revision" : "3de9f8ada6d7ce40341da23a325a5656debbb180" + "revision" : "0482ba51ff2d6d91a7ea449c3dbdce2a78802b85", + "version" : "0.19.0" } } ], diff --git a/Server/Server.xcodeproj/xcshareddata/xcschemes/Server.xcscheme b/Server/Server.xcodeproj/xcshareddata/xcschemes/Server.xcscheme index 46e99d1..d37e2f7 100644 --- a/Server/Server.xcodeproj/xcshareddata/xcschemes/Server.xcscheme +++ b/Server/Server.xcodeproj/xcshareddata/xcschemes/Server.xcscheme @@ -1,10 +1,11 @@ + LastUpgradeVersion = "2600" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> - - - - - - - - - - + selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" + selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + allowLocationSimulation = "YES"> @@ -94,7 +66,7 @@ runnableDebuggingMode = "0"> diff --git a/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests - Debug.xcscheme b/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests - Debug.xcscheme index ea7fc2f..11f2d79 100644 --- a/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests - Debug.xcscheme +++ b/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests - Debug.xcscheme @@ -1,6 +1,6 @@ + skipped = "NO" + parallelizable = "YES"> diff --git a/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests.xcscheme b/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests.xcscheme index 106281a..ea07080 100644 --- a/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests.xcscheme +++ b/Server/Server.xcodeproj/xcshareddata/xcschemes/ServerUITests.xcscheme @@ -1,51 +1,34 @@ + LastUpgradeVersion = "2600" + version = "1.7"> + buildImplicitDependencies = "YES" + buildArchitectures = "Automatic"> - - - - - - + shouldUseLaunchSchemeArgsEnv = "YES"> + + + + + skipped = "NO" + parallelizable = "YES"> - - - - @@ -53,18 +36,12 @@ buildConfiguration = "Release" selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB" selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB" - disableMainThreadChecker = "YES" launchStyle = "0" useCustomWorkingDirectory = "NO" ignoresPersistentStateOnLaunch = "NO" debugDocumentVersioning = "YES" - debugXPCServices = "NO" debugServiceExtension = "internal" - enableGPUFrameCaptureMode = "3" - enableGPUValidationMode = "1" - allowLocationSimulation = "NO" - viewDebuggingEnabled = "No" - queueDebuggingEnabled = "No"> + allowLocationSimulation = "YES"> - - - - diff --git a/Server/Server/Media.xcassets/AppIcon.appiconset/ChatGPT Image Jun 23, 2025 at 06_55_35 PM.png b/Server/Server/Media.xcassets/AppIcon.appiconset/ChatGPT Image Jun 23, 2025 at 06_55_35 PM.png new file mode 100644 index 0000000..766a3c3 Binary files /dev/null and b/Server/Server/Media.xcassets/AppIcon.appiconset/ChatGPT Image Jun 23, 2025 at 06_55_35 PM.png differ diff --git a/Server/Server/Media.xcassets/AppIcon.appiconset/Contents.json b/Server/Server/Media.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..ce589a8 --- /dev/null +++ b/Server/Server/Media.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "ChatGPT Image Jun 23, 2025 at 06_55_35 PM.png", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Server/Server/Media.xcassets/Contents.json b/Server/Server/Media.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/Server/Server/Media.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/Server/Server/Server.entitlements b/Server/Server/Server.entitlements deleted file mode 100644 index f2ef3ae..0000000 --- a/Server/Server/Server.entitlements +++ /dev/null @@ -1,10 +0,0 @@ - - - - - com.apple.security.app-sandbox - - com.apple.security.files.user-selected.read-only - - - diff --git a/Server/Server/ServerApp.swift b/Server/Server/ServerApp.swift index 23de7e0..c092984 100644 --- a/Server/Server/ServerApp.swift +++ b/Server/Server/ServerApp.swift @@ -4,7 +4,7 @@ import SwiftUI struct ServerApp: App { var body: some Scene { WindowGroup { - ContentView() + Text("Hello, world!") } } } diff --git a/Server/ServerUITests/Cache.swift b/Server/ServerUITests/Cache.swift index f7ff687..2ade6bc 100644 --- a/Server/ServerUITests/Cache.swift +++ b/Server/ServerUITests/Cache.swift @@ -2,7 +2,7 @@ import Foundation import XCTest @MainActor -class Cache { +class ServerState { private var queryIds: [UUID: XCUIElementTypeQueryProvider] = [:] private var elementIds: [UUID: XCUIElement] = [:] private var coordinates: [UUID: XCUICoordinate] = [:] diff --git a/Server/ServerUITests/GestureVelocityAPI+XCTest.swift b/Server/ServerUITests/GestureVelocityAPI+XCTest.swift new file mode 100644 index 0000000..0824125 --- /dev/null +++ b/Server/ServerUITests/GestureVelocityAPI+XCTest.swift @@ -0,0 +1,17 @@ +import UIUnitTestAPI +import XCTest + +public extension GestureVelocityAPI { + var xcUIGestureVelocity: XCUIGestureVelocity { + switch self { + case .slow: + return .slow + case .fast: + return .fast + case .default: + return .default + case let .custom(value): + return XCUIGestureVelocity(rawValue: value) + } + } +} diff --git a/Server/ServerUITests/UIServer+AccessibilityRoutes.swift b/Server/ServerUITests/UIServer+AccessibilityRoutes.swift new file mode 100644 index 0000000..e23012e --- /dev/null +++ b/Server/ServerUITests/UIServer+AccessibilityRoutes.swift @@ -0,0 +1,90 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling accessibility-related routes +@MainActor +final class AccessibilityRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new AccessibilityRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for accessibility operations + /// - performAccessibilityAudit: Performs accessibility audit + func registerRoutes() async { + await routeRegistrar.addRoute("performAccessibilityAudit", handler: performAccessibilityAudit(request:)) + } + + /// Performs an accessibility audit on the specified application + /// + /// This method runs an accessibility audit on the given application, checking for + /// potential accessibility issues based on the specified audit type. It is only + /// available on iOS 17.0 and later. + /// + /// - Parameters: + /// - request: Contains the server ID of the application and the type of accessibility audit to perform + /// - Returns: An `AccessibilityAuditResponse` containing any identified accessibility issues + /// - Throws: + /// - Errors if the application cannot be retrieved from the cache + /// - An error for iOS versions earlier than 17.0 + private func performAccessibilityAudit( + request: AccessibilityAuditRequest + ) async throws -> AccessibilityAuditResponse { + let app = try cache.getApplication(request.serverId) + + if #available(iOS 17.0, *) { + var issues = [XCUIAccessibilityAuditIssue]() + try app.performAccessibilityAudit(for: request.accessibilityAuditType.toXCUIAccessibilityAuditType()) { issue in + issues.append(issue) + return true + } + + var issuesData: [AccessibilityAuditIssueData] = [] + for issue in issues { + issuesData.append(AccessibilityAuditIssueData(xcIssue: issue, cache: cache)) + } + + return AccessibilityAuditResponse(issues: issuesData) + } else { + // Fallback on earlier versions + throw NSError(domain: "com.apple.XCTest", code: 0, userInfo: nil) + } + } + + /// Performs an accessibility audit on an application + /// + /// This method attempts to run an accessibility audit on the specified application. + /// It is only available on iOS 17.0 and later versions. + /// + /// - Parameters: + /// - request: Contains the server ID of the application to audit + /// - Returns: A boolean indicating whether the accessibility audit was successful + /// - Throws: Errors related to application retrieval or accessibility audit + private func accessibilityTest(request: ElementPayload) async throws -> Bool { + let application = try cache.getApplication(request.serverId) + + if #available(iOS 17.0, *) { + return (try? application.performAccessibilityAudit()) != nil + } else { + return false + } + } +} + +// MARK: - UIServer + Accessibility Routes +extension UIServer { + /// Registers routes for accessibility operations + func registerAccessibilityRoutes() async { + let routes = AccessibilityRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+ApplicationRoutes.swift b/Server/ServerUITests/UIServer+ApplicationRoutes.swift new file mode 100644 index 0000000..fcc4b96 --- /dev/null +++ b/Server/ServerUITests/UIServer+ApplicationRoutes.swift @@ -0,0 +1,77 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling application lifecycle management routes +@MainActor +final class ApplicationRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new ApplicationRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for application lifecycle management + /// - createApp: Creates a new XCUIApplication instance + /// - Activate: Activates a previously created application + func registerRoutes() async { + await routeRegistrar.addRoute("createApp", handler: createApp(request:)) + await routeRegistrar.addRoute("Activate", handler: activate(_:)) + } + + /// Creates a new XCUIApplication instance for a given bundle identifier + /// + /// This method instantiates an XCUIApplication with the specified bundle identifier, + /// adds it to the server's cache for tracking, and optionally activates the application. + /// + /// - Parameters: + /// - request: A request containing the bundle identifier and optional activation flag + /// - Throws: Errors related to application creation or caching + private func createApp(request: CreateApplicationRequest) async throws { + let app = XCUIApplication(bundleIdentifier: request.appId) + cache.add(application: app, id: request.serverId) + + if request.activate { + app.activate() + } + } + + /// Activates a previously created XCUIApplication instance + /// + /// This method retrieves an application from the server's cache and activates it, + /// bringing it to the foreground and making it the active application. + /// + /// - Parameters: + /// - activateRequest: A request containing the server ID of the application to activate + /// - Throws: Errors related to application retrieval or activation + private func activate(_ activateRequest: ActivateRequest) async throws { + let app = try cache.getApplication(activateRequest.serverId) + app.activate() + } +} + +/// Protocol defining the interface for route registration +@MainActor +protocol RouteRegistering { + func addRoute(_ route: String, handler: @escaping @MainActor (Request) async throws -> Response) async + func addRoute(_ route: String, handler: @escaping @MainActor (Request) async throws -> Void) async +} + +// MARK: - UIServer + RouteRegistering +extension UIServer: RouteRegistering {} + +// MARK: - UIServer + Application Routes +extension UIServer { + /// Registers routes for application lifecycle management + func registerApplicationRoutes() async { + let routes = ApplicationRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+CoordinateRoutes.swift b/Server/ServerUITests/UIServer+CoordinateRoutes.swift new file mode 100644 index 0000000..820232c --- /dev/null +++ b/Server/ServerUITests/UIServer+CoordinateRoutes.swift @@ -0,0 +1,99 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling coordinate-based routes +@MainActor +final class CoordinateRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new CoordinateRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for coordinate-based operations + /// - coordinate: Gets coordinate from element + /// - coordinateWithOffset: Gets coordinate with offset + /// - coordinateTap: Performs tap at coordinate + func registerRoutes() async { + await routeRegistrar.addRoute("coordinate", handler: coordinate(request:)) + await routeRegistrar.addRoute("coordinateWithOffset", handler: coordinateWithOffset(request:)) + await routeRegistrar.addRoute("coordinateTap", handler: coordinateTap(request:)) + } + + private func coordinate(request: CoordinateRequest) async throws -> CoordinateResponse { + // withNormalizedOffset: CGVector + let rootElement = try cache.getElement(request.serverId) + let coordinate = rootElement.coordinate(withNormalizedOffset: request.normalizedOffset) + + let coordinateUUID = cache.add(coordinate: coordinate) + let elementUUID = cache.add(element: coordinate.referencedElement) + + return CoordinateResponse( + coordinateId: coordinateUUID, + referencedElementId: elementUUID, + screenPoint: coordinate.screenPoint + ) + } + + private func coordinateWithOffset(request: CoordinateOffsetRequest) async throws -> CoordinateResponse { + let rootCoordinate = try cache.getCoordinate(request.coordinatorId) + let coordinate = rootCoordinate.withOffset(request.vector) + + let coordinateUUID = cache.add(coordinate: coordinate) + let elementUUID = cache.add(element: coordinate.referencedElement) + + return CoordinateResponse( + coordinateId: coordinateUUID, + referencedElementId: elementUUID, + screenPoint: coordinate.screenPoint + ) + } + + private func coordinateTap(request: TapCoordinateRequest) async throws -> Bool { + let rootCoordinate = try cache.getCoordinate(request.serverId) + + switch request.type { + case .tap: + rootCoordinate.tap() + case .doubleTap: + rootCoordinate.doubleTap() + case let .press(forDuration: duration): + rootCoordinate.press(forDuration: duration) + case let .pressAndDrag(forDuration: duration, thenDragTo: coordinate): + let coordinate = try cache.getCoordinate(coordinate) + rootCoordinate.press(forDuration: duration, thenDragTo: coordinate) + case let .pressDragAndHold( + forDuration: duration, + thenDragTo: coordinate, + withVelocity: velocity, + thenHoldForDuration: holdDuration + ): + let coordinate = try cache.getCoordinate(coordinate) + rootCoordinate.press( + forDuration: duration, + thenDragTo: coordinate, + withVelocity: velocity.xcUIGestureVelocity, + thenHoldForDuration: holdDuration + ) + } + + return true + } +} + +// MARK: - UIServer + Coordinate Routes +extension UIServer { + /// Registers routes for coordinate-based operations + func registerCoordinateRoutes() async { + let routes = CoordinateRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+ElementCollectionRoutes.swift b/Server/ServerUITests/UIServer+ElementCollectionRoutes.swift new file mode 100644 index 0000000..1db46aa --- /dev/null +++ b/Server/ServerUITests/UIServer+ElementCollectionRoutes.swift @@ -0,0 +1,244 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling element collection routes +@MainActor +final class ElementCollectionRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + private let server: UIServer + + /// Initializes a new ElementCollectionRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + /// - server: The UIServer instance for accessing utility methods + init(cache: ServerState, routeRegistrar: RouteRegistering, server: UIServer) { + self.cache = cache + self.routeRegistrar = routeRegistrar + self.server = server + } + + /// Registers routes for element collection operations + /// - count: Gets count of elements in query + /// - queryDescendants: Gets descendants query from query + /// - elementDescendants: Gets descendants query from element + /// - matchingElementType: Creates query matching element type + /// - allElementsBoundByAccessibilityElement: Gets all elements bound by accessibility + /// - allElementsBoundByIndex: Gets all elements bound by index + /// - children: Gets children matching type + /// - query: Performs custom query + /// - element: Gets specific element by identifier + /// - remove: Removes element from cache + /// - debugDescription: Gets debug description + func registerRoutes() async { + await routeRegistrar.addRoute("count", handler: count(request:)) + await routeRegistrar.addRoute("queryDescendants", handler: queryDescendants(request:)) + await routeRegistrar.addRoute("elementDescendants", handler: elementDescendants(request:)) + await routeRegistrar.addRoute("matchingElementType", handler: matchingElementType(request:)) + await routeRegistrar.addRoute("allElementsBoundByAccessibilityElement", handler: allElementsBoundByAccessibilityElement(request:)) + await routeRegistrar.addRoute("allElementsBoundByIndex", handler: allElementsBoundByIndex(request:)) + await routeRegistrar.addRoute("children", handler: children(request:)) + await routeRegistrar.addRoute("query", handler: query(request:)) + await routeRegistrar.addRoute("element", handler: element(request:)) + await routeRegistrar.addRoute("remove", handler: remove(request:)) + await routeRegistrar.addRoute("debugDescription", handler: debugDescription(request:)) + } + + /// Counts the number of elements in a query + /// + /// This method returns the total number of elements that match a specific query. + /// Useful for verifying the number of elements found by a particular query. + /// + /// - Parameters: + /// - request: Contains the server ID of the query to count + /// - Returns: A `CountResponse` with the number of elements in the query + /// - Throws: Errors related to query retrieval + private func count(request: CountRequest) async throws -> CountResponse { + let count: Int = try (cache.getElementQuery(request.serverId)).count + return CountResponse(count: count) + } + + /// Retrieves descendants of a query matching a specific element type + /// + /// This method finds all descendant elements of a query that match a given element type. + /// The resulting query is cached for further manipulation or querying. + /// + /// - Parameters: + /// - request: Contains the server ID of the source query and the element type to match + /// - Returns: A `QueryResponse` with the server ID of the new descendants query + /// - Throws: Errors related to query retrieval or element type matching + private func queryDescendants(request: DescendantsFromQuery) async throws -> QueryResponse { + let rootQuery = try cache.getElementQuery(request.serverId) + let descendantsQuery = rootQuery.descendants(matching: request.elementType.toXCUIElementType()) + + let id = cache.add(query: descendantsQuery) + + return QueryResponse(serverId: id) + } + + /// Retrieves descendants of an element matching a specific element type + /// + /// This method finds all descendant elements of a specific element that match a given element type. + /// The resulting query is cached for further manipulation or querying. + /// + /// - Parameters: + /// - request: Contains the server ID of the source element and the element type to match + /// - Returns: A `QueryResponse` with the server ID of the new descendants query + /// - Throws: Errors related to element retrieval or element type matching + private func elementDescendants(request: ElementTypeRequest) async throws -> QueryResponse { + let rootElement = try cache.getElement(request.serverId) + let descendantsQuery = rootElement.descendants(matching: request.elementType.toXCUIElementType()) + + let id = cache.add(query: descendantsQuery) + + return QueryResponse(serverId: id) + } + + /// Creates a new query containing elements of a specific type + /// + /// This method filters an existing element query to create a new query containing + /// only the elements that match a specified type and optional identifier. + /// + /// - Parameters: + /// - request: Contains the source query's server ID, element type, and optional identifier + /// - Returns: A `QueryResponse` with the server ID of the new filtered query + /// - Throws: Errors related to query retrieval or element type matching + private func matchingElementType(request: ElementTypeRequest) async throws -> QueryResponse { + let rootQuery = try cache.getElementQuery(request.serverId) + let descendantsQuery = rootQuery.matching(request.elementType.toXCUIElementType(), identifier: request.identifier) + let id = cache.add(query: descendantsQuery) + return QueryResponse(serverId: id) + } + + /// Retrieves all elements bound by their accessibility element + /// + /// This method returns all elements from a query that are grouped by their accessibility element. + /// The elements are added to the server's cache and their server IDs are returned. + /// + /// - Parameters: + /// - request: Contains the server ID of the source query + /// - Returns: An `ElementArrayResponse` with server IDs of the retrieved elements + /// - Throws: Errors related to query retrieval + private func allElementsBoundByAccessibilityElement(request: ElementsByAccessibility) async throws -> ElementArrayResponse { + let rootQuery = try cache.getElementQuery(request.serverId) + let allElements = rootQuery.allElementsBoundByAccessibilityElement + + let ids = cache.add(elements: allElements) + + return ElementArrayResponse(serversId: ids) + } + + /// Retrieves all elements bound by their index + /// + /// This method returns all elements from a query that are grouped by their index. + /// The elements are added to the server's cache and their server IDs are returned. + /// + /// - Parameters: + /// - request: Contains the server ID of the source query + /// - Returns: An `ElementArrayResponse` with server IDs of the retrieved elements + /// - Throws: Errors related to query retrieval + private func allElementsBoundByIndex(request: ElementsByAccessibility) async throws -> ElementArrayResponse { + let rootQuery = try cache.getElementQuery(request.serverId) + let allElements = rootQuery.allElementsBoundByIndex + + let ids = cache.add(elements: allElements) + + return ElementArrayResponse(serversId: ids) + } + + /// Retrieves child elements matching a specific type + /// + /// This method finds child elements of a query or element that match a given element type. + /// It supports retrieving children from both element queries and individual elements. + /// + /// - Parameters: + /// - request: Contains the server ID of the source query/element and the element type to match + /// - Returns: A `QueryResponse` with the server ID of the new children query + /// - Throws: Errors if the source element or query cannot be found + private func children(request: ChildrenMatchinType) async throws -> QueryResponse { + var childrenQuery: XCUIElementQuery + + if let rootQuery = try? cache.getElementQuery(request.serverId) { + childrenQuery = rootQuery.children(matching: request.elementType.toXCUIElementType()) + } else if let rootElement = try? cache.getElement(request.serverId) { + childrenQuery = rootElement.children(matching: request.elementType.toXCUIElementType()) + } else { + throw ElementNotFoundError(serverId: request.serverId.uuidString) + } + + let id = cache.add(query: childrenQuery) + + return QueryResponse(serverId: id) + } + + /// Retrieves a specific element by its identifier from a query + /// + /// This method finds an element within a query using the provided identifier. + /// The found element is added to the server's cache and its server ID is returned. + /// + /// - Parameters: + /// - request: Contains the query's server ID and the identifier of the element to find + /// - Returns: An `ElementPayload` with the server ID of the found element + /// - Throws: Errors related to query retrieval or element matching + private func element(request: ByIdRequest) async -> ElementPayload { + let rootElementQuery = try! self.cache.getElementQuery(request.queryRoot) + let newElement = rootElementQuery[request.identifier] + let id = self.cache.add(element: newElement) + return ElementPayload(serverId: id) + } + + /// Performs a custom query on an existing query + /// + /// This method allows executing a custom query on a previously cached query, + /// providing flexibility in element selection and filtering. + /// + /// - Parameters: + /// - request: Contains the server ID of the source query and the query type to apply + /// - Returns: A `QueryResponse` with the server ID of the new query + /// - Throws: Errors related to query retrieval or query execution + private func query(request: QueryRequest) async throws -> QueryResponse { + let newQuery = try await server.performQuery(queryRequest: request) + let serverId = cache.add(query: newQuery) + return QueryResponse(serverId: serverId) + } + + /// Removes an element from the server's cache + /// + /// This method deletes a specific element from the server's internal cache, + /// freeing up resources and removing the reference to the element. + /// + /// - Parameters: + /// - request: Contains the server ID of the element to remove + /// - Returns: A boolean indicating whether the removal was successful + private func remove(request: ElementPayload) async -> Bool { + cache.remove(request.serverId) + + return true + } + + private func debugDescription(request: ElementPayload) async throws -> String { + var debugDescription: String! + + if let rootQuery = try? cache.getElementQuery(request.serverId) { + debugDescription = rootQuery.debugDescription + } else if let rootElement = try? cache.getElement(request.serverId) { + debugDescription = rootElement.debugDescription + } else { + throw ElementNotFoundError(serverId: request.serverId.uuidString) + } + + return debugDescription + } +} + +// MARK: - UIServer + Element Collection Routes +extension UIServer { + /// Registers routes for element collection operations + func registerElementCollectionRoutes() async { + let routes = ElementCollectionRoutes(cache: cache, routeRegistrar: self, server: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+ElementInfoRoutes.swift b/Server/ServerUITests/UIServer+ElementInfoRoutes.swift new file mode 100644 index 0000000..38774bc --- /dev/null +++ b/Server/ServerUITests/UIServer+ElementInfoRoutes.swift @@ -0,0 +1,173 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling element information routes +@MainActor +final class ElementInfoRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new ElementInfoRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for element information retrieval + /// - identifier: Gets element identifier + /// - title: Gets element title + /// - label: Gets element label + /// - placeholderValue: Gets element placeholder value + /// - isSelected: Checks if element is selected + /// - hasFocus: Checks if element has focus + /// - isEnabled: Checks if element is enabled + /// - elementType: Gets element type + /// - frame: Gets element frame + /// - horizontalSizeClass: Gets horizontal size class + /// - verticalSizeClass: Gets vertical size class + func registerRoutes() async { + await routeRegistrar.addRoute("identifier", handler: identifier(request:)) + await routeRegistrar.addRoute("title", handler: title(request:)) + await routeRegistrar.addRoute("label", handler: label(request:)) + await routeRegistrar.addRoute("placeholderValue", handler: placeholderValue(request:)) + await routeRegistrar.addRoute("isSelected", handler: isSelected(request:)) + await routeRegistrar.addRoute("hasFocus", handler: hasFocus(request:)) + await routeRegistrar.addRoute("isEnabled", handler: isEnabled(request:)) + await routeRegistrar.addRoute("elementType", handler: elementType(request:)) + await routeRegistrar.addRoute("frame", handler: frame(request:)) + await routeRegistrar.addRoute("horizontalSizeClass", handler: horizontalSizeClass(request:)) + await routeRegistrar.addRoute("verticalSizeClass", handler: verticalSizeClass(request:)) + } + + private func identifier(request: ElementPayload) async throws -> String { + let rootElement = try cache.getElement(request.serverId) + return rootElement.identifier + } + + private func title(request: ElementPayload) async throws -> String { + let rootElement = try cache.getElement(request.serverId) + return rootElement.title + } + + private func label(request: ElementPayload) async throws -> String { + let rootElement = try cache.getElement(request.serverId) + return rootElement.label + } + + private func placeholderValue(request: ElementPayload) async throws -> String? { + let rootElement = try cache.getElement(request.serverId) + return rootElement.placeholderValue + } + + /// Checks if an element is selected + /// + /// This method determines whether a specific UI element is currently in a selected state. + /// This is commonly used for elements like checkboxes, radio buttons, or list items. + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A boolean indicating whether the element is selected + /// - Throws: Errors related to element retrieval + private func isSelected(request: ElementPayload) async throws -> Bool { + let element = try cache.getElement(request.serverId) + return element.isSelected + } + + /// Checks if an element has focus + /// + /// This method determines whether a specific UI element currently has input focus. + /// Focus indicates which element is currently selected to receive input. + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A boolean indicating whether the element has focus + /// - Throws: Errors related to element retrieval + private func hasFocus(request: ElementPayload) async throws -> Bool { + let element = try cache.getElement(request.serverId) + return element.hasFocus + } + + /// Checks if an element is enabled + /// + /// This method determines whether a specific UI element is currently enabled for interaction. + /// Disabled elements typically cannot receive user input or trigger actions. + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A boolean indicating whether the element is enabled + /// - Throws: Errors related to element retrieval + private func isEnabled(request: ElementPayload) async throws -> Bool { + let element = try cache.getElement(request.serverId) + return element.isEnabled + } + + /// Retrieves the element type of a UI element + /// + /// This method returns the type of a specific UI element, which indicates its role + /// in the interface (e.g., button, text field, etc.). + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: An `ElementTypeResponse` containing the element's type + /// - Throws: Errors related to element retrieval + private func elementType(request: ElementPayload) async throws -> UInt { + let element = try cache.getElement(request.serverId) + return element.elementType.rawValue + } + + /// Retrieves the frame of an element + /// + /// This method returns the frame (position and size) of a specific UI element + /// in the coordinate system of its container. + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A `FrameResponse` containing the element's frame coordinates + /// - Throws: Errors related to element retrieval + private func frame(request: ElementPayload) async throws -> CGRect { + let element = try cache.getElement(request.serverId) + return element.frame + } + + /// Retrieves the horizontal size class of an element + /// + /// This method returns the horizontal size class of a specific UI element, + /// which indicates how the element should be laid out horizontally. + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A `UserInterfaceSizeClassResponse` containing the horizontal size class + /// - Throws: Errors related to element retrieval + private func horizontalSizeClass(request: ElementPayload) async throws -> SizeClass { + let element = try cache.getElement(request.serverId) + return SizeClass(rawValue: element.horizontalSizeClass.rawValue)! + } + + /// Retrieves the vertical size class of an element + /// + /// This method returns the vertical size class of a specific UI element, + /// which indicates how the element should be laid out vertically. + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A `UserInterfaceSizeClassResponse` containing the vertical size class + /// - Throws: Errors related to element retrieval + private func verticalSizeClass(request: ElementPayload) async throws -> SizeClass { + let element = try cache.getElement(request.serverId) + return SizeClass(rawValue: element.verticalSizeClass.rawValue)! + } +} + +// MARK: - UIServer + Element Info Routes +extension UIServer { + /// Registers routes for element information retrieval + func registerElementInfoRoutes() async { + let routes = ElementInfoRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+ElementQueryRoutes.swift b/Server/ServerUITests/UIServer+ElementQueryRoutes.swift new file mode 100644 index 0000000..a6b3cb8 --- /dev/null +++ b/Server/ServerUITests/UIServer+ElementQueryRoutes.swift @@ -0,0 +1,181 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling element query routes +@MainActor +final class ElementQueryRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new ElementQueryRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for element querying and selection + /// - firstMatch: Gets the first matching element from a query + /// - elementFromQuery: Retrieves a specific element from a query + /// - elementMatchingPredicate: Gets element matching a predicate + /// - matchingPredicate: Creates query with elements matching predicate + /// - matchingByIdentifier: Creates query with elements matching identifier + /// - containingPredicate: Creates query containing elements matching predicate + /// - containingElementType: Creates query containing elements of specific type + func registerRoutes() async { + await routeRegistrar.addRoute("firstMatch", handler: firstMatch(firstMatchRequest:)) + await routeRegistrar.addRoute("elementFromQuery", handler: elementFromQuery(elementFromQuery:)) + await routeRegistrar.addRoute("elementMatchingPredicate", handler: elementMatchingPredicate(predicateRequest:)) + await routeRegistrar.addRoute("matchingPredicate", handler: matchingPredicate(predicateRequest:)) + await routeRegistrar.addRoute("matchingByIdentifier", handler: matchingByIdentifier(request:)) + await routeRegistrar.addRoute("containingPredicate", handler: containingPredicate(request:)) + await routeRegistrar.addRoute("containingElementType", handler: containingElementType(request:)) + } + + /// Retrieves the first matching element from a given query + /// + /// This method finds the first element that matches the specified query + /// and adds it to the server's cache for further manipulation. + /// + /// - Parameters: + /// - firstMatchRequest: A request containing the server ID of the query to match + /// - Returns: A response with the server ID of the first matching element + /// - Throws: Errors related to query retrieval or element matching + private func firstMatch(firstMatchRequest: FirstMatchRequest) async throws -> FirstMatchResponse { + let query = try cache.getQuery(firstMatchRequest.serverId) + let element = query.firstMatch + + let id = cache.add(element: element) + + return FirstMatchResponse(serverId: id) + } + + /// Retrieves a specific element from a query based on various criteria + /// + /// This method allows retrieving an element from a query using different selection methods: + /// - By index: Select an element at a specific position in the query + /// - By element type and optional identifier: Select an element matching a specific type + /// - Default: Select the first element in the query + /// + /// - Parameters: + /// - elementFromQuery: A request containing the query server ID and optional selection criteria + /// - Returns: A payload containing the server ID of the selected element + /// - Throws: Errors related to query retrieval or element selection + private func elementFromQuery(elementFromQuery: ElementFromQuery) async throws -> ElementPayload { + let query = try cache.getElementQuery(elementFromQuery.serverId) + + let element: XCUIElement + if let index = elementFromQuery.index { + element = query.element(boundBy: index) + } else if let type = elementFromQuery.elementType { + element = query.element(matching: type.toXCUIElementType(), identifier: elementFromQuery.identifier) + } else { + element = query.element + } + + let id = cache.add(element: element) + + return ElementPayload(serverId: id) + } + + /// Retrieves an element from a query that matches a specific predicate + /// + /// This method finds the first element in a query that satisfies the given predicate + /// and adds it to the server's cache for further manipulation. + /// + /// - Parameters: + /// - predicateRequest: A request containing the query server ID and the predicate to match + /// - Returns: A payload containing the server ID of the matching element + /// - Throws: Errors related to query retrieval or element matching + private func elementMatchingPredicate(predicateRequest: PredicateRequest) async throws -> ElementPayload { + let query = try cache.getElementQuery(predicateRequest.serverId) + + let element = query.element(matching: predicateRequest.predicate) + + let id = cache.add(element: element) + + return ElementPayload(serverId: id) + } + + /// Retrieves a new query containing elements that match the specified predicate + /// + /// This method filters an existing element query to create a new query containing + /// only the elements that satisfy the given predicate. The resulting query is + /// cached for further manipulation or querying. + /// + /// - Parameters: + /// - predicateRequest: A request containing the source query's server ID and the predicate to match + /// - Returns: A response with the server ID of the new filtered query + /// - Throws: Errors related to query retrieval or predicate matching + private func matchingPredicate(predicateRequest: PredicateRequest) async throws -> QueryResponse { + let query = try cache.getElementQuery(predicateRequest.serverId) + let matching = query.matching(predicateRequest.predicate) + let id = cache.add(query: matching) + return QueryResponse(serverId: id) + } + + /// Retrieves a new query containing elements with a specific identifier + /// + /// This method creates a new query that contains all elements from the root query + /// that match the given identifier. The resulting query is cached for further use. + /// + /// - Parameters: + /// - request: A request containing the root query's server ID and the identifier to match + /// - Returns: A response with the server ID of the new filtered query + /// - Throws: Errors related to query retrieval or identifier matching + private func matchingByIdentifier(request: ByIdRequest) async throws -> QueryResponse { + let query = try cache.getElementQuery(request.queryRoot) + let matching = query.matching(identifier: request.identifier) + let id = cache.add(query: matching) + return QueryResponse(serverId: id) + } + + /// Creates a new query containing elements that include a specific predicate + /// + /// This method filters an existing element query to create a new query containing + /// only the elements that contain (include) elements matching the given predicate. + /// The resulting query is cached for further manipulation or querying. + /// + /// - Parameters: + /// - request: A request containing the source query's server ID and the predicate to match + /// - Returns: A response with the server ID of the new filtered query + /// - Throws: Errors related to query retrieval or predicate matching + private func containingPredicate(request: PredicateRequest) async throws -> QueryResponse { + let query = try cache.getElementQuery(request.serverId) + let matching = query.containing(request.predicate) + let id = cache.add(query: matching) + return QueryResponse(serverId: id) + } + + /// Creates a new query containing elements of a specific type + /// + /// This method filters an existing element query to create a new query containing + /// only the elements of a specified type and optional identifier. The resulting + /// query is cached for further manipulation or querying. + /// + /// - Parameters: + /// - request: A request containing the source query's server ID, element type, and optional identifier + /// - Returns: A response with the server ID of the new filtered query + /// - Throws: Errors related to query retrieval or element type matching + private func containingElementType(request: ElementTypeRequest) async throws -> QueryResponse { + let rootQuery = try cache.getElementQuery(request.serverId) + let query = rootQuery.containing(request.elementType.toXCUIElementType(), identifier: request.identifier) + + let id = cache.add(query: query) + + return QueryResponse(serverId: id) + } +} + +// MARK: - UIServer + Element Query Routes +extension UIServer { + /// Registers routes for element querying and selection + func registerElementQueryRoutes() async { + let routes = ElementQueryRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+ElementStateRoutes.swift b/Server/ServerUITests/UIServer+ElementStateRoutes.swift new file mode 100644 index 0000000..be9bf54 --- /dev/null +++ b/Server/ServerUITests/UIServer+ElementStateRoutes.swift @@ -0,0 +1,115 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling element state routes +@MainActor +final class ElementStateRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new ElementStateRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for element state checking + /// - exists: Checks if element exists + /// - waitForExistence: Waits for element to exist + /// - waitForNonExistence: Waits for element to not exist + /// - value: Gets element value + /// - isHittable: Checks if element is hittable + func registerRoutes() async { + await routeRegistrar.addRoute("exists", handler: exists(request:)) + await routeRegistrar.addRoute("waitForExistence", handler: waitForExistence(request:)) + await routeRegistrar.addRoute("waitForNonExistence", handler: waitForNonExistence(request:)) + await routeRegistrar.addRoute("value", handler: value(request:)) + await routeRegistrar.addRoute("isHittable", handler: isHittable(request:)) + } + + /// Checks if an element exists in the UI hierarchy + /// + /// This method determines whether a specific UI element is currently present + /// in the interface. It's useful for verifying element visibility or availability. + /// + /// - Parameters: + /// - request: Contains the server ID of the element to check + /// - Returns: A boolean indicating whether the element exists + /// - Throws: Errors related to element retrieval + private func exists(request: ElementPayload) async throws -> Bool { + let element = try cache.getElement(request.serverId) + return element.exists + } + + /// Waits for an element to exist in the UI hierarchy + /// + /// This method waits for a specified duration for an element to become present + /// in the interface. It's useful for handling dynamic UI changes. + /// + /// - Parameters: + /// - request: Contains the server ID of the element and timeout duration + /// - Returns: A boolean indicating whether the element appeared within the timeout + /// - Throws: Errors related to element retrieval + private func waitForExistence(request: WaitForExistenceRequest) async throws -> Bool { + let element = try cache.getElement(request.serverId) + return element.waitForExistence(timeout: request.timeout) + } + + /// Waits for an element to disappear from the UI hierarchy + /// + /// This method waits for a specified duration for an element to become absent + /// from the interface. It's useful for handling dynamic UI changes. + /// + /// - Parameters: + /// - request: Contains the server ID of the element and timeout duration + /// - Returns: A boolean indicating whether the element disappeared within the timeout + /// - Throws: Errors related to element retrieval + private func waitForNonExistence(request: WaitForExistenceRequest) async throws -> Bool { + let element = try cache.getElement(request.serverId) + return element.waitForNonExistence(timeout: request.timeout) + } + + /// Retrieves the value of an element + /// + /// This method returns the current value of a UI element. The interpretation + /// of "value" depends on the element type (e.g., text for text fields, + /// selection for pickers). + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A `ValueResponse` containing the element's value + /// - Throws: Errors related to element retrieval + private func value(request: ElementPayload) async throws -> ValueResponse { + let element = try cache.getElement(request.serverId) + return ValueResponse(value: element.value as? String ?? "") + } + + /// Checks if an element is hittable + /// + /// This method determines whether a specific UI element can be interacted with + /// through taps or clicks. An element is hittable if it's visible and not + /// obscured by other elements. + /// + /// - Parameters: + /// - request: Contains the server ID of the element + /// - Returns: A boolean indicating whether the element is hittable + /// - Throws: Errors related to element retrieval + private func isHittable(request: ElementPayload) async throws -> Bool { + let element = try cache.getElement(request.serverId) + return element.isHittable + } +} + +// MARK: - UIServer + Element State Routes +extension UIServer { + /// Registers routes for element state checking + func registerElementStateRoutes() async { + let routes = ElementStateRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+InteractionRoutes.swift b/Server/ServerUITests/UIServer+InteractionRoutes.swift new file mode 100644 index 0000000..30be80f --- /dev/null +++ b/Server/ServerUITests/UIServer+InteractionRoutes.swift @@ -0,0 +1,178 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling interaction routes +@MainActor +final class InteractionRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new InteractionRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for element interactions and gestures + /// - tapElement: Performs tap gesture on element + /// - doubleTap: Performs double tap gesture on element + /// - typeText: Types text into element + /// - scroll: Scrolls element by delta values + /// - swipe: Performs swipe gesture on element + /// - pinch: Performs pinch gesture on element + /// - rotate: Performs rotation gesture on element + /// - HomeButton: Presses device home button + func registerRoutes() async { + await routeRegistrar.addRoute("tapElement", handler: tapElement(tapRequest:)) + await routeRegistrar.addRoute("doubleTap", handler: doubleTap(tapRequest:)) + await routeRegistrar.addRoute("typeText", handler: typeText(request:)) + await routeRegistrar.addRoute("scroll", handler: scroll(request:)) + await routeRegistrar.addRoute("swipe", handler: swipe(request:)) + await routeRegistrar.addRoute("pinch", handler: pinch(request:)) + await routeRegistrar.addRoute("rotate", handler: rotate(request:)) + await routeRegistrar.addRoute("HomeButton", handler: { (_: HomeButtonRequest) in + XCUIDevice.shared.press(.home) + }) + } + + /// Performs a tap gesture on an element + /// + /// This method executes a single tap on a specific UI element. The tap can be + /// configured with various parameters like duration and number of taps. + /// + /// - Parameters: + /// - tapRequest: Contains the server ID of the element and tap configuration + /// - Returns: A boolean indicating whether the tap was successful + /// - Throws: Errors related to element retrieval or tap execution + private func tapElement(tapRequest: TapElementRequest) throws -> Bool { + guard let element = try? cache.getElement(tapRequest.serverId) else { + return false + } + + if let duration = tapRequest.duration { + element.press(forDuration: duration) + } else if let numberOfTouches = tapRequest.numberOfTouches { + if let numberOfTaps = tapRequest.numberOfTaps { + element.tap(withNumberOfTaps: numberOfTaps, numberOfTouches: numberOfTouches) + } else { + element.twoFingerTap() + } + } else { + element.tap() + } + + return true + } + + /// Performs a double tap gesture on an element + /// + /// This method executes a double tap on a specific UI element. It's a convenience + /// method that's equivalent to a tap with a count of 2. + /// + /// - Parameters: + /// - tapRequest: Contains the server ID of the element to double tap + /// - Returns: A boolean indicating whether the double tap was successful + /// - Throws: Errors related to element retrieval or tap execution + private func doubleTap(tapRequest: ElementPayload) throws -> Void { + let element = try cache.getElement(tapRequest.serverId) + element.doubleTap() + } + + /// Types text into an element + /// + /// This method simulates keyboard input to enter text into a text field or + /// other text input element. + /// + /// - Parameters: + /// - request: Contains the server ID of the element and the text to type + /// - Returns: A boolean indicating whether the text input was successful + /// - Throws: Errors related to element retrieval or text input + private func typeText(request: EnterTextRequest) throws -> Void { + let element = try self.cache.getElement(request.serverId) + element.typeText(request.textToEnter) + } + + /// Scrolls an element by specified delta values + /// + /// This method performs a scroll gesture on an element, moving it by the + /// specified amounts in the horizontal and vertical directions. + /// + /// - Parameters: + /// - request: Contains the server ID of the element and scroll deltas + /// - Returns: A boolean indicating whether the scroll was successful + /// - Throws: Errors related to element retrieval or scroll execution + private func scroll(request: ScrollRequest) throws -> Bool { + let element = try cache.getElement(request.serverId) + element.scroll(byDeltaX: request.deltaX, deltaY: request.deltaY) + return true + } + + /// Performs a swipe gesture on an element + /// + /// This method executes a swipe gesture in the specified direction on a UI element. + /// The swipe can be configured with a specific velocity. + /// + /// - Parameters: + /// - request: Contains the server ID of the element and swipe configuration + /// - Returns: A boolean indicating whether the swipe was successful + /// - Throws: Errors related to element retrieval or swipe execution + private func swipe(request: SwipeRequest) throws -> Bool { + let element = try cache.getElement(request.serverId) + + switch request.swipeDirection { + case .up: + element.swipeUp() + case .down: + element.swipeDown() + case .left: + element.swipeLeft() + case .right: + element.swipeRight() + } + + return true + } + + /// Performs a pinch gesture on an element + /// + /// This method executes a pinch gesture on a UI element, which can be either + /// a pinch-in (zoom out) or pinch-out (zoom in) gesture. + /// + /// - Parameters: + /// - request: Contains the server ID of the element and pinch configuration + /// - Returns: A boolean indicating whether the pinch was successful + /// - Throws: Errors related to element retrieval or pinch execution + private func pinch(request: PinchRequest) throws { + let element = try self.cache.getElement(request.serverId) + element.pinch(withScale: request.scale, velocity: request.velocity) + } + + /// Performs a rotation gesture on an element + /// + /// This method executes a rotation gesture on a UI element, rotating it by + /// the specified angle with a given velocity. + /// + /// - Parameters: + /// - request: Contains the server ID of the element and rotation configuration + /// - Returns: A boolean indicating whether the rotation was successful + /// - Throws: Errors related to element retrieval or rotation execution + private func rotate(request: RotateRequest) throws -> Bool { + let element = try cache.getElement(request.serverId) + element.rotate(request.rotation, withVelocity: request.velocity) + return true + } +} + +// MARK: - UIServer + Interaction Routes +extension UIServer { + /// Registers routes for element interactions and gestures + func registerInteractionRoutes() async { + let routes = InteractionRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+Tap.swift b/Server/ServerUITests/UIServer+Tap.swift new file mode 100644 index 0000000..811e4e2 --- /dev/null +++ b/Server/ServerUITests/UIServer+Tap.swift @@ -0,0 +1,64 @@ +import UIUnitTestAPI + +/// A class responsible for handling tap-related interaction methods for UI elements. +/// +/// This class provides functionality to perform various types of taps on UI elements, +/// including standard taps, multi-touch taps, long presses, and double taps. +@MainActor +final class TapRoutes { + private let cache: ServerState + private let routeRegistrar: RouteRegistering + + /// Initializes a new TapRoutes instance + /// - Parameters: + /// - cache: The server state cache for managing application instances + /// - routeRegistrar: The object responsible for registering routes + init(cache: ServerState, routeRegistrar: RouteRegistering) { + self.cache = cache + self.routeRegistrar = routeRegistrar + } + + /// Registers routes for tap interactions + func registerRoutes() async { + // Note: This extension appears to be incomplete and may have duplicate functionality + // with InteractionRoutes. Consider consolidating or removing if not needed. + } + + /// Performs a tap action on a specified UI element with various customization options. + /// + /// This method allows for different types of taps based on the provided `TapElementRequest`: + /// - Standard single tap + /// - Tap with specific number of taps and touches + /// - Two-finger tap + /// - Press and hold for a specified duration + /// + /// - Parameters: + /// - tapRequest: A `TapElementRequest` containing details about the tap interaction + /// - Returns: A boolean indicating whether the tap was successfully performed + /// - Throws: An error if the element cannot be retrieved from the cache + private func tapElement(tapRequest: TapElementRequest) async throws -> Bool { + // Implementation would go here - currently missing from original file + let element = try cache.getElement(tapRequest.serverId) + element.tap() + return true + } + + /// Performs a double tap on a specified UI element. + /// + /// - Parameters: + /// - tapRequest: An `ElementPayload` containing the server ID of the element to double tap + /// - Throws: An error if the element cannot be retrieved from the cache + private func doubleTap(tapRequest: ElementPayload) async throws { + let element = try cache.getElement(tapRequest.serverId) + element.doubleTap() + } +} + +// MARK: - UIServer + Tap Routes +extension UIServer { + /// Registers routes for tap interactions + func registerTapRoutes() async { + let routes = TapRoutes(cache: cache, routeRegistrar: self) + await routes.registerRoutes() + } +} diff --git a/Server/ServerUITests/UIServer+UtilityRoutes.swift b/Server/ServerUITests/UIServer+UtilityRoutes.swift new file mode 100644 index 0000000..9828e28 --- /dev/null +++ b/Server/ServerUITests/UIServer+UtilityRoutes.swift @@ -0,0 +1,230 @@ +import FlyingFox +import Foundation +import UIUnitTestAPI +import XCTest + +/// A class responsible for handling utility routes for server management +@MainActor +final class UtilityRoutes { + private let server: UIServer + + /// Initializes a new UtilityRoutes instance + /// - Parameters: + /// - server: The UIServer instance for accessing server functionality + init(server: UIServer) { + self.server = server + } + + /// Registers utility routes for server management + /// - stop: Stops the server + /// - alive: Health check endpoint + /// - server-version: Gets server version + func registerRoutes() async { + await server.server.appendRoute(HTTPRoute(stringLiteral: "stop"), to: ClosureHTTPHandler { _ in + Task { + await self.server.server.stop(timeout: 10) + } + + return await self.server.buildResponse(true) + }) + + await server.server.appendRoute(HTTPRoute(stringLiteral: "alive"), to: ClosureHTTPHandler { _ in + await self.server.buildResponse(true) + }) + + await server.server.appendRoute(HTTPRoute(stringLiteral: "server-version"), to: ClosureHTTPHandler { _ in + await self.server.buildResponse(CurrentServerVersion) + }) + } +} + +// MARK: - UIServer + Utility Routes +extension UIServer { + /// Registers utility routes for server management + func registerUtilityRoutes() async { + let routes = UtilityRoutes(server: self) + await routes.registerRoutes() + } + + /// Performs a query based on a request + /// + /// This method executes a query on the UI hierarchy based on the specified criteria. + /// It supports various query types and filtering options. + /// + /// - Parameters: + /// - queryRequest: Contains the query parameters and type + /// - Returns: The resulting XCUIElementQuery + /// - Throws: Errors if the query cannot be executed or if the parameters are invalid + @MainActor + func performQuery(queryRequest: QueryRequest) async throws -> XCUIElementQuery { + let rootElementQuery = try await self.cache.getQuery(queryRequest.serverId) + + let resultQuery: XCUIElementQuery + switch queryRequest.queryType { + case .staticTexts: + resultQuery = rootElementQuery.staticTexts + case .activityIndicators: + resultQuery = rootElementQuery.activityIndicators + case .alerts: + resultQuery = rootElementQuery.alerts + case .browsers: + resultQuery = rootElementQuery.browsers + case .buttons: + resultQuery = rootElementQuery.buttons + case .cells: + resultQuery = rootElementQuery.cells + case .checkBoxes: + resultQuery = rootElementQuery.staticTexts + case .collectionViews: + resultQuery = rootElementQuery.collectionViews + case .colorWells: + resultQuery = rootElementQuery.colorWells + case .comboBoxes: + resultQuery = rootElementQuery.comboBoxes + case .datePickers: + resultQuery = rootElementQuery.datePickers + case .decrementArrows: + resultQuery = rootElementQuery.decrementArrows + case .dialogs: + resultQuery = rootElementQuery.dialogs + case .disclosureTriangles: + resultQuery = rootElementQuery.disclosureTriangles + case .disclosedChildRows: + resultQuery = rootElementQuery.disclosedChildRows + case .dockItems: + resultQuery = rootElementQuery.dockItems + case .drawers: + resultQuery = rootElementQuery.drawers + case .grids: + resultQuery = rootElementQuery.grids + case .groups: + resultQuery = rootElementQuery.groups + case .handles: + resultQuery = rootElementQuery.handles + case .helpTags: + resultQuery = rootElementQuery.helpTags + case .icons: + resultQuery = rootElementQuery.icons + case .images: + resultQuery = rootElementQuery.images + case .incrementArrows: + resultQuery = rootElementQuery.incrementArrows + case .keyboards: + resultQuery = rootElementQuery.keyboards + case .keys: + resultQuery = rootElementQuery.keys + case .layoutAreas: + resultQuery = rootElementQuery.layoutAreas + case .layoutItems: + resultQuery = rootElementQuery.layoutItems + case .levelIndicators: + resultQuery = rootElementQuery.levelIndicators + case .links: + resultQuery = rootElementQuery.links + case .maps: + resultQuery = rootElementQuery.maps + case .mattes: + resultQuery = rootElementQuery.mattes + case .menuBarItems: + resultQuery = rootElementQuery.menuBarItems + case .menuBars: + resultQuery = rootElementQuery.menuBars + case .menuButtons: + resultQuery = rootElementQuery.menuButtons + case .menuItems: + resultQuery = rootElementQuery.menuItems + case .menus: + resultQuery = rootElementQuery.menus + case .navigationBars: + resultQuery = rootElementQuery.navigationBars + case .otherElements: + resultQuery = rootElementQuery.otherElements + case .outlineRows: + resultQuery = rootElementQuery.outlineRows + case .outlines: + resultQuery = rootElementQuery.outlines + case .pageIndicators: + resultQuery = rootElementQuery.pageIndicators + case .pickerWheels: + resultQuery = rootElementQuery.pickerWheels + case .pickers: + resultQuery = rootElementQuery.pickers + case .popUpButtons: + resultQuery = rootElementQuery.popUpButtons + case .popovers: + resultQuery = rootElementQuery.popovers + case .progressIndicators: + resultQuery = rootElementQuery.progressIndicators + case .radioButtons: + resultQuery = rootElementQuery.radioButtons + case .radioGroups: + resultQuery = rootElementQuery.radioGroups + case .ratingIndicators: + resultQuery = rootElementQuery.ratingIndicators + case .relevanceIndicators: + resultQuery = rootElementQuery.relevanceIndicators + case .rulerMarkers: + resultQuery = rootElementQuery.rulerMarkers + case .rulers: + resultQuery = rootElementQuery.rulers + case .scrollBars: + resultQuery = rootElementQuery.scrollBars + case .scrollViews: + resultQuery = rootElementQuery.scrollViews + case .searchFields: + resultQuery = rootElementQuery.searchFields + case .secureTextFields: + resultQuery = rootElementQuery.secureTextFields + case .segmentedControls: + resultQuery = rootElementQuery.segmentedControls + case .sheets: + resultQuery = rootElementQuery.sheets + case .sliders: + resultQuery = rootElementQuery.sliders + case .splitGroups: + resultQuery = rootElementQuery.splitGroups + case .splitters: + resultQuery = rootElementQuery.splitters + case .statusBars: + resultQuery = rootElementQuery.statusBars + case .statusItems: + resultQuery = rootElementQuery.statusItems + case .steppers: + resultQuery = rootElementQuery.steppers + case .switches: + resultQuery = rootElementQuery.switches + case .tabBars: + resultQuery = rootElementQuery.tabBars + case .tabGroups: + resultQuery = rootElementQuery.tabGroups + case .tableColumns: + resultQuery = rootElementQuery.tableColumns + case .tableRows: + resultQuery = rootElementQuery.tableRows + case .tables: + resultQuery = rootElementQuery.tables + case .textFields: + resultQuery = rootElementQuery.textFields + case .textViews: + resultQuery = rootElementQuery.textViews + case .timelines: + resultQuery = rootElementQuery.timelines + case .toggles: + resultQuery = rootElementQuery.toggles + case .toolbarButtons: + resultQuery = rootElementQuery.toolbarButtons + case .toolbars: + resultQuery = rootElementQuery.toolbars + case .touchBars: + resultQuery = rootElementQuery.touchBars + case .valueIndicators: + resultQuery = rootElementQuery.valueIndicators + case .webViews: + resultQuery = rootElementQuery.webViews + case .windows: + resultQuery = rootElementQuery.windows + } + + return resultQuery + } +} diff --git a/Server/ServerUITests/UIServer.swift b/Server/ServerUITests/UIServer.swift index b1adb8a..721ec84 100644 --- a/Server/ServerUITests/UIServer.swift +++ b/Server/ServerUITests/UIServer.swift @@ -1,459 +1,104 @@ import FlyingFox import FlyingSocks -import XCTest import Foundation import UIUnitTestAPI +import XCTest +/// A global JSON decoder used for decoding incoming HTTP requests let decoder = JSONDecoder() + +/// A global JSON encoder used for encoding HTTP responses let encoder = JSONEncoder() +/// The current version of the UIServer let CurrentServerVersion = 6 +/// A comprehensive HTTP server for remote UI testing of iOS applications using XCTest and XCUIApplication. +/// +/// # Overview +/// `UIServer` provides a flexible, type-safe, and extensible framework for programmatically +/// interacting with and testing user interfaces across iOS applications. It bridges the gap +/// between automated testing tools and iOS applications by offering a robust, RESTful HTTP interface. +/// +/// # Key Features +/// - **Application Lifecycle Management** +/// * Create and launch applications dynamically +/// * Activate specific app instances +/// * Manage multiple application contexts +/// +/// - **Comprehensive Element Interaction** +/// * Advanced UI element querying and selection +/// * Gesture simulation (tap, swipe, pinch, rotate) +/// * Text input and value retrieval +/// * Scrolling and navigation +/// +/// - **Accessibility and Inspection** +/// * Perform detailed accessibility audits +/// * Retrieve and validate element properties +/// * Check element states and accessibility +/// +/// # Technical Architecture +/// ## Caching System +/// - Implements an intelligent caching mechanism for XCUIApplication and XCUIElement references +/// - Provides efficient, stateful lookup of UI elements across test sessions +/// - Minimizes resource overhead and improves test performance +/// +/// ## Dynamic Routing +/// - Type-safe, dynamically registered HTTP routes +/// - JSON-based request/response communication +/// - Comprehensive error handling and logging +/// +/// ## Concurrency and Thread Safety +/// - Fully `@MainActor` decorated to ensure thread-safe UI interactions +/// - Leverages Swift's async/await for non-blocking, responsive operations +/// +/// # Use Cases +/// - Remote UI testing frameworks +/// - Automated UI test execution platforms +/// - Cross-platform testing tools +/// - Continuous integration and deployment (CI/CD) UI testing +/// +/// # Performance and Security +/// - Minimal testing operation overhead +/// - Efficient element reference management +/// - Low-latency HTTP communication +/// - Secure loopback address execution +/// - Dynamic port assignment +/// - Support for multiple server instances +/// +/// # Compatibility and Limitations +/// - Requires XCTest framework +/// - iOS platform specific +/// - Advanced features require iOS 17.0+ +/// +/// # Implementation Notes +/// The server uses a combination of dynamic routing, type-safe request handling, +/// and a sophisticated caching mechanism to provide a comprehensive UI testing solution. +@MainActor class UIServer { + /// Stores the last XCTest issue encountered during server operations + /// Useful for tracking and debugging test-related errors var lastIssue: XCTIssue? + /// The underlying HTTP server that handles routing and request processing + /// Manages the network communication for UI testing operations var server: HTTPServer! - @MainActor - let cache = Cache() - - @MainActor - func performAccessibilityAudit( - request: AccessibilityAuditRequest - ) async throws -> AccessibilityAuditResponse { - let app = try await cache.getApplication(request.serverId) - - if #available(iOS 17.0, *) { - var issues = [XCUIAccessibilityAuditIssue]() - try app.performAccessibilityAudit(for: request.accessibilityAuditType.toXCUIAccessibilityAuditType()) { issue in - issues.append(issue) - return true - } - - var issuesData: [AccessibilityAuditIssueData] = [] - for issue in issues { - await issuesData.append(AccessibilityAuditIssueData(xcIssue: issue, cache: cache)) - } - - return AccessibilityAuditResponse(issues: issuesData) - } else { - // Fallback on earlier versions - throw NSError(domain: "com.apple.XCTest", code: 0, userInfo: nil) - } - } - - @MainActor - func firstMatch(firstMatchRequest: FirstMatchRequest) async throws -> FirstMatchResponse { - let query = try await self.cache.getQuery(firstMatchRequest.serverId) - let element = query.firstMatch - - let id = await self.cache.add(element: element) - - return FirstMatchResponse(serverId: id) - } - - @MainActor - func elementFromQuery(elementFromQuery: ElementFromQuery) async throws -> ElementPayload { - let query = try await self.cache.getElementQuery(elementFromQuery.serverId) - - let element: XCUIElement - if let index = elementFromQuery.index { - element = query.element(boundBy: index) - } else if let type = elementFromQuery.elementType { - element = query.element(matching: type.toXCUIElementType(), identifier: elementFromQuery.identifier) - } else { - element = query.element - } - - let id = await self.cache.add(element: element) - - return ElementPayload(serverId: id) - } - - @MainActor - func elementMatchingPredicate(predicateRequest: PredicateRequest) async throws -> ElementPayload { - let query = try await self.cache.getElementQuery(predicateRequest.serverId) - - let element = query.element(matching: NSPredicate(format: predicateRequest.predicate)) - - let id = await self.cache.add(element: element) - - return ElementPayload(serverId: id) - } - - @MainActor - func matchingPredicate(predicateRequest: PredicateRequest) async throws -> QueryResponse { - let query = try await self.cache.getElementQuery(predicateRequest.serverId) - let matching = query.matching(NSPredicate(format: predicateRequest.predicate)) - let id = await self.cache.add(query: matching) - return QueryResponse(serverId: id) - } - - @MainActor - func matchingByIdentifier(request: ByIdRequest) async throws -> QueryResponse { - let query = try await self.cache.getElementQuery(request.queryRoot) - let matching = query.matching(identifier: request.identifier) - let id = await self.cache.add(query: matching) - return QueryResponse(serverId: id) - } - - @MainActor - func containingPredicate(request: PredicateRequest) async throws -> QueryResponse { - let query = try await self.cache.getElementQuery(request.serverId) - let matching = query.containing(NSPredicate(format: request.predicate)) - let id = await self.cache.add(query: matching) - return QueryResponse(serverId: id) - } - - @MainActor - func containingElementType(request: ElementTypeRequest) async throws -> QueryResponse { - let rootQuery = try await self.cache.getElementQuery(request.serverId) - let query = rootQuery.containing(request.elementType.toXCUIElementType(), identifier: request.identifier) - - let id = await self.cache.add(query: query) - - return QueryResponse(serverId: id) - } - - @MainActor - func tapElement(tapRequest: TapElementRequest) async throws -> Bool { - guard let element = try? await self.cache.getElement(tapRequest.serverId) else { - return false - } - - if let duration = tapRequest.duration { - element.press(forDuration: duration) - } else if let numberOfTouches = tapRequest.numberOfTouches { - if let numberOfTaps = tapRequest.numberOfTaps { - element.tap(withNumberOfTaps: numberOfTaps, numberOfTouches: numberOfTouches) - } else { - element.twoFingerTap() - } - } else { - element.tap() - } - - return true - } - - @MainActor - func doubleTap(tapRequest: ElementPayload) async throws { - let element = try await self.cache.getElement(tapRequest.serverId) - element.doubleTap() - } - - @MainActor - func exists(request: ElementPayload) async throws -> ExistsResponse { - let element = try await self.cache.getElement(request.serverId) - let exists = element.exists - - return ExistsResponse(exists: exists) - } - - @MainActor - func typeText(request: EnterTextRequest) async throws { - let element = try await self.cache.getElement(request.serverId) - element.typeText(request.textToEnter) - } - - @MainActor - func value(request: ElementPayload) async throws -> ValueResponse { - let element = try await self.cache.getElement(request.serverId) - let value = element.value as? String - - return ValueResponse(value: value) - } - - @MainActor - func scroll(request: ScrollRequest) async throws { - let element = try await self.cache.getElement(request.serverId) - element.scroll(byDeltaX: request.deltaX, deltaY: request.deltaY) - } - - @MainActor - func swipe(request: SwipeRequest) async throws { - let element = try await self.cache.getElement(request.serverId) - - let velocity = request.velocity.xcUIGestureVelocity - - switch request.swipeDirection { - case .left: - element.swipeLeft(velocity: velocity) - case .right: - element.swipeRight(velocity: velocity) - case .up: - element.swipeUp(velocity: velocity) - case .down: - element.swipeDown(velocity: velocity) - } - } - - @MainActor - func pinch(request: PinchRequest) async throws { - let element = try await self.cache.getElement(request.serverId) - element.pinch(withScale: request.scale, velocity: request.velocity) - } - - @MainActor - func rotate(request: RotateRequest) async throws { - let element = try await self.cache.getElement(request.serverId) - element.rotate(request.rotation, withVelocity: request.velocity) - } - - @MainActor - func waitForExistence(request: WaitForExistenceRequest) async throws -> WaitForExistenceResponse { - let element = try await self.cache.getElement(request.serverId) - let exists = element.waitForExistence(timeout: request.timeout) - - return WaitForExistenceResponse(elementExists: exists) - } - - @MainActor - func waitForNonExistence(request: WaitForExistenceRequest) async throws -> WaitForExistenceResponse { - let element = try await self.cache.getElement(request.serverId) - - let predicate = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: element) - - let result = await XCTWaiter().fulfillment(of: [predicate], timeout: request.timeout, enforceOrder: false) - - return WaitForExistenceResponse(elementExists: result != .completed) - } - - @MainActor - func isHittable(request: ElementPayload) async throws -> IsHittableResponse { - let isHittable = try await self.cache.getElement(request.serverId).isHittable - return IsHittableResponse(isHittable: isHittable) - } - - @MainActor - func count(request: CountRequest) async throws -> CountResponse { - let count: Int = (try await self.cache.getElementQuery(request.serverId)).count - return CountResponse(count: count) - } - - @MainActor - func queryDescendants(request: DescendantsFromQuery) async throws -> QueryResponse { - let rootQuery = try await self.cache.getElementQuery(request.serverId) - let descendantsQuery = rootQuery.descendants(matching: request.elementType.toXCUIElementType()) - - let id = await self.cache.add(query: descendantsQuery) - - return QueryResponse(serverId: id) - } - - @MainActor - func elementDescendants(request: ElementTypeRequest) async throws -> QueryResponse { - let rootElement = try await self.cache.getElement(request.serverId) - let descendantsQuery = rootElement.descendants(matching: request.elementType.toXCUIElementType()) - - let id = await self.cache.add(query: descendantsQuery) - - return QueryResponse(serverId: id) - } - - @MainActor - func matchingElementType(request: ElementTypeRequest) async throws -> QueryResponse { - let rootQuery = try await self.cache.getElementQuery(request.serverId) - let descendantsQuery = rootQuery.matching(request.elementType.toXCUIElementType(), identifier: request.identifier) - let id = await self.cache.add(query: descendantsQuery) - return QueryResponse(serverId: id) - } - - @MainActor - func allElementsBoundByAccessibilityElement(request: ElementsByAccessibility) async throws -> ElementArrayResponse { - let rootQuery = try await self.cache.getElementQuery(request.serverId) - let allElements = rootQuery.allElementsBoundByAccessibilityElement - - let ids = await self.cache.add(elements: allElements) - - return ElementArrayResponse(serversId: ids) - } - - @MainActor - func allElementsBoundByIndex(request: ElementsByAccessibility) async throws -> ElementArrayResponse { - let rootQuery = try await self.cache.getElementQuery(request.serverId) - let allElements = rootQuery.allElementsBoundByIndex - - let ids = await self.cache.add(elements: allElements) - - return ElementArrayResponse(serversId: ids) - } - - @MainActor - func children(request: ChildrenMatchinType) async throws -> QueryResponse { - var childrenQuery: XCUIElementQuery - - if let rootQuery = try? await self.cache.getElementQuery(request.serverId) { - childrenQuery = rootQuery.children(matching: request.elementType.toXCUIElementType()) - } else if let rootElement = try? await self.cache.getElement(request.serverId) { - childrenQuery = rootElement.children(matching: request.elementType.toXCUIElementType()) - } else { - throw ElementNotFoundError(serverId: request.serverId.uuidString) - } - - let id = await self.cache.add(query: childrenQuery) - - return QueryResponse(serverId: id) - } - - @MainActor - func element(request: ByIdRequest) async -> ElementPayload { - let newElement = try! await self.findElement(elementRequest: request) - let id = await self.cache.add(element: newElement) - return ElementPayload(serverId: id) - } - - @MainActor - func query(request: QueryRequest) async throws -> QueryResponse { - let newQuery = try await self.performQuery(queryRequest: request) - let serverId = await self.cache.add(query: newQuery) - return QueryResponse(serverId: serverId) - } - - @MainActor - func remove(request: ElementPayload) async -> Bool { - await self.cache.remove(request.serverId) - - return true - } - - @MainActor - func debugDescription(request: ElementPayload) async throws -> String { - var debugDescription: String! - - if let rootQuery = try? await self.cache.getElementQuery(request.serverId) { - debugDescription = rootQuery.debugDescription - } else if let rootElement = try? await self.cache.getElement(request.serverId) { - debugDescription = rootElement.debugDescription - } else { - throw ElementNotFoundError(serverId: request.serverId.uuidString) - } - - return debugDescription - } - - @MainActor - func identifier(request: ElementPayload) async throws -> String { - let rootElement = try await self.cache.getElement(request.serverId) - return rootElement.identifier - } - - @MainActor - func title(request: ElementPayload) async throws -> String { - let rootElement = try await self.cache.getElement(request.serverId) - return rootElement.title - } - - @MainActor - func label(request: ElementPayload) async throws -> String { - let rootElement = try await self.cache.getElement(request.serverId) - return rootElement.label - } - - @MainActor - func placeholderValue(request: ElementPayload) async throws -> String? { - let rootElement = try await self.cache.getElement(request.serverId) - return rootElement.placeholderValue - } - - @MainActor - func isSelected(request: ElementPayload) async throws -> Bool { - let rootElement = try await self.cache.getElement(request.serverId) - return rootElement.isSelected - } - - @MainActor - func hasFocus(request: ElementPayload) async throws -> Bool { - let rootElement = try await self.cache.getElement(request.serverId) - return rootElement.hasFocus - } - - @MainActor - func isEnabled(request: ElementPayload) async throws -> Bool { - let rootElement = try await self.cache.getElement(request.serverId) - return rootElement.isEnabled - } - - @MainActor - func coordinate(request: CoordinateRequest) async throws -> CoordinateResponse { - // withNormalizedOffset: CGVector - let rootElement = try await self.cache.getElement(request.serverId) - let coordinate = rootElement.coordinate(withNormalizedOffset: request.normalizedOffset) - - let coordinateUUID = await cache.add(coordinate: coordinate) - let elementUUID = await cache.add(element: coordinate.referencedElement) - - return CoordinateResponse(coordinateId: coordinateUUID, referencedElementId: elementUUID, screenPoint: coordinate.screenPoint) - } - - @MainActor - func coordinateWithOffset(request: CoordinateOffsetRequest) async throws -> CoordinateResponse { - let rootCoordinate = try await self.cache.getCoordinate(request.coordinatorId) - let coordinate = rootCoordinate.withOffset(request.vector) - - let coordinateUUID = await cache.add(coordinate: coordinate) - let elementUUID = await cache.add(element: coordinate.referencedElement) - - return CoordinateResponse(coordinateId: coordinateUUID, referencedElementId: elementUUID, screenPoint: coordinate.screenPoint) - } - - @MainActor - func coordinateTap(request: TapCoordinateRequest) async throws -> Bool { - let rootCoordinate = try await self.cache.getCoordinate(request.serverId) - - switch request.type { - case .tap: - rootCoordinate.tap() - case .doubleTap: - rootCoordinate.doubleTap() - case .press(forDuration: let duration): - rootCoordinate.press(forDuration: duration) - case .pressAndDrag(forDuration: let duration, thenDragTo: let coordinate): - let coordinate = try await self.cache.getCoordinate(coordinate) - rootCoordinate.press(forDuration: duration, thenDragTo: coordinate) - case .pressDragAndHold(forDuration: let duration, thenDragTo: let coordinate, withVelocity: let velocity, thenHoldForDuration: let holdDuration): - let coordinate = try await self.cache.getCoordinate(coordinate) - rootCoordinate.press(forDuration: duration, thenDragTo: coordinate, withVelocity: velocity.xcUIGestureVelocity, thenHoldForDuration: holdDuration) - } - - return true - } - - @MainActor - func frame(request: ElementPayload) async throws -> CGRect { - let element = try await self.cache.getElement(request.serverId) - return element.frame - } - - @MainActor - func horizontalSizeClass(request: ElementPayload) async throws -> SizeClass { - let element = try await self.cache.getElement(request.serverId) - return SizeClass(rawValue: element.horizontalSizeClass.rawValue)! - } - - @MainActor - func verticalSizeClass(request: ElementPayload) async throws -> SizeClass { - let element = try await self.cache.getElement(request.serverId) - return SizeClass(rawValue: element.verticalSizeClass.rawValue)! - } - - @MainActor - func elementType(request: ElementPayload) async throws -> UInt { - let element = try await self.cache.getElement(request.serverId) - return element.elementType.rawValue - } - - @MainActor - func accessibilityTest(request: ElementPayload) async throws -> Bool { - let application = try await self.cache.getApplication(request.serverId) - - if #available(iOS 17.0, *) { - return (try? application.performAccessibilityAudit()) != nil - } else { - return false - } - } - + /// A cache mechanism to manage and track XCUIApplication and XCUIElement instances + /// Provides efficient reference management and lookup for UI testing elements + @MainActor + let cache = ServerState() + + /// Starts the UIServer with a specified port index + /// + /// This method sets up an HTTP server for UI testing operations, registering multiple routes + /// for various UI interaction and querying capabilities. The server runs on a loopback address + /// with a dynamically assigned port based on the provided port index. + /// + /// - Parameters: + /// - portIndex: An optional index to offset the base port number, allowing multiple server instances + /// Default value is 0, which means the server will start on the base port 22087 + /// - Throws: Errors related to server setup, route registration, or server startup func start(portIndex: UInt16 = 0) async throws { let server = HTTPServer( address: .loopback(port: 22087 + portIndex), @@ -461,92 +106,18 @@ class UIServer { ) self.server = server - await addRoute("createApp", handler: { (request: CreateApplicationRequest) in - let app = XCUIApplication(bundleIdentifier: request.appId) - await self.cache.add(application: app, id: request.serverId) - - if request.activate { - app.activate() - } - }) - - await addRoute("Activate", handler: { (activateRequest: ActivateRequest) in - let app = try await self.cache.getApplication(activateRequest.serverId) - app.activate() - }) - - await addRoute("firstMatch", handler: self.firstMatch(firstMatchRequest:)) - await addRoute("elementFromQuery", handler: self.elementFromQuery(elementFromQuery:)) - await addRoute("elementMatchingPredicate", handler: self.elementMatchingPredicate(predicateRequest:)) - await addRoute("matchingPredicate", handler: self.matchingPredicate(predicateRequest:)) - await addRoute("matchingByIdentifier", handler: self.matchingByIdentifier(request:)) - await addRoute("containingPredicate", handler: self.containingPredicate(request:)) - await addRoute("containingElementType", handler: self.containingElementType(request:)) - await addRoute("tapElement", handler: self.tapElement(tapRequest:)) - await addRoute("doubleTap", handler: self.doubleTap(tapRequest:)) - await addRoute("exists", handler: self.exists(request:)) - await addRoute("value", handler: self.value(request:)) - await addRoute("typeText", handler: self.typeText(request:)) - await addRoute("scroll", handler: self.scroll(request:)) - await addRoute("swipe", handler: self.swipe(request:)) - await addRoute("pinch", handler: self.pinch(request:)) - await addRoute("rotate", handler: self.rotate(request:)) - await addRoute("waitForExistence", handler: self.waitForExistence(request:)) - await addRoute("waitForNonExistence", handler: self.waitForNonExistence(request:)) - - await addRoute("HomeButton", handler: { (_: HomeButtonRequest) in - XCUIDevice.shared.press(.home) - }) - - await addRoute("isHittable", handler: self.isHittable(request:)) - await addRoute("count", handler: self.count(request:)) - await addRoute("queryDescendants", handler: self.queryDescendants(request:)) - await addRoute("elementDescendants", handler: self.elementDescendants(request:)) - await addRoute("matchingElementType", handler: self.matchingElementType(request:)) - await addRoute("allElementsBoundByAccessibilityElement", handler: self.allElementsBoundByAccessibilityElement(request:)) - await addRoute("allElementsBoundByIndex", handler: self.allElementsBoundByIndex(request:)) - await addRoute("children", handler: self.children(request:)) - await addRoute("query", handler: self.query(request:)) - await addRoute("element", handler: self.element(request:)) - await addRoute("remove", handler: self.remove(request:)) - await addRoute("debugDescription", handler: self.debugDescription(request:)) - - await addRoute("identifier", handler: self.identifier(request:)) - await addRoute("title", handler: self.title(request:)) - await addRoute("label", handler: self.label(request:)) - await addRoute("placeholderValue", handler: self.placeholderValue(request:)) - await addRoute("isSelected", handler: self.isSelected(request:)) - await addRoute("hasFocus", handler: self.hasFocus(request:)) - await addRoute("isEnabled", handler: self.isEnabled(request:)) - - await addRoute("coordinate", handler: self.coordinate(request:)) - await addRoute("coordinateWithOffset", handler: self.coordinateWithOffset(request:)) - await addRoute("coordinateTap", handler: self.coordinateTap(request:)) + // Register all route groups + await registerApplicationRoutes() + await registerElementQueryRoutes() + await registerInteractionRoutes() + await registerElementStateRoutes() + await registerElementInfoRoutes() + await registerElementCollectionRoutes() + await registerCoordinateRoutes() + await registerAccessibilityRoutes() + await registerUtilityRoutes() - await addRoute("frame", handler: self.frame(request:)) - await addRoute("horizontalSizeClass", handler: self.horizontalSizeClass(request:)) - await addRoute("verticalSizeClass", handler: self.verticalSizeClass(request:)) - await addRoute("elementType", handler: self.elementType(request:)) - - await addRoute("performAccessibilityAudit", handler: self.performAccessibilityAudit(request:)) - - await self.server.appendRoute(HTTPRoute(stringLiteral: "stop"), to: ClosureHTTPHandler({ _ in - Task { - await self.server.stop(timeout: 10) - } - - return self.buildResponse(true) - })) - - await self.server.appendRoute(HTTPRoute(stringLiteral: "alive"), to: ClosureHTTPHandler({ _ in - return self.buildResponse(true) - })) - - await self.server.appendRoute(HTTPRoute(stringLiteral: "server-version"), to: ClosureHTTPHandler({ _ in - return self.buildResponse(CurrentServerVersion) - })) - - let task = Task { try await server.start() } + let task = Task { try await server.run() } try await server.waitUntilListening() @@ -555,199 +126,33 @@ class UIServer { _ = await task.result } - func buildResponse(_ data: some Codable) -> HTTPResponse { - if let lastIssue { - return buildError(lastIssue.detailedDescription ?? lastIssue.description) - } else { - return HTTPResponse(statusCode: .ok, body: try! encoder.encode(UIResponse(response: data))) - } - } - - func buildError(_ error: String) -> HTTPResponse { - return HTTPResponse(statusCode: .badRequest, body: try! encoder.encode(UIResponse(error: error))) - } - - @MainActor - func performQuery(queryRequest: QueryRequest) async throws -> XCUIElementQuery { - let rootElementQuery = try await self.cache.getQuery(queryRequest.serverId) - - let resultQuery: XCUIElementQuery - switch queryRequest.queryType { - case .staticTexts: - resultQuery = rootElementQuery.staticTexts - case .activityIndicators: - resultQuery = rootElementQuery.activityIndicators - case .alerts: - resultQuery = rootElementQuery.alerts - case .browsers: - resultQuery = rootElementQuery.browsers - case .buttons: - resultQuery = rootElementQuery.buttons - case .cells: - resultQuery = rootElementQuery.cells - case .checkBoxes: - resultQuery = rootElementQuery.staticTexts - case .collectionViews: - resultQuery = rootElementQuery.collectionViews - case .colorWells: - resultQuery = rootElementQuery.colorWells - case .comboBoxes: - resultQuery = rootElementQuery.comboBoxes - case .datePickers: - resultQuery = rootElementQuery.datePickers - case .decrementArrows: - resultQuery = rootElementQuery.decrementArrows - case .dialogs: - resultQuery = rootElementQuery.dialogs - case .disclosureTriangles: - resultQuery = rootElementQuery.disclosureTriangles - case .disclosedChildRows: - resultQuery = rootElementQuery.disclosedChildRows - case .dockItems: - resultQuery = rootElementQuery.dockItems - case .drawers: - resultQuery = rootElementQuery.drawers - case .grids: - resultQuery = rootElementQuery.grids - case .groups: - resultQuery = rootElementQuery.groups - case .handles: - resultQuery = rootElementQuery.handles - case .helpTags: - resultQuery = rootElementQuery.helpTags - case .icons: - resultQuery = rootElementQuery.icons - case .images: - resultQuery = rootElementQuery.images - case .incrementArrows: - resultQuery = rootElementQuery.incrementArrows - case .keyboards: - resultQuery = rootElementQuery.keyboards - case .keys: - resultQuery = rootElementQuery.keys - case .layoutAreas: - resultQuery = rootElementQuery.layoutAreas - case .layoutItems: - resultQuery = rootElementQuery.layoutItems - case .levelIndicators: - resultQuery = rootElementQuery.levelIndicators - case .links: - resultQuery = rootElementQuery.links - case .maps: - resultQuery = rootElementQuery.maps - case .mattes: - resultQuery = rootElementQuery.mattes - case .menuBarItems: - resultQuery = rootElementQuery.menuBarItems - case .menuBars: - resultQuery = rootElementQuery.menuBars - case .menuButtons: - resultQuery = rootElementQuery.menuButtons - case .menuItems: - resultQuery = rootElementQuery.menuItems - case .menus: - resultQuery = rootElementQuery.menus - case .navigationBars: - resultQuery = rootElementQuery.navigationBars - case .otherElements: - resultQuery = rootElementQuery.otherElements - case .outlineRows: - resultQuery = rootElementQuery.outlineRows - case .outlines: - resultQuery = rootElementQuery.outlines - case .pageIndicators: - resultQuery = rootElementQuery.pageIndicators - case .pickerWheels: - resultQuery = rootElementQuery.pickerWheels - case .pickers: - resultQuery = rootElementQuery.pickers - case .popUpButtons: - resultQuery = rootElementQuery.popUpButtons - case .popovers: - resultQuery = rootElementQuery.popovers - case .progressIndicators: - resultQuery = rootElementQuery.progressIndicators - case .radioButtons: - resultQuery = rootElementQuery.radioButtons - case .radioGroups: - resultQuery = rootElementQuery.radioGroups - case .ratingIndicators: - resultQuery = rootElementQuery.ratingIndicators - case .relevanceIndicators: - resultQuery = rootElementQuery.relevanceIndicators - case .rulerMarkers: - resultQuery = rootElementQuery.rulerMarkers - case .rulers: - resultQuery = rootElementQuery.rulers - case .scrollBars: - resultQuery = rootElementQuery.scrollBars - case .scrollViews: - resultQuery = rootElementQuery.scrollViews - case .searchFields: - resultQuery = rootElementQuery.searchFields - case .secureTextFields: - resultQuery = rootElementQuery.secureTextFields - case .segmentedControls: - resultQuery = rootElementQuery.segmentedControls - case .sheets: - resultQuery = rootElementQuery.sheets - case .sliders: - resultQuery = rootElementQuery.sliders - case .splitGroups: - resultQuery = rootElementQuery.splitGroups - case .splitters: - resultQuery = rootElementQuery.splitters - case .statusBars: - resultQuery = rootElementQuery.statusBars - case .statusItems: - resultQuery = rootElementQuery.statusItems - case .steppers: - resultQuery = rootElementQuery.steppers - case .switches: - resultQuery = rootElementQuery.switches - case .tabBars: - resultQuery = rootElementQuery.tabBars - case .tabGroups: - resultQuery = rootElementQuery.tabGroups - case .tableColumns: - resultQuery = rootElementQuery.tableColumns - case .tableRows: - resultQuery = rootElementQuery.tableRows - case .tables: - resultQuery = rootElementQuery.tables - case .textFields: - resultQuery = rootElementQuery.textFields - case .textViews: - resultQuery = rootElementQuery.textViews - case .timelines: - resultQuery = rootElementQuery.timelines - case .toggles: - resultQuery = rootElementQuery.toggles - case .toolbarButtons: - resultQuery = rootElementQuery.toolbarButtons - case .toolbars: - resultQuery = rootElementQuery.toolbars - case .touchBars: - resultQuery = rootElementQuery.touchBars - case .valueIndicators: - resultQuery = rootElementQuery.valueIndicators - case .webViews: - resultQuery = rootElementQuery.webViews - case .windows: - resultQuery = rootElementQuery.windows - } - - return resultQuery - } - - func findElement(elementRequest: ByIdRequest) async throws -> XCUIElement { - let rootElementQuery = try await self.cache.getElementQuery(elementRequest.queryRoot) - - return await rootElementQuery[elementRequest.identifier] - } - + /// Adds a dynamically routed handler for processing HTTP requests with a specific type + /// + /// This method allows dynamic registration of routes with type-safe request and response handling. + /// It provides a centralized mechanism for processing UI testing requests, including: + /// - Decoding incoming JSON requests + /// - Executing the provided handler + /// - Building appropriate HTTP responses + /// - Error handling for request processing + /// + /// - Parameters: + /// - route: The string route to register for handling requests + /// - handler: An asynchronous closure that processes the request and returns a typed response + /// Dynamically registers a route with a type-safe request handler that returns a response + /// + /// This method allows for flexible and type-safe HTTP route registration for UI testing operations. + /// It handles the entire request lifecycle, including: + /// - JSON decoding of the incoming request + /// - Executing the provided handler + /// - Building an appropriate HTTP response + /// - Error handling and logging + /// + /// - Parameters: + /// - route: A string representing the HTTP route to register + /// - handler: An asynchronous closure that processes the request and returns a typed response + /// - Note: Automatically resets `lastIssue` before processing each request func addRoute(_ route: String, handler: @escaping @MainActor (Request) async throws -> Response) async { - await self.server.appendRoute(HTTPRoute(stringLiteral: route), handler: { @MainActor request in + await server.appendRoute(HTTPRoute(stringLiteral: route), handler: { @MainActor request in defer { self.lastIssue = nil @@ -764,23 +169,34 @@ class UIServer { }) } + /// Dynamically registers a route with a type-safe request handler that does not return a response + /// + /// This overload of `addRoute` is used for handlers that perform an action without returning a value. + /// It wraps the void handler to return a boolean success indicator. + /// + /// - Parameters: + /// - route: A string representing the HTTP route to register + /// - handler: An asynchronous closure that processes the request without returning a value + /// - Note: Converts the void handler to return `true` on successful completion func addRoute(_ route: String, handler: @escaping @MainActor (Request) async throws -> Void) async { - await self.server.appendRoute(HTTPRoute(stringLiteral: route), handler: { @MainActor request in - defer { - self.lastIssue = nil - } - - let tapRequest = try await decoder.decode(Request.self, from: request.bodyData) + await addRoute(route, handler: { request in + try await handler(request) + return true + }) + } - do { - try await handler(tapRequest) - return self.buildResponse(true) - } catch { - return self.buildError(error.localizedDescription) - } + func buildResponse(_ data: some Codable) -> HTTPResponse { + if let lastIssue { + return buildError(lastIssue.detailedDescription ?? lastIssue.description) + } else { + return HTTPResponse(statusCode: .ok, body: try! encoder.encode(UIResponse(response: data))) + } + } - }) + func buildError(_ error: String) -> HTTPResponse { + return HTTPResponse(statusCode: .badRequest, body: try! encoder.encode(UIResponse(error: error))) } + } public extension UInt { @@ -789,21 +205,6 @@ public extension UInt { } } -public extension GestureVelocityAPI { - var xcUIGestureVelocity: XCUIGestureVelocity { - switch self { - case .slow: - return .slow - case .fast: - return .fast - case .default: - return .default - case let .custom(value): - return XCUIGestureVelocity(rawValue: value) - } - } -} - extension UInt64 { @available(iOS 17.0, *) func toXCUIAccessibilityAuditType() -> XCUIAccessibilityAuditType { @@ -811,27 +212,13 @@ extension UInt64 { } } -extension Sequence { - func asyncMap( - _ transform: (Element) async throws -> T - ) async rethrows -> [T] { - var values = [T]() - - for element in self { - try await values.append(transform(element)) - } - - return values - } -} - extension AccessibilityAuditIssueData { @available(iOS 17.0, *) @MainActor - init(xcIssue: XCUIAccessibilityAuditIssue, cache: Cache) async { + init(xcIssue: XCUIAccessibilityAuditIssue, cache: ServerState) { var elementId: UUID? if let element = xcIssue.element { - elementId = await cache.add(element: element) + elementId = cache.add(element: element) } self.init( element: elementId, diff --git a/Server/ServerUITests/UITestServerTest.swift b/Server/ServerUITests/UITestServerTest.swift index ca65416..1994cdb 100644 --- a/Server/ServerUITests/UITestServerTest.swift +++ b/Server/ServerUITests/UITestServerTest.swift @@ -1,9 +1,12 @@ import XCTest +import FlyingFox final class UITestServerTest: XCTestCase { override func record(_ issue: XCTIssue) { - self.server.lastIssue = issue + MainActor.assumeIsolated { [server] in + server!.lastIssue = issue + } } override func setUpWithError() throws { @@ -29,7 +32,8 @@ final class UITestServerTest: XCTestCase { print("SERVER ERROR ----------------------------------") } } - + + @MainActor func deviceId() -> Int { let deviceName = UIDevice.current.name diff --git a/Server/ServerUITests/XCUIElementTypeQueryProvider+Additions.swift b/Server/ServerUITests/XCUIElementTypeQueryProvider+Additions.swift new file mode 100644 index 0000000..570db3a --- /dev/null +++ b/Server/ServerUITests/XCUIElementTypeQueryProvider+Additions.swift @@ -0,0 +1,174 @@ +import XCTest +import UIUnitTestAPI + +extension XCUIElementTypeQueryProvider { + func queryBy(_ queryType: QueryType) -> XCUIElementQuery { + let resultQuery: XCUIElementQuery + switch queryType { + case .staticTexts: + resultQuery = self.staticTexts + case .activityIndicators: + resultQuery = self.activityIndicators + case .alerts: + resultQuery = self.alerts + case .browsers: + resultQuery = self.browsers + case .buttons: + resultQuery = self.buttons + case .cells: + resultQuery = self.cells + case .checkBoxes: + resultQuery = self.checkBoxes + case .collectionViews: + resultQuery = self.collectionViews + case .colorWells: + resultQuery = self.colorWells + case .comboBoxes: + resultQuery = self.comboBoxes + case .datePickers: + resultQuery = self.datePickers + case .decrementArrows: + resultQuery = self.decrementArrows + case .dialogs: + resultQuery = self.dialogs + case .disclosureTriangles: + resultQuery = self.disclosureTriangles + case .disclosedChildRows: + resultQuery = self.disclosedChildRows + case .dockItems: + resultQuery = self.dockItems + case .drawers: + resultQuery = self.drawers + case .grids: + resultQuery = self.grids + case .groups: + resultQuery = self.groups + case .handles: + resultQuery = self.handles + case .helpTags: + resultQuery = self.helpTags + case .icons: + resultQuery = self.icons + case .images: + resultQuery = self.images + case .incrementArrows: + resultQuery = self.incrementArrows + case .keyboards: + resultQuery = self.keyboards + case .keys: + resultQuery = self.keys + case .layoutAreas: + resultQuery = self.layoutAreas + case .layoutItems: + resultQuery = self.layoutItems + case .levelIndicators: + resultQuery = self.levelIndicators + case .links: + resultQuery = self.links + case .maps: + resultQuery = self.maps + case .mattes: + resultQuery = self.mattes + case .menuBarItems: + resultQuery = self.menuBarItems + case .menuBars: + resultQuery = self.menuBars + case .menuButtons: + resultQuery = self.menuButtons + case .menuItems: + resultQuery = self.menuItems + case .menus: + resultQuery = self.menus + case .navigationBars: + resultQuery = self.navigationBars + case .otherElements: + resultQuery = self.otherElements + case .outlineRows: + resultQuery = self.outlineRows + case .outlines: + resultQuery = self.outlines + case .pageIndicators: + resultQuery = self.pageIndicators + case .pickerWheels: + resultQuery = self.pickerWheels + case .pickers: + resultQuery = self.pickers + case .popUpButtons: + resultQuery = self.popUpButtons + case .popovers: + resultQuery = self.popovers + case .progressIndicators: + resultQuery = self.progressIndicators + case .radioButtons: + resultQuery = self.radioButtons + case .radioGroups: + resultQuery = self.radioGroups + case .ratingIndicators: + resultQuery = self.ratingIndicators + case .relevanceIndicators: + resultQuery = self.relevanceIndicators + case .rulerMarkers: + resultQuery = self.rulerMarkers + case .rulers: + resultQuery = self.rulers + case .scrollBars: + resultQuery = self.scrollBars + case .scrollViews: + resultQuery = self.scrollViews + case .searchFields: + resultQuery = self.searchFields + case .secureTextFields: + resultQuery = self.secureTextFields + case .segmentedControls: + resultQuery = self.segmentedControls + case .sheets: + resultQuery = self.sheets + case .sliders: + resultQuery = self.sliders + case .splitGroups: + resultQuery = self.splitGroups + case .splitters: + resultQuery = self.splitters + case .statusBars: + resultQuery = self.statusBars + case .statusItems: + resultQuery = self.statusItems + case .steppers: + resultQuery = self.steppers + case .switches: + resultQuery = self.switches + case .tabBars: + resultQuery = self.tabBars + case .tabGroups: + resultQuery = self.tabGroups + case .tableColumns: + resultQuery = self.tableColumns + case .tableRows: + resultQuery = self.tableRows + case .tables: + resultQuery = self.tables + case .textFields: + resultQuery = self.textFields + case .textViews: + resultQuery = self.textViews + case .timelines: + resultQuery = self.timelines + case .toggles: + resultQuery = self.toggles + case .toolbarButtons: + resultQuery = self.toolbarButtons + case .toolbars: + resultQuery = self.toolbars + case .touchBars: + resultQuery = self.touchBars + case .valueIndicators: + resultQuery = self.valueIndicators + case .webViews: + resultQuery = self.webViews + case .windows: + resultQuery = self.windows + } + + return resultQuery + } +} diff --git a/Server/UIUnitTestAPI/Sources/API.swift b/Server/UIUnitTestAPI/Sources/API.swift index c0cf8c3..496d4b3 100644 --- a/Server/UIUnitTestAPI/Sources/API.swift +++ b/Server/UIUnitTestAPI/Sources/API.swift @@ -224,12 +224,39 @@ public struct CountResponse: Codable, Sendable { public struct PredicateRequest: Codable, Sendable { public let serverId: UUID - public let predicate: String + public nonisolated(unsafe) + let predicate: NSPredicate - public init(serverId: UUID, predicate: String) { + public init(serverId: UUID, predicate: NSPredicate) { self.serverId = serverId self.predicate = predicate } + + enum CodingKeys: CodingKey { + case serverId + case predicate + } + + public func encode(to encoder: Encoder) throws { + let data = try NSKeyedArchiver.archivedData( + withRootObject: predicate, + requiringSecureCoding: true + ) + + var container = encoder.container(keyedBy: CodingKeys.self) + try container.encode(serverId, forKey: .serverId) + try container.encode(data, forKey: .predicate) + } + + public init(from decoder: Decoder) throws { + let container = try decoder.container(keyedBy: CodingKeys.self) + + serverId = try container.decode(UUID.self, forKey: .serverId) + + let data = try container.decode(Data.self, forKey: .predicate) + + predicate = try NSKeyedUnarchiver.unarchivedObject(ofClasses: [NSPredicate.self], from: data) as! NSPredicate + } } public struct ElementFromQuery: Codable, Sendable { @@ -482,7 +509,7 @@ public struct UIResponse: Codable { extension UIResponse: Sendable where T: Sendable {} public enum Response { - case error(error: ErrorResponse) +case error(error: ErrorResponse) case success(data: T) } diff --git a/UIUnitTest.xcworkspace/contents.xcworkspacedata b/UIUnitTest.xcworkspace/contents.xcworkspacedata deleted file mode 100644 index 7e79a19..0000000 --- a/UIUnitTest.xcworkspace/contents.xcworkspacedata +++ /dev/null @@ -1,10 +0,0 @@ - - - - - - - diff --git a/UIUnitTest.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist b/UIUnitTest.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist deleted file mode 100644 index 18d9810..0000000 --- a/UIUnitTest.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +++ /dev/null @@ -1,8 +0,0 @@ - - - - - IDEDidComputeMac32BitWarning - - - diff --git a/UIUnitTest.xcworkspace/xcshareddata/swiftpm/Package.resolved b/UIUnitTest.xcworkspace/xcshareddata/swiftpm/Package.resolved deleted file mode 100644 index ae8cba8..0000000 --- a/UIUnitTest.xcworkspace/xcshareddata/swiftpm/Package.resolved +++ /dev/null @@ -1,41 +0,0 @@ -{ - "pins" : [ - { - "identity" : "commander", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/Commander", - "state" : { - "revision" : "4a1f2fb82fb6cef613c4a25d2e38f702e4d812c2", - "version" : "0.9.2" - } - }, - { - "identity" : "spectre", - "kind" : "remoteSourceControl", - "location" : "https://github.com/kylef/Spectre.git", - "state" : { - "revision" : "26cc5e9ae0947092c7139ef7ba612e34646086c7", - "version" : "0.10.1" - } - }, - { - "identity" : "swift-argument-parser", - "kind" : "remoteSourceControl", - "location" : "https://github.com/apple/swift-argument-parser", - "state" : { - "revision" : "8f4d2753f0e4778c76d5f05ad16c74f707390531", - "version" : "1.2.3" - } - }, - { - "identity" : "xccov2lcov", - "kind" : "remoteSourceControl", - "location" : "https://github.com/trax-retail/xccov2lcov", - "state" : { - "revision" : "fd0b15537f0b4b949d8e6b8a38eeef09a35d31ff", - "version" : "1.0.0" - } - } - ], - "version" : 2 -} diff --git a/docs/devlog/010 - Long time no see.md b/docs/devlog/010 - Long time no see.md new file mode 100644 index 0000000..376491a --- /dev/null +++ b/docs/devlog/010 - Long time no see.md @@ -0,0 +1,23 @@ +# Long time no see + +A lot happened and I was not really working on the lib for some time. But this time helped me rethink some of the design decisions and move foward with some of the challenges that I faced with the lib. + +A lot of things changed on iOS too. A new Swift version, a new testing framework. Concurrency is now a big thing and a lot of changes are needed to address it. + +## Swift 6.0 + +With the new concurrency model, many changes were required to avoid potential data races. These changes highlighted one problem of the Sync API and I got very close to completly remove it. + +## Swift testing + +The new testing framework has many new behaviours that were nice, but conflict with the UIUnitTest assumptions. UIUnitTest shares the current UI, so they cannot run in parallel in the same simulator. The Sync API was deadlocking the main thread and failing some tests. I started working on some changes to address these problems, and I think I got to an OK solution. And now all the tests on XCTest have a Swift-Testing copy. + +## NeoVim + +This is more a personal change, but I decided to start learn Vim/NewVim. But iOS was really not friendly with it. Luckly I found one plugin recently that was a game changer, and now I'm really trying to do all the development of the lib on it. But it definitly reduced my speed, and I still going to be slower for some time, until I get used with the new tooling. + +# Changes + +- The Sync API will be kept as experimental. It is working on XCTests and Swift-Testing but it required some changes that were not 'nice' to say the least. I will still keep testing it, but I should change the documentation to give more space to the Async API +- The Async API was changed to match the Sync one. All the methods were the same now, the only difference it that they are `async throws`, but the use properties, subscripts and all match one to one with Apples APIs. +- Swift-Testing are in experimental phase, but the lib supports it. There is some `traits` that I need to dive deeper to understand, but it should work in other projects. diff --git a/generate-zip.sh b/generate-zip.sh new file mode 100755 index 0000000..f410dbd --- /dev/null +++ b/generate-zip.sh @@ -0,0 +1,26 @@ +## Builds the app, then gets the App and the Test Runner and zips them +## Then gets the xctestrun to be used to install the apps on simulators + +## TODO: Change minimum version to 15 or 14. +## TODO: Maybe convert to Swift inside the command line? + +## Build the UI test +root=$PWD + +## Remove derived data to avoid adding it to the zip file +rm -rf "$root"/Server/DerivedData + +(cd "$root"/Server/ && zip -r "$root"/Lib/Sources/UIUnitTestCLI/resources/Server.zip *) || exit 1 + +xcodebuild -project ./Server/Server.xcodeproj \ + -scheme ServerUITests -sdk iphonesimulator \ + -destination "platform=iOS Simulator,name=iPhone 16,OS=18.0" \ + -IDEBuildLocationStyle=Custom \ + -IDECustomBuildLocationType=Absolute \ + -IDECustomBuildProductsPath="$PWD/build/Products" \ + -derivedDataPath="$PWD/derivedData/" \ + build-for-testing | xcbeautify || exit 1 + +## (cd "$root"/build/Products/Debug-iphonesimulator/ && zip -r "$root"/Lib/Sources/UIUnitTestCLI/resources/PreBuild.zip ServerUITests-Runner.app) || exit 1 + +(cd "$root"/build/Products/Release-iphonesimulator/ && zip -r "$root"/Lib/Sources/UIUnitTestCLI/resources/PreBuild.zip ServerUITests-Runner.app) || exit 1 diff --git a/makefile b/makefile index f77617a..f9323c3 100644 --- a/makefile +++ b/makefile @@ -7,7 +7,7 @@ test: set -o pipefail && xcodebuild -project Client/Client.xcodeproj \ -scheme Client \ test \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \ -resultBundlePath test-result.xcresult \ -derivedDataPath 'derivedData' \ -clonedSourcePackagesDirPath SourcePackages \ @@ -20,7 +20,7 @@ test-parallel: NSUnbufferedIO=YES xcodebuild -project Client/Client.xcodeproj \ -scheme "ClientTests - Parallel" \ test \ - -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.0' \ + -destination 'platform=iOS Simulator,name=iPhone 16,OS=18.2' \ -resultBundlePath test-result.xcresult \ -clonedSourcePackagesDirPath SourcePackages \ -disableAutomaticPackageResolution \ @@ -35,7 +35,7 @@ generate-zip: (cd $(root)/Server/ && zip -r $(root)/Lib/Sources/UIUnitTestCLI/resources/Server.zip *) || exit 1 xcodebuild -project ./Server/Server.xcodeproj \ -scheme ServerUITests -sdk iphonesimulator \ - -destination "platform=iOS Simulator,name=iPhone 16,OS=18.0" \ + -destination "platform=iOS Simulator,name=iPhone 16,OS=18.2" \ -IDEBuildLocationStyle=Custom \ -IDECustomBuildLocationType=Absolute \ -IDECustomBuildProductsPath="$(root)/build/Products" \ @@ -50,3 +50,6 @@ clean-up: rm -rf derivedData 2> /dev/null rm -rf test-result.xcresult 2> /dev/null +.PHONY: reset-simulators +reset-simulators: + killall "Simulator" 2> /dev/null; xcrun simctl erase all diff --git a/stop-server.sh b/stop-server.sh index 1165898..f221e9b 100755 --- a/stop-server.sh +++ b/stop-server.sh @@ -1,4 +1,4 @@ - #!/bin/sh +#!/bin/sh unset SDKROOT