├── .gitignore ├── LICENSE ├── Package.swift ├── README.md ├── Sources └── Sisyphos │ ├── DefaultAlert.swift │ ├── DefaultPermissionAlert.swift │ ├── Documentation.docc │ ├── Documentation.md │ ├── Integration.md │ ├── Interruptions.md │ ├── Resources │ │ ├── codegeneration.gif │ │ ├── integration-wrong-target~dark.png │ │ ├── integration-wrong-target~light.png │ │ ├── landsmark-app-homepage@3x.png │ │ └── landsmark-app-silversalmoncreek@3x.png │ └── WritingTests.md │ ├── Extensions │ ├── NSPredicate+Description.swift │ ├── String+Localized.swift │ ├── XCUIApplication+CurrentPage.swift │ ├── XCUIElement+Extensions.swift │ ├── XCUIElementSnapshot+Matching.swift │ ├── XCUIElementType+BetterCustomDebugString.swift │ ├── XCUITestCase+CodeGeneration.swift │ └── XCUITestCase+InterruptionMonitor.swift │ ├── Internal │ ├── ElementFinder.swift │ ├── Snapshot.swift │ └── UIInterruptionsObserver.swift │ ├── InterruptionMonitor.swift │ ├── Page Elements │ ├── Alert.swift │ ├── Button.swift │ ├── Cell.swift │ ├── CollectionView.swift │ ├── NavigationBar.swift │ ├── Other.swift │ ├── SecureTextField.swift │ ├── StaticText.swift │ ├── Switch.swift │ ├── TabBar.swift │ └── TextField.swift │ ├── Page.swift │ ├── PageBuilder.swift │ ├── PageDescription.swift │ ├── PageElement.swift │ ├── PageElementIdentifier.swift │ ├── PageExistsResults.swift │ ├── QueryIdentifier.swift │ └── TestData.swift └── Tests ├── SisyphosTestApp ├── SisyphosTestapp.xcodeproj │ ├── project.pbxproj │ ├── project.xcworkspace │ │ ├── contents.xcworkspacedata │ │ └── xcshareddata │ │ │ └── IDEWorkspaceChecks.plist │ └── xcshareddata │ │ └── xcschemes │ │ └── SisyphosTestapp.xcscheme ├── SisyphosTestapp │ ├── Assets.xcassets │ │ ├── AccentColor.colorset │ │ │ └── Contents.json │ │ ├── AppIcon.appiconset │ │ │ └── Contents.json │ │ └── Contents.json │ ├── Generated.swift │ ├── Info.plist │ └── SisyphosTestapp.swift ├── SisyphosTests │ ├── AlertTests.swift │ ├── ButtonTests.swift │ ├── CollectionViewTests.swift │ ├── InterruptionTests.swift │ ├── Meta │ │ ├── CommonHelpers.swift │ │ └── XCTestCase+LaunchApp.swift │ ├── NavigationBarTests.swift │ ├── OtherTests.swift │ ├── PageExistsErrorTests.swift │ ├── PageHierarchyTests.swift │ ├── SecureTextFieldTests.swift │ ├── StaticTextTests.swift │ ├── SwitchTests.swift │ ├── TabBarTests.swift │ ├── TestDataTests.swift │ └── TextFieldTests.swift └── TestCodeGenerator.swift └── SisyphosTests ├── Internal └── NSPredicateTests.swift ├── PageBuilderTests.swift └── SisyphosTests.swift /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | DerivedData/ 7 | .swiftpm/config/registries.json 8 | .swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata 9 | .netrc 10 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | 2 | Apache License 3 | Version 2.0, January 2004 4 | http://www.apache.org/licenses/ 5 | 6 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 7 | 8 | 1. Definitions. 9 | 10 | "License" shall mean the terms and conditions for use, reproduction, 11 | and distribution as defined by Sections 1 through 9 of this document. 12 | 13 | "Licensor" shall mean the copyright owner or entity authorized by 14 | the copyright owner that is granting the License. 15 | 16 | "Legal Entity" shall mean the union of the acting entity and all 17 | other entities that control, are controlled by, or are under common 18 | control with that entity. For the purposes of this definition, 19 | "control" means (i) the power, direct or indirect, to cause the 20 | direction or management of such entity, whether by contract or 21 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 22 | outstanding shares, or (iii) beneficial ownership of such entity. 23 | 24 | "You" (or "Your") shall mean an individual or Legal Entity 25 | exercising permissions granted by this License. 26 | 27 | "Source" form shall mean the preferred form for making modifications, 28 | including but not limited to software source code, documentation 29 | source, and configuration files. 30 | 31 | "Object" form shall mean any form resulting from mechanical 32 | transformation or translation of a Source form, including but 33 | not limited to compiled object code, generated documentation, 34 | and conversions to other media types. 35 | 36 | "Work" shall mean the work of authorship, whether in Source or 37 | Object form, made available under the License, as indicated by a 38 | copyright notice that is included in or attached to the work 39 | (an example is provided in the Appendix below). 40 | 41 | "Derivative Works" shall mean any work, whether in Source or Object 42 | form, that is based on (or derived from) the Work and for which the 43 | editorial revisions, annotations, elaborations, or other modifications 44 | represent, as a whole, an original work of authorship. For the purposes 45 | of this License, Derivative Works shall not include works that remain 46 | separable from, or merely link (or bind by name) to the interfaces of, 47 | the Work and Derivative Works thereof. 48 | 49 | "Contribution" shall mean any work of authorship, including 50 | the original version of the Work and any modifications or additions 51 | to that Work or Derivative Works thereof, that is intentionally 52 | submitted to Licensor for inclusion in the Work by the copyright owner 53 | or by an individual or Legal Entity authorized to submit on behalf of 54 | the copyright owner. For the purposes of this definition, "submitted" 55 | means any form of electronic, verbal, or written communication sent 56 | to the Licensor or its representatives, including but not limited to 57 | communication on electronic mailing lists, source code control systems, 58 | and issue tracking systems that are managed by, or on behalf of, the 59 | Licensor for the purpose of discussing and improving the Work, but 60 | excluding communication that is conspicuously marked or otherwise 61 | designated in writing by the copyright owner as "Not a Contribution." 62 | 63 | "Contributor" shall mean Licensor and any individual or Legal Entity 64 | on behalf of whom a Contribution has been received by Licensor and 65 | subsequently incorporated within the Work. 66 | 67 | 2. Grant of Copyright License. Subject to the terms and conditions of 68 | this License, each Contributor hereby grants to You a perpetual, 69 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 70 | copyright license to reproduce, prepare Derivative Works of, 71 | publicly display, publicly perform, sublicense, and distribute the 72 | Work and such Derivative Works in Source or Object form. 73 | 74 | 3. Grant of Patent License. Subject to the terms and conditions of 75 | this License, each Contributor hereby grants to You a perpetual, 76 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 77 | (except as stated in this section) patent license to make, have made, 78 | use, offer to sell, sell, import, and otherwise transfer the Work, 79 | where such license applies only to those patent claims licensable 80 | by such Contributor that are necessarily infringed by their 81 | Contribution(s) alone or by combination of their Contribution(s) 82 | with the Work to which such Contribution(s) was submitted. If You 83 | institute patent litigation against any entity (including a 84 | cross-claim or counterclaim in a lawsuit) alleging that the Work 85 | or a Contribution incorporated within the Work constitutes direct 86 | or contributory patent infringement, then any patent licenses 87 | granted to You under this License for that Work shall terminate 88 | as of the date such litigation is filed. 89 | 90 | 4. Redistribution. You may reproduce and distribute copies of the 91 | Work or Derivative Works thereof in any medium, with or without 92 | modifications, and in Source or Object form, provided that You 93 | meet the following conditions: 94 | 95 | (a) You must give any other recipients of the Work or 96 | Derivative Works a copy of this License; and 97 | 98 | (b) You must cause any modified files to carry prominent notices 99 | stating that You changed the files; and 100 | 101 | (c) You must retain, in the Source form of any Derivative Works 102 | that You distribute, all copyright, patent, trademark, and 103 | attribution notices from the Source form of the Work, 104 | excluding those notices that do not pertain to any part of 105 | the Derivative Works; and 106 | 107 | (d) If the Work includes a "NOTICE" text file as part of its 108 | distribution, then any Derivative Works that You distribute must 109 | include a readable copy of the attribution notices contained 110 | within such NOTICE file, excluding those notices that do not 111 | pertain to any part of the Derivative Works, in at least one 112 | of the following places: within a NOTICE text file distributed 113 | as part of the Derivative Works; within the Source form or 114 | documentation, if provided along with the Derivative Works; or, 115 | within a display generated by the Derivative Works, if and 116 | wherever such third-party notices normally appear. The contents 117 | of the NOTICE file are for informational purposes only and 118 | do not modify the License. You may add Your own attribution 119 | notices within Derivative Works that You distribute, alongside 120 | or as an addendum to the NOTICE text from the Work, provided 121 | that such additional attribution notices cannot be construed 122 | as modifying the License. 123 | 124 | You may add Your own copyright statement to Your modifications and 125 | may provide additional or different license terms and conditions 126 | for use, reproduction, or distribution of Your modifications, or 127 | for any such Derivative Works as a whole, provided Your use, 128 | reproduction, and distribution of the Work otherwise complies with 129 | the conditions stated in this License. 130 | 131 | 5. Submission of Contributions. Unless You explicitly state otherwise, 132 | any Contribution intentionally submitted for inclusion in the Work 133 | by You to the Licensor shall be under the terms and conditions of 134 | this License, without any additional terms or conditions. 135 | Notwithstanding the above, nothing herein shall supersede or modify 136 | the terms of any separate license agreement you may have executed 137 | with Licensor regarding such Contributions. 138 | 139 | 6. Trademarks. This License does not grant permission to use the trade 140 | names, trademarks, service marks, or product names of the Licensor, 141 | except as required for reasonable and customary use in describing the 142 | origin of the Work and reproducing the content of the NOTICE file. 143 | 144 | 7. Disclaimer of Warranty. Unless required by applicable law or 145 | agreed to in writing, Licensor provides the Work (and each 146 | Contributor provides its Contributions) on an "AS IS" BASIS, 147 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 148 | implied, including, without limitation, any warranties or conditions 149 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 150 | PARTICULAR PURPOSE. You are solely responsible for determining the 151 | appropriateness of using or redistributing the Work and assume any 152 | risks associated with Your exercise of permissions under this License. 153 | 154 | 8. Limitation of Liability. In no event and under no legal theory, 155 | whether in tort (including negligence), contract, or otherwise, 156 | unless required by applicable law (such as deliberate and grossly 157 | negligent acts) or agreed to in writing, shall any Contributor be 158 | liable to You for damages, including any direct, indirect, special, 159 | incidental, or consequential damages of any character arising as a 160 | result of this License or out of the use or inability to use the 161 | Work (including but not limited to damages for loss of goodwill, 162 | work stoppage, computer failure or malfunction, or any and all 163 | other commercial damages or losses), even if such Contributor 164 | has been advised of the possibility of such damages. 165 | 166 | 9. Accepting Warranty or Additional Liability. While redistributing 167 | the Work or Derivative Works thereof, You may choose to offer, 168 | and charge a fee for, acceptance of support, warranty, indemnity, 169 | or other liability obligations and/or rights consistent with this 170 | License. However, in accepting such obligations, You may act only 171 | on Your own behalf and on Your sole responsibility, not on behalf 172 | of any other Contributor, and only if You agree to indemnify, 173 | defend, and hold each Contributor harmless for any liability 174 | incurred by, or claims asserted against, such Contributor by reason 175 | of your accepting any such warranty or additional liability. 176 | 177 | END OF TERMS AND CONDITIONS 178 | 179 | APPENDIX: How to apply the Apache License to your work. 180 | 181 | To apply the Apache License to your work, attach the following 182 | boilerplate notice, with the fields enclosed by brackets "[]" 183 | replaced with your own identifying information. (Don't include 184 | the brackets!) The text should be enclosed in the appropriate 185 | comment syntax for the file format. We also recommend that a 186 | file or class name and description of purpose be included on the 187 | same "printed page" as the copyright notice for easier 188 | identification within third-party archives. 189 | 190 | Copyright [yyyy] [name of copyright owner] 191 | 192 | Licensed under the Apache License, Version 2.0 (the "License"); 193 | you may not use this file except in compliance with the License. 194 | You may obtain a copy of the License at 195 | 196 | http://www.apache.org/licenses/LICENSE-2.0 197 | 198 | Unless required by applicable law or agreed to in writing, software 199 | distributed under the License is distributed on an "AS IS" BASIS, 200 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 201 | See the License for the specific language governing permissions and 202 | limitations under the License. -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version: 5.6 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "Sisyphos", 8 | platforms: [ 9 | .macOS(.v12), 10 | .iOS(.v14), 11 | ], 12 | products: [ 13 | .library(name: "Sisyphos", targets: ["Sisyphos"]), 14 | ], 15 | dependencies: [], 16 | targets: [ 17 | .target( 18 | name: "Sisyphos", 19 | dependencies: [] 20 | ), 21 | .testTarget( 22 | name: "SisyphosTests", 23 | dependencies: [ 24 | "Sisyphos" 25 | ] 26 | ), 27 | ] 28 | ) 29 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Sisyphos - Declarative end-to-end and UI testing for iOS and macOS 2 | 3 | ![](https://img.shields.io/badge/platform-iOS%20%7C%20macOS-lightgrey) 4 | ![](https://img.shields.io/badge/Swift-5.7%20|%205.6%20|%205.7%20|%205.8%20|%205.9-brightgreen) 5 | 6 | Sisyphos uses a declarative syntax, 7 | so you can simply state how the user interface of your app under test should look like. 8 | Your code is simpler and easier to read than ever before, saving you time and maintenance. 9 | 10 | There's no need for manually tweaking sleeps or timeouts. 11 | And no need to write imperative code to wait for elements to appear. 12 | Your tests will wait automatically exactly as long as needed to have all elements on the screen. 13 | As soon as the elements appear, the tests will interact with them and continue. 14 | 15 | Sisyphos builds on top of Apple's [XCTest framework](https://developer.apple.com/documentation/xctest/user_interface_tests). 16 | Therefore, no extra software or tooling is needed and the risk of breaking with new Xcode or 17 | Swift versions is limited. 18 | 19 | ## Example 20 | 21 | Let's say you want to write a test for the [Landmarks app](https://developer.apple.com/tutorials/swiftui/composing-complex-interfaces). 22 | 23 | You want to test that it shows the correct featured lakes and that after tapping on one of the lakes, it will navigate 24 | to the correct page. 25 | 26 | | | | 27 | |--------------------------------------------------------|-----------------------------------------------------------------| 28 | 29 | ```swift 30 | import XCTest 31 | import Sisyphos 32 | 33 | final class LandmarksUITests: XCTestCase { 34 | 35 | func testFeaturedNavigation() { 36 | let app = XCUIApplication() 37 | app.launch() 38 | 39 | let homepage = Homepage() 40 | homepage.waitForExistence() 41 | homepage.silverSalmonCreekLake.tap() 42 | 43 | let silverSalmonCreek = SilverSalmonCreekPage() 44 | silverSalmonCreek.waitForExistence() 45 | } 46 | } 47 | 48 | struct Homepage: Page { 49 | 50 | let silverSalmonCreekLake = Button(label: "Silver Salmon Creek") 51 | 52 | var body: PageDescription { 53 | NavigationBar(identifier: "Featured") { 54 | StaticText("Featured") 55 | } 56 | CollectionView { 57 | Cell {} 58 | Cell { 59 | StaticText("Lakes") 60 | silverSalmonCreekLake 61 | Button(label: "St. Mary Lake") 62 | Button(label: "Twin Lake") 63 | Button(label: "Rainbow Lake") 64 | Button(label: "Hidden Lake") 65 | Button(label: "Lake Umbagog") 66 | } 67 | Cell { 68 | StaticText("Mountains") 69 | Button(label: "Chilkoot Trail") 70 | Button(label: "Lake McDonald") 71 | Button(label: "Icy Bay") 72 | } 73 | } 74 | TabBar { 75 | Button(label: "Featured") 76 | Button(label: "List") 77 | } 78 | } 79 | } 80 | 81 | struct SilverSalmonCreekPage: Page { 82 | var body: PageDescription { 83 | NavigationBar(identifier: "Silver Salmon Creek") { 84 | Button(label: "Featured") 85 | StaticText("Silver Salmon Creek") 86 | } 87 | StaticText("Silver Salmon Creek") 88 | Button(label: "Toggle Favorite") 89 | StaticText("Lake Clark National Park and Preserve") 90 | StaticText("Alaska") 91 | StaticText("About Silver Salmon Creek") 92 | TabBar { 93 | Button(label: "Featured") 94 | Button(label: "List") 95 | } 96 | } 97 | } 98 | ``` 99 | 100 | Please see [Sisyphos' documentation](https://sisyphostests.github.io/documentation/sisyphos/) for further reference on 101 | how to write tests with Sisyphos. 102 | 103 | ## Code generation 104 | 105 | Instead of building the descriptions of the screens yourself, Sisyphos can automatically generate the code for you. 106 | Call `startCodeGeneration()` inside of an `XCTestCase`. Sisyphos will then record any new screen 107 | which will appear and add the screens' source code at the end of the file while you manually browse through the app. 108 | 109 | ![](./Sources/Sisyphos/Documentation.docc/Resources/codegeneration.gif) 110 | 111 | ## Integrating Sisyphos to your project 112 | 113 | Sisyphos is distributed via the [Swift Package Manager](https://www.swift.org/getting-started/#using-the-package-manager) 114 | with the repository URL `https://github.com/SisyphosTests/sisyphos`. 115 | 116 | Please refer to [Apple's documentation](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app) 117 | on how to add a Swift package dependency to your app. 118 | 119 | ⚠️ Please make sure to add the `Sisyphos` library to your app's UI test target, not the app target itself! If you see 120 | errors such as the errors below when building the app, then you added the Sisyphos library to the wrong target. 121 | 122 | -------------------------------------------------------------------------------- /Sources/Sisyphos/DefaultAlert.swift: -------------------------------------------------------------------------------- 1 | /// A page to interact with other iOS alerts like the App Tracking Transparency prompt. 2 | public struct DefaultAlert: Page { 3 | public let application: String = "com.apple.springboard" 4 | 5 | /// The text which is displayed in the alert. 6 | public let textOfAlert: String? 7 | 8 | /// The button to dismiss the alert by declining. 9 | public let disallowButton = Button() 10 | /// The button to dismiss the alert by accepting. 11 | public let allowButton = Button() 12 | 13 | /// - Parameter textOfAlert: The text which is displayed in the alert. If you provide a text, then the alert needs 14 | /// to match the text. If you don't provide any text, then any alert is matched. 15 | public init(textOfAlert: String? = nil) { 16 | self.textOfAlert = textOfAlert 17 | } 18 | 19 | public var body: PageDescription { 20 | Alert { 21 | if let textOfAlert { 22 | StaticText(textOfAlert) 23 | } 24 | disallowButton 25 | allowButton 26 | } 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Sisyphos/DefaultPermissionAlert.swift: -------------------------------------------------------------------------------- 1 | /// A page to interact with iOS' permission dialogue. 2 | public struct DefaultPermissionAlert: Page { 3 | public let application: String = "com.apple.springboard" 4 | 5 | /// The text which is displayed in the permission dialogue. 6 | public let permissionText: String? 7 | 8 | /// The button do decline the permission. 9 | public let disallowButton = Button(label: "Don’t Allow".localizedForSimulator) 10 | /// The button to allow the permission. 11 | public let allowButton = Button() 12 | 13 | /// - Parameter permissionText: 14 | /// The text which is displayed in the permission dialogue. If you provide a text, then the permission dialogue 15 | /// needs to match the text. If you don't provide any text, then any permission dialogue is matched. 16 | public init(permissionText: String? = nil) { 17 | self.permissionText = permissionText 18 | } 19 | 20 | public var body: PageDescription { 21 | Alert { 22 | if let permissionText { 23 | StaticText(permissionText) 24 | } 25 | disallowButton 26 | allowButton 27 | } 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Documentation.md: -------------------------------------------------------------------------------- 1 | # ``Sisyphos`` 2 | 3 | Declarative end-to-end and UI testing for iOS and macOS 4 | 5 | ## Which problem does Sisyphos solve? 6 | 7 | Sisyphos enables you to write user interface tests in a declarative way and even with the support of automatic code 8 | generation based on the user interface of your running app. 9 | You don't need to write lots of imperative code to match elements or to validate screens. 10 | It significantly reduces the time that is needed for writing and maintaing user interface tests. 11 | Looking at tests written with Sisyphos will give you a precise idea how the user interface of the app looks like. 12 | 13 | ## Overview 14 | 15 | Sisyphos uses a declarative syntax, 16 | so you can simply state how the user interface of your app under test should look like. 17 | Your code is simpler and easier to read than ever before, saving you time and maintenance. 18 | 19 | There's no need for manually tweaking sleeps or timeouts. 20 | And no need to write imperative code to wait for elements to appear. 21 | Your tests will wait automatically exactly as long as needed to have all elements on the screen. 22 | As soon as the elements appear, the tests will interact with them and continue. 23 | 24 | Sisyphos builds on top of Apple's [XCTest framework](https://developer.apple.com/documentation/xctest/user_interface_tests). 25 | Therefore, no extra software or tooling is needed and the risk of breaking with new Xcode or 26 | Swift versions is limited. 27 | 28 | ## Contributing, Bugs, and Feature Requests 29 | 30 | Sisyphos is an open-source project licensed under the Apache-2.0 license. 31 | Please report any bugs or feature requests at its [GitHub project](https://github.com/SisyphosTests/Sisyphos). 32 | 33 | ## Topics 34 | 35 | ### Essentials 36 | 37 | - 38 | - 39 | - 40 | 41 | ### Page Elements 42 | 43 | - ``Alert`` 44 | - ``Button`` 45 | - ``Cell`` 46 | - ``CollectionView`` 47 | - ``NavigationBar`` 48 | - ``SecureTextField`` 49 | - ``StaticText`` 50 | - ``Switch`` 51 | - ``TabBar`` 52 | - ``TextField`` 53 | 54 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Integration.md: -------------------------------------------------------------------------------- 1 | # Integrating Sisyphos in your tests 2 | 3 | Learn how to add Sisyphos to your app's UI tests. 4 | 5 | ## Overview 6 | 7 | Sisyphos is distributed via the [Swift Package Manager](https://www.swift.org/getting-started/#using-the-package-manager) 8 | with the repository URL `https://github.com/SisyphosTests/sisyphos`. 9 | 10 | Please refer to [Apple's documentation](https://developer.apple.com/documentation/xcode/adding-package-dependencies-to-your-app) 11 | on how to add a Swift package dependency to your app. 12 | 13 | > Warning: Please make sure to add the `Sisyphos` library to your app's UI test target, not the app target itself! 14 | > 15 | > If you see errors such as the errors below when building the app, then you added the Sisyphos library to the wrong target. 16 | > ![](integration-wrong-target.png) 17 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Interruptions.md: -------------------------------------------------------------------------------- 1 | # Handling Interruptions 2 | 3 | Learn how to handle interruptions that can happen unexpectedly or at any time, such as alerts and permission dialogues. 4 | 5 | ## Registering interruption handlers 6 | 7 | Sisyphos has the philosophy that you always describe what to expect upfront. 8 | You usually don't react on things happening in your tested app. Instead, you describe what you expect that happens and 9 | Sisyphos is validating that the app does like expected. 10 | 11 | However, there are sometimes UI elements which will interrupt the user interface and you are not in control when or 12 | where these elements will appear. 13 | 14 | For such situations, Sisyphos provides the possibility to register interruption handlers. 15 | You can describe the interruption that appears with a page, similar to what you do in regular tests. 16 | 17 | Let's say we want to react on an alert that has the message `There was an error` and a button that says `OK`. 18 | We can describe the alert with the following page: 19 | 20 | ```swift 21 | struct ErrorAlert: Page { 22 | let button: Button(label: "OK") 23 | var body: PageDescription { 24 | Alert { 25 | StaticText("There was an error") 26 | button 27 | } 28 | } 29 | } 30 | ``` 31 | 32 | Then, in the test, you register an interruption monitor that presses on the button whenever the alert is currently 33 | displayed and interrupting the user interface because of this. 34 | 35 | ```swift 36 | addInterruptionMonitor(page: ErrorAlert()) { page in 37 | page.button.tap() 38 | } 39 | ``` 40 | 41 | See the ``XCTest/XCTestCase/addUIInterruptionMonitor(page:handler:)`` method for more details. 42 | 43 | ## Unregistering interruption handlers 44 | 45 | A Sisyphos interruption monitor only handles interruptions in the test case in which it was registered. It will not 46 | handle interruptions that are called in other test cases. 47 | 48 | Yet, sometimes you want to have even more precise control and only handle interruptions that happen in selected parts of 49 | a test case. 50 | 51 | To achieve this, you can unregister any interruption monitor that was previously registered via the 52 | ``XCTest/XCTestCase/removeUIInterruptionMonitor(_:)`` method. 53 | 54 | 55 | ## Provided Pages for iOS Permission interruptions 56 | 57 | Sisyphos comes with page implementations that enable you to write tests for permission flows on iOS. 58 | You can use this pages to add interruption handlers that can handle permission dialogues. 59 | 60 | * ``DefaultPermissionAlert`` A page that can describe permission dialogues on iOS. 61 | * ``DefaultAlert``: A page that can describe certain iOS permission alerts such as App Tracking Transparency prompt. 62 | 63 | ## Removing Default Interruption Handlers 64 | 65 | By default, XCTest will handle interruptions such as permission dialogues automatically. 66 | This is done with implicit interruption handlers that take care of the most common interruptions for you. 67 | On iOS, XCTest handles interrupting elements if they have a cancel button or a default button. 68 | Starting from Xcode 12, it also implicitly handles Banner notifications. 69 | The details are explained in a [WWDC 2020 video](https://developer.apple.com/videos/play/wwdc2020/10220/?time=209). 70 | 71 | If you want to test permission flows, then this implicit handling will interfere with your tests, 72 | which ultimatively makes it impossible to write such tests. 73 | 74 | Therefore, Sisyphos extends the ``XCTest/XCTestCase`` class 75 | and provides the ``XCTest/XCTestCase/disableDefaultXCUIInterruptionHandlers()`` method. 76 | Calling this method will disable the implicit handlers, so you can write tests that test permission flows. 77 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Resources/codegeneration.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SisyphosTests/Sisyphos/416dd431d45f619b45422d280409a7039a4893f8/Sources/Sisyphos/Documentation.docc/Resources/codegeneration.gif -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Resources/integration-wrong-target~dark.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SisyphosTests/Sisyphos/416dd431d45f619b45422d280409a7039a4893f8/Sources/Sisyphos/Documentation.docc/Resources/integration-wrong-target~dark.png -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Resources/integration-wrong-target~light.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SisyphosTests/Sisyphos/416dd431d45f619b45422d280409a7039a4893f8/Sources/Sisyphos/Documentation.docc/Resources/integration-wrong-target~light.png -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Resources/landsmark-app-homepage@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SisyphosTests/Sisyphos/416dd431d45f619b45422d280409a7039a4893f8/Sources/Sisyphos/Documentation.docc/Resources/landsmark-app-homepage@3x.png -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/Resources/landsmark-app-silversalmoncreek@3x.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/SisyphosTests/Sisyphos/416dd431d45f619b45422d280409a7039a4893f8/Sources/Sisyphos/Documentation.docc/Resources/landsmark-app-silversalmoncreek@3x.png -------------------------------------------------------------------------------- /Sources/Sisyphos/Documentation.docc/WritingTests.md: -------------------------------------------------------------------------------- 1 | # Writing Tests with Sisyphos 2 | 3 | A short introduction into how to describe user interfaces and how to write tests with Sisyphos. 4 | 5 | 6 | ## The basic building block: Pages 7 | 8 | Pages are the basic building blocks in Sisyphos. A ``Page`` describes the user interface which you expect in your tests. 9 | You describe pages with a syntax that is very familiar if you are used to SwiftUI: 10 | 11 | ```swift 12 | struct ExamplePage: Page { 13 | var body: PageDescription { 14 | NavigationBar { 15 | StaticText("Title in the Nav Bar") 16 | } 17 | } 18 | } 19 | ``` 20 | 21 | This page describes that you are expecting a screen with a navigation bar. Inside the Navigation bar, there should be 22 | the title _Title in the Nav Bar_. 23 | 24 | The name of the page elements are the capitalized versions of 25 | [XCUIElement.ElementType](https://developer.apple.com/documentation/xctest/xcuielement/elementtype). 26 | Sisyphos doesn't implement all element types which are available for `XCUIElements`. 27 | Instead, it only implements a subset of elements. 28 | You can check the ``PageElement`` protocol to see all elements which are available in Sisyphos. 29 | 30 | After you have defined your page, you usually want to use it in your tests to interact with its elements 31 | and to validate that your app behaves correctly and displays the correct information. 32 | There are two ways to check if a page exists: 33 | 34 | 1. Calling its ``Sisyphos/Page/waitForExistence(timeout:file:line:)`` method. 35 | This method will check if all the page's elements exist and are in the expected state. 36 | If not, it will wait until the timeout is reached and periodically check if the page exists. 37 | As soon as page exists, the method returns. 38 | This means that your tests will only take as long as needed to have all the elements visible 39 | and will execute as fast as possible. 40 | If the page doesn't exist after the timeout, then this method will automatically fail the currently running test. 41 | 2. Using its ``Page/exists()`` method. 42 | This method will check if the page exists and return immediately with the results. 43 | If the page doesn't exist, it will tell you in the ``PageExistsResults/missingElements`` property which elements 44 | weren't found. 45 | A non-existing page will not automatically fail your test. 46 | 47 | In a test, where you want to validate that a page exists and all its elements are in the expected state, 48 | you should usually use its ``Page/waitForExistence(timeout:file:line:)`` method. 49 | 50 | If you want to interact with a page's element, then you need to put it in a property on the page. 51 | E.g. if you want to tap on a button which is in the nav bar: 52 | 53 | ```swift 54 | struct ExamplePage: Page { 55 | 56 | let buttonInNavBar = Button() 57 | 58 | var body: PageDescription { 59 | NavigationBar { 60 | StaticText("Title in the Nav Bar") 61 | buttonInNavBar 62 | } 63 | } 64 | } 65 | 66 | final class Tests: XCTestCase { 67 | func testApp() { 68 | let app = XCUIApplication() 69 | app.launch() 70 | 71 | let page = ExamplePage() 72 | page.waitForExistence() 73 | page.buttonInNavBar.tap() 74 | } 75 | } 76 | ``` 77 | 78 | > Important: Always call a page's `waitForExistence()` method before you interact with its elements. 79 | 80 | ## Pages can describe other apps than the target app 81 | 82 | By default, a page describes screens of the application which is configured as the tests' target application. 83 | But you can also interact with screens of other applications, e.g. the settings app in iOS. 84 | 85 | All pages need to provide an `application` parameter which is the bundle identifier of the app which should be tested. 86 | By default, this bundle identifier is empty, therefore Sisyphos will use the application which is configured as the 87 | tests' target application in the tests target configuration. 88 | If you set this to a bundle identifier of another application, then Sisyphos will use this bundle identifier and check 89 | the elements of the app with this bundle identifier. 90 | 91 | ``` 92 | struct Preferences: Page { 93 | 94 | let application = "com.apple.Preferences" 95 | 96 | var body: PageDescription { 97 | NavigationBar {} 98 | } 99 | } 100 | ``` 101 | 102 | ## General principles for building pages 103 | 104 | Now that you learned what a _Page_ is in Sisyphos, here are some of the basic principles which will help you to work 105 | with pages. 106 | 107 | > Info: Why do we call it _pages_? We don't test websites. Or even books. Shouldn't we call this _screens_? 108 | > The name `Page` is influenced by the [page object pattern](https://martinfowler.com/bliki/PageObject.html). 109 | 110 | First of all, when describing pages, you should describe the page like it's visible for the user. 111 | Start from the upper left and go to the lower right. 112 | 113 | ### You can omit elements in which you are not interested 114 | 115 | You don't need to add every element which is visible on the screen. 116 | It's perfectly fine to omit elements. 117 | You only need to add the elements which are relevant and which should make a test fail if they are not present or 118 | if they have the wrong contents. 119 | 120 | ### The order and hierarchy of elements is important 121 | 122 | Although you can skip elements, the relative order of elements which are expected in your page is important. 123 | For example, if you have the following screen: 124 | 125 | ```swift 126 | struct ExamplePage: Page { 127 | var body: PageDescription { 128 | StaticText("First Name") 129 | TextField() 130 | } 131 | } 132 | ``` 133 | 134 | In this example, it's important that the text field actually appears after the static text - which means that the text 135 | should be above the text field. If the text is below the text field, then the tests will fail because the page will 136 | never match with the screen contents. 137 | 138 | You can utilize this to match elements which would be hard to match otherwise. For example, in the following page the 139 | text fields wouldn't be distinguishable as they neither have an accessibility identifier nor any other attribute. 140 | 141 | ```swift 142 | struct ExamplePage: Page { 143 | let firstNameField = TextField() 144 | 145 | let lastNameField = TextField() 146 | 147 | var body: PageDescription { 148 | StaticText("First Name") 149 | firstNameField 150 | StaticText("Last Name") 151 | lastNameField 152 | } 153 | } 154 | ``` 155 | 156 | In a regular XCUITest without Sisyphos, you would need to go via the text fields' indices which will make the test very 157 | fragile if the order of elements change. 158 | 159 | With Sisyphos, you have an easy way to describe the relations between the position of elements. 160 | 161 | ### You don't need to describe all ancestors of an element 162 | 163 | The hierarchy of elements can be deeply nested. It's not uncommon for elements to have 5-10 ancestors. 164 | In Sisyphos, you can omit arbitrary ancestors and the element matching will work nevertheless. 165 | This makes the tests very maintainable as usually the ancestors of an element change quite a lot, but the element and 166 | its contents are stable. 167 | 168 | 169 | ## Code Generation 170 | 171 | Instead of building the descriptions of the screens yourself, Sisyphos can automatically generate the code for you. 172 | Call `startCodeGeneration()` inside of an `XCTestCase`. Sisyphos will then record any new screen 173 | which will appear and add the screens' source code at the end of the file while you manually browse through the app. 174 | 175 | ```swift 176 | import XCTest 177 | import Sisyphos 178 | 179 | class UITests: XCTestCase { 180 | 181 | func testNew() { 182 | let app = XCUIApplication() 183 | app.launch() 184 | 185 | startCodeGeneration() 186 | } 187 | 188 | } 189 | ``` 190 | 191 | ![](codegeneration) 192 | 193 | 194 | > Info: The Code generation will add the generated code at the end of the file from which you call 195 | > `startCodeGeneration()`. This will only work when running the code generation on a simulator. 196 | > When running on a real device, the code generation cannot access the source file. 197 | 198 | ## Extracting Test Data 199 | 200 | It's best practices to expect screen contents upfront and to not react dynamically on the things that are 201 | happening inside your app. However, sometimes you need to extract data which you cannot predict upfront because 202 | it's created dynamically as a side effect or your actual test, and you need to use the data for further steps or 203 | validations in your tests. 204 | 205 | For situations like this, Sisyphos provides the ``TestData`` property wrapper. 206 | You can use it to extract contents out of static texts or the labels of elements. 207 | Simply use it on a string property on your page. 208 | Then, wherever you want to extract the text, use the property in a string interpolation. 209 | 210 | ```swift 211 | struct ExamplePage: Page { 212 | 213 | @TestData var invoiceNumber: String 214 | 215 | struct PurchaseLastStep: Page { 216 | var body: PageDescription { 217 | NavigationBar { 218 | StaticText("Success") 219 | } 220 | 221 | StaticText("Invoice \(invoiceNumber)") 222 | StaticText("Thank you for your purchase!") 223 | } 224 | } 225 | } 226 | 227 | final class PurchaseTests: XCTestCase { 228 | func testPurchase() { 229 | // .... 230 | let page = PurchaseLastStep() 231 | page.waitForExistence() 232 | 233 | // The invoiceNumber property is now available and you can use it in the test. 234 | // It will contain the invoice number which was extracted from the page. 235 | print(page.invoiceNumber) 236 | } 237 | } 238 | ``` 239 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/NSPredicate+Description.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | extension NSPredicate { 5 | convenience init(snapshot: XCUIElementSnapshot) { 6 | assert(Thread.isMainThread) 7 | 8 | self.init(block: { element, _ in 9 | guard let elementSnapshot = element as? XCUIElementSnapshot else { return false } 10 | return snapshot.matches(snapshot: elementSnapshot) 11 | }) 12 | 13 | snapshotByPredicate.setObject(snapshot, forKey: self) 14 | } 15 | } 16 | 17 | /// A mapping where the key is a predicate that was created by Sisyphos and the value is the corresponding element 18 | /// snapshot that is used for matching in the predicate. This is needed so we can provide useful output in the test 19 | /// reports. 20 | private var snapshotByPredicate: NSMapTable = NSMapTable.weakToStrongObjects() 21 | 22 | 23 | extension NSPredicate { 24 | /// When we use a predicate in an element query to match elements, the `description` of the predicate is used in the 25 | /// test reports. Unfortunately, the description isn't really helpful and the test report looks like 26 | /// `Find: Elements matching predicate 'BLOCKPREDICATE(0x600000ceb750)'`. That's why we implement our own 27 | /// description that gives important information. 28 | /// 29 | /// The default way to implement our own helpful description would be to create a subclass of NSPredicate that 30 | /// overrides the description. While in theory it's possible to create subclasses of NSPredicate, in practice it's 31 | /// not really possible. NSPredicate is a class cluster and XCTest uses Apple's knowledge of implementation details 32 | /// to differentiate between the different predicate types in element queries. So if you pass your own NSPredicate 33 | /// subclass in an element query, then XCTest will throw an assertion. 34 | /// 35 | /// Because of that, we use Swift's dynamic method replacement to replace the description of NSPredicate. If the 36 | /// predicate is created by Sisyphos, then we provide helpful output in the test reports that look like 37 | /// `Find: Elements matching predicate '{element=textField, identifier="some identifier"}'`. If the predicate is 38 | /// not created by Sisyphos, then it falls back to the default description of NSPredicate. 39 | @_dynamicReplacement(for: description) 40 | var sisyphosDescription: String { 41 | guard let snapshot = snapshotByPredicate.object(forKey: self) else { return super.description } 42 | var attributes: [String] = [ 43 | "element=\(snapshot.elementType)", 44 | ] 45 | if !snapshot.identifier.isEmpty { 46 | attributes.append("identifier=\(snapshot.identifier.debugDescription)") 47 | } 48 | if !snapshot.label.isEmpty { 49 | attributes.append("label=\(snapshot.label.debugDescription)") 50 | } 51 | if let value = snapshot.value as? String { 52 | attributes.append("value=\(value.debugDescription)") 53 | } 54 | 55 | return "{\(attributes.joined(separator: ", "))}" 56 | } 57 | } 58 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/String+Localized.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | extension String { 5 | 6 | /// Returns the string localized in the locale of the simulator. This is handy for translating standard UIKit 7 | /// elements or the keyboard. 8 | var localizedForSimulator: String { 9 | Bundle.systemFramework.localizedString(forKey: self, value: "NO TRANSLATION FOUND for \(self)", table: nil) 10 | } 11 | } 12 | 13 | 14 | #if canImport(UIKit) 15 | import UIKit 16 | 17 | private extension Bundle { 18 | 19 | static var systemFramework: Bundle { 20 | Bundle(for: UIButton.self) 21 | } 22 | 23 | } 24 | #elseif canImport(AppKit) 25 | import AppKit 26 | 27 | private extension Bundle { 28 | 29 | static var systemFramework: Bundle { 30 | Bundle(for: NSButton.self) 31 | } 32 | 33 | } 34 | #endif 35 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/XCUIApplication+CurrentPage.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | public extension XCUIApplication { 5 | var currentPage: PageDescription? { 6 | guard let snapshot = try? snapshot() else { return nil } 7 | return snapshot.toPage() 8 | } 9 | } 10 | 11 | 12 | extension XCUIElementSnapshot { 13 | func toPage() -> PageDescription { 14 | PageDescription(elements: flatten(element: self)) 15 | } 16 | } 17 | 18 | 19 | private func extract(element: XCUIElementSnapshot) -> PageElement? { 20 | switch element.elementType { 21 | case .navigationBar: 22 | return NavigationBar( 23 | identifier: element.identifier, 24 | elements: element.children.flatMap(flatten(element:)) 25 | ) 26 | case .staticText: 27 | return StaticText( 28 | identifier: element.identifier, 29 | element.label 30 | ) 31 | case .button: 32 | return Button( 33 | identifier: element.identifier, 34 | label: element.label 35 | ) 36 | case .switch: 37 | return Switch( 38 | identifier: element.identifier, 39 | label: element.label 40 | ) 41 | case .tabBar: 42 | return TabBar( 43 | elements: element.children.flatMap(flatten(element:)) 44 | ) 45 | case .collectionView: 46 | return CollectionView( 47 | elements: element.children.flatMap(flatten(element:)) 48 | ) 49 | case .cell: 50 | return Cell( 51 | identifier: element.identifier, 52 | elements: element.children.flatMap(flatten(element:)) 53 | ) 54 | case .textField: 55 | return TextField( 56 | identifier: element.identifier, 57 | value: element.value as? String 58 | ) 59 | case .secureTextField: 60 | return SecureTextField( 61 | identifier: element.identifier, 62 | value: element.value as? String 63 | ) 64 | // Usually, there are a lot of `Other` elements which produce a lot of visual noise. 65 | // Because of that, we only include them if they have either a label or an identifier. 66 | case .other where !element.label.isEmpty || !element.identifier.isEmpty: 67 | return Other( 68 | identifier: element.identifier, 69 | label: element.label, 70 | elements: element.children.flatMap(flatten(element:)) 71 | ) 72 | default: 73 | return nil 74 | } 75 | } 76 | 77 | private func flatten(element: XCUIElementSnapshot) -> [PageElement] { 78 | if let extracted = extract(element: element) { 79 | return [extracted] 80 | } 81 | return element.children.flatMap(flatten(element:)) 82 | } 83 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/XCUIElement+Extensions.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | extension XCUIElement { 5 | 6 | /// Waits until the element doesn't move on the screen anymore (e.g. because of an ongoing animation). This method 7 | /// will wait at most until the timeout. If the element doesn't have a stable position after the timeout, this 8 | /// method will return nevertheless. 9 | /// 10 | /// - Parameter timeout: The maximal time to wait until the element is not moving anymore in seconds. 11 | func waitUntilStablePosition(timeout: CFTimeInterval = 2) { 12 | XCTContext.runActivity(named: "Wait for element to have a stable position") { activity in 13 | let timeout = Date(timeIntervalSinceNow: timeout) 14 | var lastPosition: CGRect = getFrame() 15 | repeat { 16 | let newFrame = getFrame() 17 | guard lastPosition != newFrame else { return } 18 | lastPosition = newFrame 19 | _ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 0.1)) 20 | } while Date() < timeout 21 | } 22 | } 23 | 24 | func getFrame() -> CGRect { 25 | guard self.exists else { return .zero } 26 | 27 | return frame 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/XCUIElementSnapshot+Matching.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | extension XCUIElementSnapshot { 5 | 6 | /// Checks if the given element matches this element snapshot, so the element is actually the element from which 7 | /// this snapshot was created. 8 | func matches(element: XCUIElement) -> Bool { 9 | // The private Apple API crashes if elements have different types. That's why we check upfront. 10 | guard element.elementType == self.elementType else { return false } 11 | // It's not expressed in the type system, but all of Apple's implementations of `XCUIElementSnapshot` are 12 | // actually NSObjects. 13 | guard 14 | let selfObject = self as? NSObject, 15 | let elementObject = element as? NSObject 16 | else { 17 | return false 18 | } 19 | 20 | return selfObject.matches(element: elementObject) 21 | } 22 | 23 | /// Checks if the given snapshot matches this element snapshot, so both snapshots are snapshots of the same element. 24 | func matches(snapshot: XCUIElementSnapshot) -> Bool { 25 | // The private Apple API crashes if elements have different types. That's why we check upfront. 26 | guard snapshot.elementType == self.elementType else { return false } 27 | // It's not expressed in the type system, but all of Apple's implementations of `XCUIElementSnapshot` are 28 | // actually NSObjects. 29 | guard 30 | let selfObject = self as? NSObject, 31 | let elementObject = snapshot as? NSObject 32 | else { 33 | return false 34 | } 35 | 36 | return selfObject.matches(element: elementObject) 37 | } 38 | } 39 | 40 | 41 | private extension NSObject { 42 | func matches(element: NSObject) -> Bool { 43 | typealias MethodType = @convention(c) (NSObject, Selector, NSObject) -> Bool 44 | let selector = Selector("_matchesElement:") 45 | let methodImplementation = unsafeBitCast(method(for: selector), to: MethodType.self) 46 | return methodImplementation(self, selector, element) 47 | } 48 | } 49 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/XCUIElementType+BetterCustomDebugString.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | /// By default, `XCUIElement.ElementType` doesn't have a helpful debug string. It will always print 5 | /// `__C.XCUIElementType` for every element type which gives no idea of which type the element is. This implementation 6 | /// will print the actual element type, e.g. `.staticText` or `.other`. 7 | extension XCUIElement.ElementType: CustomDebugStringConvertible { 8 | public var debugDescription: String { 9 | guard let elementType = ElementType(self) else { return "XCUIElement.ElementType(rawValue:\(rawValue))" } 10 | return String(describing: elementType) 11 | } 12 | } 13 | 14 | 15 | private enum ElementType: UInt, Codable, Hashable { 16 | 17 | case any = 0 18 | 19 | case other = 1 20 | 21 | case application = 2 22 | 23 | case group = 3 24 | 25 | case window = 4 26 | 27 | case sheet = 5 28 | 29 | case drawer = 6 30 | 31 | case alert = 7 32 | 33 | case dialog = 8 34 | 35 | case button = 9 36 | 37 | case radioButton = 10 38 | 39 | case radioGroup = 11 40 | 41 | case checkBox = 12 42 | 43 | case disclosureTriangle = 13 44 | 45 | case popUpButton = 14 46 | 47 | case comboBox = 15 48 | 49 | case menuButton = 16 50 | 51 | case toolbarButton = 17 52 | 53 | case popover = 18 54 | 55 | case keyboard = 19 56 | 57 | case key = 20 58 | 59 | case navigationBar = 21 60 | 61 | case tabBar = 22 62 | 63 | case tabGroup = 23 64 | 65 | case toolbar = 24 66 | 67 | case statusBar = 25 68 | 69 | case table = 26 70 | 71 | case tableRow = 27 72 | 73 | case tableColumn = 28 74 | 75 | case outline = 29 76 | 77 | case outlineRow = 30 78 | 79 | case browser = 31 80 | 81 | case collectionView = 32 82 | 83 | case slider = 33 84 | 85 | case pageIndicator = 34 86 | 87 | case progressIndicator = 35 88 | 89 | case activityIndicator = 36 90 | 91 | case segmentedControl = 37 92 | 93 | case picker = 38 94 | 95 | case pickerWheel = 39 96 | 97 | case `switch` = 40 98 | 99 | case toggle = 41 100 | 101 | case link = 42 102 | 103 | case image = 43 104 | 105 | case icon = 44 106 | 107 | case searchField = 45 108 | 109 | case scrollView = 46 110 | 111 | case scrollBar = 47 112 | 113 | case staticText = 48 114 | 115 | case textField = 49 116 | 117 | case secureTextField = 50 118 | 119 | case datePicker = 51 120 | 121 | case textView = 52 122 | 123 | case menu = 53 124 | 125 | case menuItem = 54 126 | 127 | case menuBar = 55 128 | 129 | case menuBarItem = 56 130 | 131 | case map = 57 132 | 133 | case webView = 58 134 | 135 | case incrementArrow = 59 136 | 137 | case decrementArrow = 60 138 | 139 | case timeline = 61 140 | 141 | case ratingIndicator = 62 142 | 143 | case valueIndicator = 63 144 | 145 | case splitGroup = 64 146 | 147 | case splitter = 65 148 | 149 | case relevanceIndicator = 66 150 | 151 | case colorWell = 67 152 | 153 | case helpTag = 68 154 | 155 | case matte = 69 156 | 157 | case dockItem = 70 158 | 159 | case ruler = 71 160 | 161 | case rulerMarker = 72 162 | 163 | case grid = 73 164 | 165 | case levelIndicator = 74 166 | 167 | case cell = 75 168 | 169 | case layoutArea = 76 170 | 171 | case layoutItem = 77 172 | 173 | case handle = 78 174 | 175 | case stepper = 79 176 | 177 | case tab = 80 178 | 179 | case touchBar = 81 180 | 181 | case statusItem = 82 182 | 183 | init?(_ elementType: XCUIElement.ElementType) { 184 | self.init(rawValue: elementType.rawValue) 185 | } 186 | } 187 | 188 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/XCUITestCase+CodeGeneration.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | extension XCTestCase { 5 | public func startCodeGeneration( 6 | application: String? = nil, 7 | file: String = #file, 8 | line: UInt = #line 9 | ) { 10 | 11 | #if !targetEnvironment(simulator) 12 | print("⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️") 13 | print("⚠️ You are running the code generation on a real device 📱") 14 | print("⚠️ The code generation will not automatically add the") 15 | print("⚠️ generated pages to the source file") 16 | print("⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️⚠️") 17 | #endif 18 | 19 | func appendToSourceFile(addedContents: String) { 20 | // TODO: Give the user a hint if things go wrong. 21 | let fileUrl = URL(fileURLWithPath: file) 22 | guard let contents = try? String(contentsOf: fileUrl) else { return } 23 | try? (contents + addedContents).write(to: fileUrl, atomically: true, encoding: .utf8) 24 | } 25 | 26 | let app: XCUIApplication 27 | if let application { 28 | app = XCUIApplication(bundleIdentifier: application) 29 | } else { 30 | app = XCUIApplication() 31 | } 32 | 33 | var lastSource = "" 34 | while true { 35 | RunLoop.current.run(until: Date(timeIntervalSinceNow: 1)) 36 | guard 37 | let snapshot = try? app.snapshot(), 38 | let page = app.currentPage 39 | else { continue } 40 | 41 | let identifier = snapshot.findFirstIdentifier() ?? "GeneratedPage" 42 | let sourceWithoutUniqueName = page.generatePageSource(pageName: identifier, applicationName: application) 43 | guard lastSource != sourceWithoutUniqueName else { continue } 44 | lastSource = sourceWithoutUniqueName 45 | appendToSourceFile( 46 | addedContents: sourceWithoutUniqueName + "\n\n" 47 | ) 48 | } 49 | } 50 | } 51 | 52 | 53 | private var usedPageNames: Set = [] 54 | 55 | private extension String { 56 | func generatePageName() -> String { 57 | let generatedName = String(unicodeScalars.filter { CharacterSet.alphanumerics.contains($0)}) 58 | var pageName = generatedName 59 | if usedPageNames.contains(pageName) { 60 | var iteration = 1 61 | repeat { 62 | iteration += 1 63 | } while usedPageNames.contains("\(pageName)\(iteration)") 64 | pageName = "\(pageName)\(iteration)" 65 | } 66 | 67 | usedPageNames.insert(pageName) 68 | return pageName 69 | } 70 | } 71 | 72 | 73 | private extension XCUIElementSnapshot { 74 | func find(elementType: XCUIElement.ElementType) -> XCUIElementSnapshot? { 75 | if self.elementType == elementType { 76 | return self 77 | } 78 | for child in children { 79 | if let element = child.find(elementType: elementType) { 80 | return element 81 | } 82 | } 83 | 84 | return nil 85 | } 86 | 87 | /// Tries to find the first identifier in the element hierarchy that could be used as and identifier for the 88 | /// page that is currently displayed. 89 | func findFirstIdentifier() -> String? { 90 | if let identifier = find(elementType: .navigationBar)?.identifier, !identifier.isEmpty { 91 | return identifier 92 | } 93 | if let identifier = find(elementType: .other)?.identifier, !identifier.isEmpty { 94 | return identifier 95 | } 96 | 97 | return nil 98 | } 99 | } 100 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Extensions/XCUITestCase+InterruptionMonitor.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | public extension XCTestCase { 5 | 6 | /// Adds a new UI interruption monitor. 7 | /// 8 | /// Use XCTestCase UI interruption monitors to handle situations in which unrelated UI elements might appear and 9 | /// block the test’s interaction with elements in the workflow under test. The following situations could result in 10 | /// a blocked test: 11 | /// - Your app presents a modal view that takes focus away from the UI under test, as can happen, for example, 12 | /// when a background task fails and you notify the user of the failure. 13 | /// - Your app performs an action that causes the operating system to present a modal UI. An example is an action 14 | /// that presents a photo picker, which may make the system request access to photos if the user hasn’t already 15 | /// granted it. 16 | /// 17 | /// It will add the interruption monitor only to this test case. It will be active for this test case only and will 18 | /// not influence other test cases. 19 | /// 20 | /// Added UI interruption monitors are evaluated in the reversed order in which they were added, meaning that the 21 | /// UI interruption monitor which has been added the latest will be evaluated first. 22 | /// 23 | /// > Note: Unlike XCTest's UI interruption monitor system, Sisyphos will not call all handlers for any UI 24 | /// > interruption until the first handler returns true. Instead, you can describe how the expected UI interruption 25 | /// > looks like -- which is not possible in XCTest. Sisysphos then will call only the handlers of those UI 26 | /// > interruption monitors which are actually blocking the user interaction. 27 | /// 28 | /// - Parameters: 29 | /// - page: A page which describes the UI interruption. Whenever the page exists, Sisyphos will consider it a UI 30 | /// interruption which needs to be removed and will call your provided handler. 31 | /// - handler: A handler which is called when the page currently interrupts the user interface. It gets passed an 32 | /// instance of the page, so you can interact with its elements to dismiss the interruption. 33 | /// 34 | /// - Returns: The created interruption monitor. You can use this created interruption monitor if you want to remove 35 | /// it from further evaluation later in the test case by calling ``removeUIInterruptionMonitor(:)``. 36 | @discardableResult 37 | func addUIInterruptionMonitor( 38 | page: P, 39 | handler: @escaping (P) -> Void 40 | ) -> InterruptionMonitor { 41 | if !UIInterruptionsObserver.shared.isInstalled { 42 | UIInterruptionsObserver.shared.install() 43 | } 44 | let interruptionMonitor: InterruptionMonitor = .init( 45 | page: page, 46 | handler: { handler(page) } 47 | ) 48 | UIInterruptionsObserver.shared.addInterruptionMonitor(interruptionMonitor, for: self) 49 | if isRunning { 50 | UIInterruptionsObserver.shared.testCaseWillStart(self) 51 | } 52 | 53 | return interruptionMonitor 54 | } 55 | 56 | /// Removes the given interruption monitor from this test case. When checking for UI interruptions, Sisyphos will no 57 | /// longer consider this interruption monitor. 58 | /// - Parameter monitor: The previously registered interruption monitor which should be removed. 59 | func removeUIInterruptionMonitor(_ monitor: InterruptionMonitor) { 60 | UIInterruptionsObserver.shared.removeInterruptionMonitor(monitor) 61 | } 62 | 63 | /// By default, UI testing in an `XCTestCase` has implicit UI interruption handlers which will dismiss alerts, 64 | /// permission dialogs, banners and so on. See https://developer.apple.com/videos/play/wwdc2020/10220/?time=209. 65 | /// Those implicit handlers will interfere if you are setting up tests for permission flows or tests which require 66 | /// the handling of alerts. Because of this, you can remove this implicit handlers by calling this method. 67 | func disableDefaultXCUIInterruptionHandlers() { 68 | addUIInterruptionMonitor(withDescription: "Sisyphos handler") { _ in 69 | UIInterruptionsObserver.shared.checkForInterruptions() 70 | 71 | // If an interruption monitor handles a UI interruption, then no other UI interruption monitors are called. 72 | // Because of this, returning true will disable Apple's implicit handlers. 73 | return true 74 | } 75 | } 76 | } 77 | 78 | 79 | private extension XCTestCase { 80 | 81 | /// Whether or not the test is currently running. 82 | var isRunning: Bool { 83 | invocation != nil && testRun?.stopDate == nil 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Internal/ElementFinder.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | final class ElementFinder { 5 | let snapshot: Snapshot 6 | let page: Page 7 | 8 | init(page: Page, snapshot: XCUIElementSnapshot) { 9 | self.page = page 10 | self.snapshot = Snapshot(xcuisnapshot: snapshot) 11 | } 12 | 13 | func check() -> [PageElement] { 14 | var missingElements: [PageElement] = [] 15 | var indexOfPreviousElement: UInt = 0 16 | for element in page.body.elements { 17 | registerElement(element: element) 18 | guard let result = find(element: element, after: indexOfPreviousElement, in: snapshot) else { 19 | missingElements.append(element) 20 | continue 21 | } 22 | indexOfPreviousElement = result.index 23 | } 24 | 25 | return missingElements 26 | } 27 | 28 | private func registerElement(element: PageElement) { 29 | elementPathCache[element.elementIdentifier] = CacheEntry(page: page, snapshot: nil) 30 | if let hasChildren = element as? HasChildren { 31 | for child in hasChildren.elements { 32 | registerElement(element: child) 33 | } 34 | } 35 | } 36 | 37 | private func find(element: PageElement, after elementIndex: UInt, in snapshot: Snapshot) -> Snapshot? { 38 | elementItSelf: 39 | if snapshot.index > elementIndex && snapshot.matchesQueryAttributes(queryIdentifier: element.queryIdentifier) { 40 | if let hasChildren = element as? HasChildren { 41 | var previousIndex = snapshot.index 42 | for descendant in hasChildren.elements { 43 | guard let descendantElement = find(element: descendant, after: previousIndex, in: snapshot) else { 44 | break elementItSelf 45 | } 46 | previousIndex = descendantElement.index 47 | } 48 | } 49 | 50 | elementPathCache[element.elementIdentifier] = .init( 51 | page: page, 52 | snapshot: snapshot.xcuisnapshot 53 | ) 54 | 55 | return snapshot 56 | } 57 | 58 | for child in snapshot.children { 59 | if let result = find(element: element, after: elementIndex, in: child) { 60 | return result 61 | } 62 | } 63 | 64 | return nil 65 | } 66 | } 67 | 68 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Internal/Snapshot.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | struct Snapshot { 5 | let index: UInt 6 | let snapshotIdentifier = UUID() 7 | let xcuisnapshot: XCUIElementSnapshot 8 | 9 | var elementType: XCUIElement.ElementType { 10 | xcuisnapshot.elementType 11 | } 12 | var identifier: String { 13 | xcuisnapshot.identifier 14 | } 15 | var label: String { 16 | xcuisnapshot.label 17 | } 18 | var value: String? { 19 | xcuisnapshot.value as? String 20 | } 21 | 22 | let children: [Snapshot] 23 | 24 | private init(xcuisnapshot: XCUIElementSnapshot, counter: Counter) { 25 | index = counter.next() 26 | self.xcuisnapshot = xcuisnapshot 27 | children = xcuisnapshot.children.map { child in 28 | Snapshot( 29 | xcuisnapshot: child, 30 | counter: counter 31 | ) 32 | } 33 | } 34 | 35 | init(xcuisnapshot: XCUIElementSnapshot) { 36 | self.init(xcuisnapshot: xcuisnapshot, counter: Counter()) 37 | } 38 | 39 | /// IMPORTANT: Doesn't check the children. Only checks the attributes on the element itself. 40 | func matchesQueryAttributes(queryIdentifier: QueryIdentifier) -> Bool { 41 | guard queryIdentifier.elementType == elementType else { return false } 42 | if let neededIdentifier = queryIdentifier.identifier { 43 | guard identifier == neededIdentifier else { return false } 44 | } 45 | if let neededLabel = queryIdentifier.label { 46 | guard label.matches(searchedLabel: neededLabel) else { return false } 47 | } 48 | if let neededValue = queryIdentifier.value { 49 | guard value == neededValue else { return false } 50 | } 51 | 52 | return true 53 | } 54 | } 55 | 56 | 57 | private final class Counter { 58 | private var count: UInt = 0 59 | 60 | func next() -> UInt { 61 | count += 1 62 | return count 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Internal/UIInterruptionsObserver.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | /// The observer which observers which test case is currently running 5 | class UIInterruptionsObserver: NSObject { 6 | 7 | /// The singleton instance which is used withing Sisyphos to do the UI interruption checks and handling of the same. 8 | static let shared = UIInterruptionsObserver() 9 | 10 | /// Whether or not this observer currently runs a check for existing UI interruptions. 11 | private(set) var isRunningCheck = false 12 | 13 | /// Whether or not this observer is already installed at the ``XCTest/XCTestObservationCenter``. 14 | private(set) var isInstalled: Bool = false 15 | 16 | /// The test case which is currently running. 17 | private var currentTestCase: XCTestCase? 18 | 19 | /// All interruption monitors which have been added and the the test cases for which they were added. 20 | private var registeredInterruptionMonitors: [(testCase: XCTestCase, monitor: InterruptionMonitor)] = [] 21 | 22 | /// The interruption monitors which should be evaluated for the currently running test case. 23 | private var interruptionMonitorsForCurrentTest: [InterruptionMonitor] { 24 | registeredInterruptionMonitors.compactMap { (testCase, monitor) in 25 | guard testCase == currentTestCase else { return nil } 26 | return monitor 27 | } 28 | } 29 | 30 | /// Adds the given UI interruption monitor. 31 | /// 32 | /// - Parameters: 33 | /// - monitor: The interruption monitor. 34 | /// - testCase: The test case for which the interruption monitor should be added. The interruption monitor will 35 | /// only be evaluated when tests of this test case are running. 36 | func addInterruptionMonitor(_ monitor: InterruptionMonitor, for testCase: XCTestCase) { 37 | registeredInterruptionMonitors.append(( 38 | testCase: testCase, 39 | monitor: monitor 40 | )) 41 | } 42 | 43 | /// Removes the given interruption monitor. It will no longer be evaluated and ignored for the remaining tests. 44 | /// 45 | /// - Parameter monitor: The UI interruption monitor which should be removed. 46 | func removeInterruptionMonitor(_ monitor: InterruptionMonitor) { 47 | guard let index = registeredInterruptionMonitors.firstIndex(where: { $0.monitor == monitor }) else { return } 48 | registeredInterruptionMonitors.remove(at: index) 49 | } 50 | 51 | /// Evaluates all UI interruption monitors which were added for the currently running test case. 52 | /// If the interruption monitor reports that the UI is currently interrupted, the monitor's handler is called to 53 | /// 54 | /// The handlers are called in the reversed order in which they were added (LIFO), meaning that the interruption 55 | /// monitor which has been added the latest will be evaluated first. 56 | func checkForInterruptions() { 57 | // The handlers of interruption monitors use pages and interact with pages' elements. 58 | // When they request the elements, this would then trigger another round of `checkForInterruptions()`. 59 | // That's why we do this check and won't run a second interruption check while one is already running. 60 | guard !isRunningCheck else { return } 61 | 62 | isRunningCheck = true 63 | defer { 64 | isRunningCheck = false 65 | } 66 | for monitor in interruptionMonitorsForCurrentTest { 67 | guard monitor.isCurrentlyInterrupting else { continue } 68 | monitor.handler() 69 | } 70 | } 71 | 72 | /// Adds this observer to the test observation via the `XCTestObservationCenter`. 73 | func install() { 74 | assert(!isInstalled) 75 | 76 | XCTestObservationCenter.shared.addTestObserver(self) 77 | isInstalled = true 78 | } 79 | 80 | } 81 | 82 | extension UIInterruptionsObserver: XCTestObservation { 83 | 84 | func testCaseWillStart(_ testCase: XCTestCase) { 85 | currentTestCase = testCase 86 | } 87 | 88 | func testCaseDidFinish(_ testCase: XCTestCase) { 89 | currentTestCase = nil 90 | } 91 | } 92 | -------------------------------------------------------------------------------- /Sources/Sisyphos/InterruptionMonitor.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | import XCTest 3 | 4 | 5 | /// Describes a UI interruption that can happen at undefined times during testing and how to handle it so the tests are 6 | /// unblocked from interacting with the user interface. 7 | public struct InterruptionMonitor { 8 | 9 | /// A unique identifier so we can keep track of the interruption monitor. 10 | let identifier = UUID() 11 | 12 | /// The page which describes the expected UI interruption. If the pages exists, then it's considered a UI 13 | /// interruption and the interruption monitor's handler is called. 14 | public let page: Page 15 | /// The handler which is called when the interruption described by this UI interruption monitor is blocking the 16 | /// user interface while testing. 17 | public let handler: () -> Void 18 | 19 | /// Whether or not the UI interruption monitor is currently 20 | public var isCurrentlyInterrupting: Bool { 21 | page.exists().isExisting 22 | } 23 | } 24 | 25 | 26 | extension InterruptionMonitor: Equatable { 27 | public static func == (lhs: Self, rhs: Self) -> Bool { 28 | lhs.identifier == rhs.identifier 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/Alert.swift: -------------------------------------------------------------------------------- 1 | public struct Alert: PageElement, HasChildren { 2 | 3 | public let elementIdentifier: PageElementIdentifier 4 | 5 | public let identifier: String? 6 | 7 | public let elements: [PageElement] 8 | 9 | public init( 10 | identifier: String? = nil, 11 | @PageBuilder children: () -> PageDescription, 12 | file: String = #file, 13 | line: UInt = #line, 14 | column: UInt = #column 15 | ) { 16 | self.elementIdentifier = .init(file: file, line: line, column: column) 17 | self.identifier = identifier 18 | self.elements = children().elements 19 | } 20 | 21 | public var queryIdentifier: QueryIdentifier { 22 | .init( 23 | elementType: .alert, 24 | identifier: identifier, 25 | label: nil, 26 | value: nil, 27 | descendants: [] 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/Button.swift: -------------------------------------------------------------------------------- 1 | public struct Button: PageElement { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | let label: String? 5 | let identifier: String? 6 | 7 | public var queryIdentifier: QueryIdentifier { 8 | .init( 9 | elementType: .button, 10 | identifier: identifier, 11 | label: label, 12 | value: nil, 13 | descendants: [] 14 | ) 15 | } 16 | 17 | public init( 18 | identifier: String? = nil, 19 | label: String? = nil, 20 | file: String = #file, 21 | line: UInt = #line, 22 | column: UInt = #column 23 | ) { 24 | self.elementIdentifier = .init(file: file, line: line, column: column) 25 | self.label = label 26 | self.identifier = identifier 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/Cell.swift: -------------------------------------------------------------------------------- 1 | public struct Cell: PageElement, HasChildren { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | let identifier: String? 5 | 6 | public let elements: [PageElement] 7 | 8 | init(identifier: String?, elements: [PageElement]) { 9 | self.elementIdentifier = .dynamic 10 | self.identifier = identifier 11 | self.elements = elements 12 | } 13 | 14 | public init( 15 | identifier: String? = nil, 16 | @PageBuilder elements: () -> PageDescription, 17 | file: String = #file, 18 | line: UInt = #line, 19 | column: UInt = #column 20 | ) { 21 | self.elementIdentifier = .init(file: file, line: line, column: column) 22 | self.identifier = identifier 23 | self.elements = elements().elements 24 | } 25 | 26 | public var queryIdentifier: QueryIdentifier { 27 | .init( 28 | elementType: .cell, 29 | identifier: identifier, 30 | label: nil, 31 | value: nil, 32 | descendants: elements.map { $0.queryIdentifier } 33 | ) 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/CollectionView.swift: -------------------------------------------------------------------------------- 1 | public struct CollectionView: PageElement, HasChildren { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | public let elements: [PageElement] 5 | 6 | init(elements: [PageElement]) { 7 | self.elementIdentifier = .dynamic 8 | self.elements = elements 9 | } 10 | 11 | public init( 12 | @PageBuilder elements: () -> PageDescription, 13 | file: String = #file, 14 | line: UInt = #line, 15 | column: UInt = #column 16 | ) { 17 | self.elementIdentifier = .init(file: file, line: line, column: column) 18 | self.elements = elements().elements 19 | } 20 | 21 | public var queryIdentifier: QueryIdentifier { 22 | .init( 23 | elementType: .collectionView, 24 | identifier: nil, 25 | label: nil, 26 | value: nil, 27 | descendants: elements.map { $0.queryIdentifier } 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/NavigationBar.swift: -------------------------------------------------------------------------------- 1 | public struct NavigationBar: PageElement, HasChildren { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | let identifier: String? 5 | public let elements: [PageElement] 6 | 7 | public var queryIdentifier: QueryIdentifier { 8 | .init( 9 | elementType: .navigationBar, 10 | identifier: identifier, 11 | label: nil, 12 | value: nil, 13 | descendants: elements.map { $0.queryIdentifier } 14 | ) 15 | } 16 | 17 | init(identifier: String, elements: [PageElement]) { 18 | self.elementIdentifier = .dynamic 19 | self.identifier = identifier 20 | self.elements = elements 21 | } 22 | 23 | public init( 24 | identifier: String? = nil, 25 | @PageBuilder pageDescription: () -> PageDescription, 26 | file: String = #file, 27 | line: UInt = #line, 28 | column: UInt = #column 29 | ) { 30 | self.elementIdentifier = .init(file: file, line: line, column: column) 31 | self.identifier = identifier 32 | self.elements = pageDescription().elements 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/Other.swift: -------------------------------------------------------------------------------- 1 | public struct Other: PageElement, HasChildren { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | let identifier: String? 5 | let label: String? 6 | let elements: [any PageElement] 7 | 8 | public var queryIdentifier: QueryIdentifier { 9 | .init( 10 | elementType: .other, 11 | identifier: identifier, 12 | label: label, 13 | value: nil, 14 | descendants: elements.map { $0.queryIdentifier } 15 | ) 16 | } 17 | 18 | init(identifier: String?, label: String?, elements: [PageElement]) { 19 | self.elementIdentifier = .dynamic 20 | self.label = label 21 | self.identifier = identifier 22 | self.elements = elements 23 | } 24 | 25 | public init( 26 | identifier: String? = nil, 27 | label: String, 28 | @PageBuilder children: () -> PageDescription = PageBuilder.empty, 29 | file: String = #file, 30 | line: UInt = #line, 31 | column: UInt = #column 32 | ) { 33 | self.elementIdentifier = .init(file: file, line: line, column: column) 34 | self.identifier = identifier 35 | self.label = label 36 | self.elements = children().elements 37 | } 38 | 39 | public init( 40 | identifier: String, 41 | @PageBuilder children: () -> PageDescription = PageBuilder.empty, 42 | file: String = #file, 43 | line: UInt = #line, 44 | column: UInt = #column 45 | ) { 46 | self.elementIdentifier = .init(file: file, line: line, column: column) 47 | self.identifier = identifier 48 | self.label = nil 49 | self.elements = children().elements 50 | } 51 | } 52 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/SecureTextField.swift: -------------------------------------------------------------------------------- 1 | public struct SecureTextField: PageElement { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | public let identifier: String? 5 | 6 | public let value: String? 7 | 8 | public init( 9 | identifier: String? = nil, 10 | value: String? = nil, 11 | file: String = #file, 12 | line: UInt = #line, 13 | column: UInt = #column 14 | ) { 15 | self.elementIdentifier = .init(file: file, line: line, column: column) 16 | self.value = value 17 | self.identifier = identifier 18 | } 19 | 20 | public var queryIdentifier: QueryIdentifier { 21 | .init( 22 | elementType: .secureTextField, 23 | identifier: identifier, 24 | label: nil, 25 | value: value, 26 | descendants: [] 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/StaticText.swift: -------------------------------------------------------------------------------- 1 | public struct StaticText: PageElement { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | let identifier: String? 5 | let text: String? 6 | 7 | public var queryIdentifier: QueryIdentifier { 8 | .init( 9 | elementType: .staticText, 10 | identifier: identifier, 11 | label: text, 12 | value: nil, 13 | descendants: [] 14 | ) 15 | } 16 | 17 | public init( 18 | identifier: String? = nil, 19 | _ text: String, 20 | file: String = #file, 21 | line: UInt = #line, 22 | column: UInt = #column 23 | ) { 24 | self.elementIdentifier = .init(file: file, line: line, column: column) 25 | self.identifier = identifier 26 | self.text = text 27 | } 28 | 29 | public init( 30 | identifier: String, 31 | file: String = #file, 32 | line: UInt = #line, 33 | column: UInt = #column 34 | ){ 35 | self.elementIdentifier = .init(file: file, line: line, column: column) 36 | self.identifier = identifier 37 | self.text = nil 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/Switch.swift: -------------------------------------------------------------------------------- 1 | public struct Switch: PageElement { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | let identifier: String? 5 | let label: String? 6 | 7 | public var queryIdentifier: QueryIdentifier { 8 | .init( 9 | elementType: .switch, 10 | identifier: identifier, 11 | label: label, 12 | value: nil, 13 | descendants: [] 14 | ) 15 | } 16 | 17 | public init( 18 | identifier: String? = nil, 19 | label: String? = nil, 20 | file: String = #file, 21 | line: UInt = #line, 22 | column: UInt = #column 23 | ) { 24 | self.elementIdentifier = .init(file: file, line: line, column: column) 25 | self.identifier = identifier 26 | self.label = label 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/TabBar.swift: -------------------------------------------------------------------------------- 1 | public struct TabBar: PageElement, HasChildren { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | public let elements: [PageElement] 5 | 6 | init(elements: [PageElement]) { 7 | self.elementIdentifier = .dynamic 8 | self.elements = elements 9 | } 10 | 11 | public init( 12 | @PageBuilder elements: () -> PageDescription, 13 | file: String = #file, 14 | line: UInt = #line, 15 | column: UInt = #column 16 | ) { 17 | self.elementIdentifier = .init(file: file, line: line, column: column) 18 | self.elements = elements().elements 19 | } 20 | 21 | public var queryIdentifier: QueryIdentifier { 22 | .init( 23 | elementType: .tabBar, 24 | identifier: nil, 25 | label: nil, 26 | value: nil, 27 | descendants: elements.map { $0.queryIdentifier } 28 | ) 29 | } 30 | } 31 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page Elements/TextField.swift: -------------------------------------------------------------------------------- 1 | public struct TextField: PageElement { 2 | public let elementIdentifier: PageElementIdentifier 3 | 4 | public let identifier: String? 5 | 6 | public let value: String? 7 | 8 | public init( 9 | identifier: String? = nil, 10 | value: String? = nil, 11 | file: String = #file, 12 | line: UInt = #line, 13 | column: UInt = #column 14 | ) { 15 | self.elementIdentifier = .init(file: file, line: line, column: column) 16 | self.value = value 17 | self.identifier = identifier 18 | } 19 | 20 | public var queryIdentifier: QueryIdentifier { 21 | .init( 22 | elementType: .textField, 23 | identifier: identifier, 24 | label: nil, 25 | value: value, 26 | descendants: [] 27 | ) 28 | } 29 | } 30 | -------------------------------------------------------------------------------- /Sources/Sisyphos/Page.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import UniformTypeIdentifiers 3 | 4 | 5 | /// A page describes a screen that is expected to appear in the tests. It's the basic building block for writing tests 6 | /// with Sisyphos. 7 | public protocol Page { 8 | 9 | /// The bundle identifier of the pages' application. 10 | /// 11 | /// You can use this property to test and interact with other apps. 12 | /// 13 | /// If you set this property to an empty string (`""`), then the target application of your tests' target is used. 14 | /// 15 | /// Usually you don't need to implement this property as long as you have properly set up your test target to have a 16 | /// target application. Then a a default implementation is provided which sets the bundle identifier to the empty 17 | /// string, therefore using the app which is configured as the tests' target application. 18 | var application: String { get } 19 | 20 | /// Describes the expected contents of the screen. 21 | @PageBuilder var body: PageDescription { get } 22 | } 23 | 24 | public extension Page { 25 | var application: String { "" } 26 | } 27 | 28 | 29 | extension Page { 30 | /// The name of the page which is displayed to the user, e.g. in error messages or debug output. 31 | var debugName: String { 32 | String(describing: type(of: self)) 33 | } 34 | } 35 | 36 | extension Page { 37 | var xcuiapplication: XCUIApplication { 38 | if !application.isEmpty { 39 | return XCUIApplication(bundleIdentifier: application) 40 | } else { 41 | return XCUIApplication() 42 | } 43 | } 44 | } 45 | 46 | 47 | public extension Page { 48 | 49 | /// Checks if the given page exists, that means it's currently visible on the screen as described in the page 50 | /// description. 51 | func exists() -> PageExistsResults { 52 | XCTContext.runActivity(named: "Check if page \(debugName) exists") { activity in 53 | UIInterruptionsObserver.shared.checkForInterruptions() 54 | 55 | let screenshotAttachment = XCTAttachment(screenshot: xcuiapplication.screenshot()) 56 | screenshotAttachment.lifetime = .deleteOnSuccess 57 | activity.add(screenshotAttachment) 58 | 59 | guard let snapshot = try? xcuiapplication.snapshot() else { 60 | return PageExistsResults( 61 | missingElements: body.elements, 62 | actualPage: nil 63 | ) 64 | } 65 | let finder = ElementFinder(page: self, snapshot: snapshot) 66 | TestData.isEvaluatingBody = true 67 | defer { 68 | TestData.isEvaluatingBody = false 69 | } 70 | return PageExistsResults( 71 | missingElements: finder.check(), 72 | actualPage: snapshot.toPage() 73 | ) 74 | } 75 | } 76 | 77 | /// Checks if the given page exists, that means it's currently visible on the screen as described in the page 78 | /// description. 79 | /// 80 | /// If it doesn't exist, the currently running test is failed automatically. 81 | /// 82 | /// - Parameters: 83 | /// - timeout: 84 | /// How long Sisyphos should wait (in seconds) until the non-existence of the page will cause a test failure. 85 | /// You should choose a balance between slow loading pages (e.g. if the screen depends on a content that is 86 | /// loaded from a slow server) and your test's total execution time. 87 | /// - file: 88 | /// The file where the failure occurs. You usually don't provide this value. The default is the filename of 89 | /// the test case where you call this function. 90 | /// - line: 91 | /// The line number where the failure occurs. You usually don't provide this value. The default is the line 92 | /// number where you call this function. 93 | /// 94 | /// > Note: 95 | /// If you want to check if a page exists without failing the currently running test, you can use the 96 | /// ``exists()`` method. It will not fail the test and give a result that you can introspect to find out which 97 | /// elements of a page are missing. 98 | func waitForExistence( 99 | timeout: CFTimeInterval = 15, 100 | file: StaticString = #file, 101 | line: UInt = #line 102 | ) { 103 | XCTContext.runActivity(named: "Wait max \(timeout)s for page \(debugName) to exist") { activity in 104 | let runLoop = RunLoop.current 105 | let deadline = Date(timeIntervalSinceNow: timeout) 106 | var results: PageExistsResults? 107 | repeat { 108 | let currentResults = exists() 109 | guard !currentResults.isExisting else { return } 110 | results = currentResults 111 | _ = runLoop.run(mode: .default, before: Date(timeIntervalSinceNow: 1)) 112 | } while Date() < deadline 113 | 114 | if let data = results?.actualPage?.generatePageSource().data(using: .utf8) { 115 | activity.add( 116 | XCTAttachment( 117 | uniformTypeIdentifier: UTType.swiftSource.identifier, 118 | name: "ActualPage.swift", 119 | payload: data 120 | ) 121 | ) 122 | } 123 | 124 | XCTFail( 125 | "Page \(debugName) didn't exist after \(timeout)s" 126 | + (results?.failureDescription ?? ""), 127 | file: file, 128 | line: line 129 | ) 130 | } 131 | } 132 | } 133 | 134 | extension Page { 135 | func refreshElementCache() { 136 | guard let snapshot = try? xcuiapplication.snapshot() else { 137 | return 138 | } 139 | let finder = ElementFinder(page: self, snapshot: snapshot) 140 | _ = finder.check() 141 | } 142 | } 143 | 144 | var elementPathCache: [PageElementIdentifier: CacheEntry] = [:] 145 | 146 | struct CacheEntry { 147 | let page: Page 148 | let snapshot: XCUIElementSnapshot? 149 | } 150 | 151 | 152 | private extension PageExistsResults { 153 | var failureDescription: String { 154 | missingElements.map { 155 | "\n⛔️ missing element \(type(of: $0)), defined at \($0.elementIdentifier.file) \($0.elementIdentifier.line):\($0.elementIdentifier.column)" 156 | }.joined() 157 | } 158 | } 159 | -------------------------------------------------------------------------------- /Sources/Sisyphos/PageBuilder.swift: -------------------------------------------------------------------------------- 1 | 2 | @resultBuilder public struct PageBuilder { 3 | public static func buildBlock(_ components: PageDescriptionBlock...) -> PageDescription { 4 | PageDescription(elements: components.flatMap { $0.buildingBlocks }) 5 | } 6 | 7 | // MARK: - Conditions 8 | 9 | public static func buildIf(_ value: PageDescription?) -> PageDescription { 10 | guard let value else { return .empty } 11 | 12 | return value 13 | } 14 | 15 | public static func buildEither(first component: PageDescription) -> PageDescription { 16 | component 17 | } 18 | 19 | public static func buildEither(second component: PageDescription) -> PageDescription { 20 | component 21 | } 22 | 23 | // MARK: - Optionals 24 | 25 | public static func buildOptional(_ component: PageDescription?) -> PageDescription { 26 | guard let component else { return .empty } 27 | 28 | return component 29 | } 30 | } 31 | 32 | public extension PageBuilder { 33 | static var empty: () -> PageDescription { 34 | { PageDescription(elements: []) } 35 | } 36 | } 37 | 38 | 39 | public protocol PageDescriptionBlock { 40 | var buildingBlocks: [PageElement] { get } 41 | } 42 | 43 | 44 | public extension PageElement { 45 | var buildingBlocks: [PageElement] { [self] } 46 | } 47 | 48 | 49 | extension PageDescription: PageDescriptionBlock { 50 | public var buildingBlocks: [PageElement] { elements } 51 | } 52 | 53 | 54 | private extension PageDescription { 55 | static var empty: PageDescription { .init(elements: []) } 56 | } 57 | 58 | 59 | extension Optional: PageDescriptionBlock where Wrapped: PageElement { 60 | public var buildingBlocks: [PageElement] { 61 | switch self { 62 | case .none: 63 | return [] 64 | case .some(let element): 65 | return [element] 66 | } 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Sources/Sisyphos/PageDescription.swift: -------------------------------------------------------------------------------- 1 | 2 | 3 | public struct PageDescription { 4 | public let elements: [PageElement] 5 | } 6 | 7 | extension PageDescription { 8 | public func generatePageSource( 9 | pageName: String = "DebugPage", 10 | applicationName: String? = nil 11 | ) -> String { 12 | var output = "struct \(pageName): Page {\n" 13 | if let applicationName { 14 | output += "\n let application = \(applicationName.debugDescription)\n\n" 15 | } 16 | output += " var body: PageDescription {\n" 17 | for element in elements { 18 | output += element.prettyPrint(indentation: 2) + "\n" 19 | } 20 | output += " }\n}" 21 | 22 | return output 23 | } 24 | } 25 | 26 | 27 | private extension PageElement { 28 | func prettyPrint(indentation: Int) -> String { 29 | let indentationString = String(repeating: " ", count: indentation) 30 | var output = "\(indentationString)\(String(describing: Swift.type(of: self)))" 31 | var properties: [(propertyName: String, value: String)] = Mirror(reflecting: self).children.compactMap { (property, value) in 32 | guard let property else { return nil } 33 | guard !["elementIdentifier", "elements"].contains(property) else { return nil } 34 | guard let stringValue = value as? String, !stringValue.isEmpty else { return nil } 35 | 36 | return ( 37 | propertyName: property, 38 | value: stringValue.debugDescription 39 | ) 40 | } 41 | if !properties.isEmpty || !(self is HasChildren) { 42 | output += "(" 43 | } 44 | properties.sort { left, right in 45 | switch left.propertyName { 46 | case "value": 47 | return false 48 | case "label": 49 | return right.propertyName != "identifier" 50 | case "text": 51 | return false 52 | default: 53 | return true 54 | } 55 | } 56 | for (index, (propertyName, value)) in properties.enumerated() { 57 | if index != 0 { 58 | output += ", " 59 | } 60 | // special treatment for text, which only exists on StaticText 61 | if propertyName == "text" { 62 | output += value 63 | } else { 64 | output += "\(propertyName): \(value)" 65 | } 66 | } 67 | if !properties.isEmpty || !(self is HasChildren) { 68 | output += ")" 69 | } 70 | if let hasChildren = self as? HasChildren { 71 | output += " {\n" 72 | for child in hasChildren.elements { 73 | output += child.prettyPrint(indentation: indentation + 1) + "\n" 74 | } 75 | output += "\(indentationString)}" 76 | } 77 | return output 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /Sources/Sisyphos/PageElement.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | 4 | public protocol PageElement: PageDescriptionBlock { 5 | var elementIdentifier: PageElementIdentifier { get } 6 | 7 | var queryIdentifier: QueryIdentifier { get } 8 | } 9 | 10 | protocol HasChildren { 11 | var elements: [PageElement] { get } 12 | } 13 | 14 | 15 | extension PageElement { 16 | 17 | func getXCUIElement(forAction action: String) -> XCUIElement? { 18 | handleInterruptions() 19 | 20 | getPage()?.refreshElementCache() 21 | guard let cacheEntry = elementPathCache[elementIdentifier] else { 22 | assertionFailure("\(action) called before page.exists()!") 23 | return nil 24 | } 25 | guard let snapshot = cacheEntry.snapshot else { 26 | assertionFailure("Try to run \(action) on an element of a non-existing page") 27 | return nil 28 | } 29 | 30 | let application = cacheEntry.page.xcuiapplication 31 | let query = application.descendants(matching: snapshot.elementType).matching(NSPredicate(snapshot: snapshot)) 32 | 33 | return query.firstMatch 34 | } 35 | 36 | /// It is used for getting the window that contains the element 37 | /// - Returns: Corresponding window 38 | func getXCUIWindow(forAction action: String) -> XCUIElement? { 39 | handleInterruptions() 40 | 41 | getPage()?.refreshElementCache() 42 | guard let cacheEntry = elementPathCache[elementIdentifier] else { 43 | assertionFailure("\(action) called before page.exists()!") 44 | return nil 45 | } 46 | guard let snapshot = cacheEntry.snapshot else { 47 | assertionFailure("Try to run \(action) on an element of a non-existing page") 48 | return nil 49 | } 50 | let application = cacheEntry.page.xcuiapplication 51 | let query = application.windows.containing(NSPredicate(snapshot: snapshot)) 52 | 53 | return query.firstMatch 54 | } 55 | 56 | private func getPage() -> Page? { 57 | guard let cacheEntry = elementPathCache[elementIdentifier] else { 58 | return nil 59 | } 60 | 61 | return cacheEntry.page 62 | } 63 | 64 | /// Sends a tap event to a hittable point the system computes for the element. 65 | public func tap() { 66 | guard let element = getXCUIElement(forAction: "tap()") else { return } 67 | element.waitUntilStablePosition() 68 | element.tap() 69 | } 70 | 71 | /// Sends a tap event to the hittable point that is described by the given normalized coordinate. 72 | /// 73 | /// In the current scenarios, we observed that the static text element on the WebView is not hittable when the 74 | /// regular `tap()` function has been used. The reason is that XCUIElement recognizes the object as not accessible, 75 | /// but it's not true. To avoid non-accessible situations, we need to tap on the component by using offset 76 | /// coordinates. 77 | /// - Parameter coordinates: Normalized offset coordinates to specify tap position. 78 | public func tap(usingCoordinates coordinates: CGVector) { 79 | guard let element = getXCUIElement(forAction: "tap()") else { return } 80 | element.waitUntilStablePosition() 81 | element.coordinate(withNormalizedOffset: coordinates).tap() 82 | } 83 | 84 | /// Types a string into the element. 85 | /// 86 | /// The element doesn't need to have keyboard focus prior to typing. To make sure that the element has keyboard 87 | /// focus, a tap event is sent to the element before typing. 88 | /// 89 | /// - Parameters: 90 | /// - text: 91 | /// The string which should be typed into the element. 92 | /// - dismissKeyboard: 93 | /// Whether or not the keyboard should be dismissed after typing the text has finished. If the keyboard should 94 | /// be dismissed, it's dismissed by tapping the `Done` button on the keyboard. 95 | public func type(text: String, dismissKeyboard: Bool = true) { 96 | // TODO: better activity description 97 | XCTContext.runActivity(named: "Typing text \(text.debugDescription)") { activity in 98 | guard let element = getXCUIElement(forAction: "type(text: \(text.debugDescription)") else { return } 99 | element.waitUntilStablePosition() 100 | element.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)).tap() 101 | element.typeText(text) 102 | 103 | guard dismissKeyboard else { return } 104 | 105 | guard let dismissButton = getPage()?.xcuiapplication.keyboards.buttons["Done".localizedForSimulator] else { 106 | return 107 | } 108 | guard dismissButton.exists else { return } 109 | // If this is a fresh simulator - which is very common on CI systems - then there's an overlay over the 110 | // keyboard which explains how to use the swipe keyboard. All of the buttons of the keyboard and its 111 | // toolbar are visible for the automation, but not tappable. We first need to dismiss the overlay. 112 | // Unfortunately this overlay is not part of the keyboard, so querying it via application.keyboards... will 113 | // not work. It doesn't have an accessibility identifier neither. 114 | if !dismissButton.isHittable { 115 | guard let app = getPage()?.xcuiapplication else { return } 116 | for button in app.buttons.matching(identifier: "Continue".localizedForSimulator).allElementsBoundByIndex { 117 | guard button.isHittable else { continue } 118 | button.tap() 119 | break 120 | } 121 | } 122 | if dismissButton.isHittable { 123 | dismissButton.tap() 124 | } 125 | } 126 | } 127 | 128 | public func waitUntilIsHittable( 129 | timeout: CFTimeInterval = 10, 130 | file: StaticString = #file, 131 | line: UInt = #line 132 | ) { 133 | guard let element = getXCUIElement(forAction: "wait until hittable") else { return } 134 | let deadline = Date(timeIntervalSinceNow: timeout) 135 | repeat { 136 | guard !element.isHittable else { return } 137 | _ = RunLoop.current.run(mode: .default, before: Date(timeIntervalSinceNow: 1)) 138 | } while Date() < deadline 139 | 140 | XCTFail( 141 | "Did not become hittable after \(timeout)s", 142 | file: file, 143 | line: line 144 | ) 145 | } 146 | 147 | /// Scrolls on the screen until the element is in the visible area. 148 | /// - Parameters: 149 | /// - direction: Indicates the direction of the scroll. 150 | /// - maxTryCount: Specifies how many attempts should it apply. 151 | /// - file: Name of the file that will be displayed if it fails. 152 | /// - line: Line number that will be displayed if it fails. 153 | // TODO: Add PageBuilder argument to make the function more generic 154 | public func scrollUntilVisibleOnScreen( 155 | direction: ScrollDirection, 156 | maxTryCount: Int = 5, 157 | file: StaticString = #file, 158 | line: UInt = #line 159 | ) { 160 | guard let element = getXCUIElement(forAction: "scroll until visible") else { return } 161 | guard let app = getPage()?.xcuiapplication else { return } 162 | guard let window = getXCUIWindow(forAction: "scroll until visible") else { return } 163 | guard !CGRectContainsRect(window.frame, element.frame) else { return } 164 | var tryCounter = maxTryCount 165 | var startCoordinate = app.coordinate(withNormalizedOffset: CGVector(dx: 0.5, dy: 0.5)) 166 | repeat { 167 | var endCoordinate = startCoordinate 168 | switch direction { 169 | case .down: 170 | endCoordinate = startCoordinate.withOffset(CGVector(dx: 0, dy: -window.frame.height/3)) 171 | startCoordinate.press(forDuration: 0.05, thenDragTo: endCoordinate) 172 | case .up: 173 | endCoordinate = startCoordinate.withOffset(CGVector(dx: 0, dy: window.frame.height/3)) 174 | startCoordinate.press(forDuration: 0.05, thenDragTo: endCoordinate) 175 | case .left: 176 | endCoordinate = startCoordinate.withOffset(CGVector(dx: window.frame.width/3, dy: 0)) 177 | startCoordinate.press(forDuration: 0.05, thenDragTo: endCoordinate) 178 | case .right: 179 | endCoordinate = startCoordinate.withOffset(CGVector(dx: -window.frame.width/3, dy: 0)) 180 | startCoordinate.press(forDuration: 0.05, thenDragTo: endCoordinate) 181 | } 182 | element.waitUntilStablePosition() 183 | tryCounter -= 1 184 | startCoordinate = endCoordinate 185 | guard !CGRectContainsRect(window.frame, element.frame) else { return } 186 | } while tryCounter > 0 187 | 188 | XCTFail( 189 | "Did not exist after attempting \(maxTryCount) times scrolling", 190 | file: file, 191 | line: line 192 | ) 193 | } 194 | 195 | /// For debugging only. Please don't use this for writing tests. 196 | public var element: XCUIElement { 197 | getXCUIElement(forAction: "debugging the element")! 198 | } 199 | } 200 | 201 | 202 | public enum ScrollDirection { 203 | case down 204 | case up 205 | case left 206 | case right 207 | } 208 | 209 | 210 | extension String { 211 | 212 | func matches(searchedLabel: String) -> Bool { 213 | let variableMatches = TestData.regex.matches( 214 | in: searchedLabel, 215 | range: NSRange(location: 0, length: searchedLabel.utf16.count) 216 | ) 217 | guard !variableMatches.isEmpty else { 218 | return searchedLabel == self 219 | } 220 | 221 | let variables: [UUID] = variableMatches.compactMap { match -> UUID? in 222 | guard let range = Range(NSRange(location: match.range.location + 1, length: match.range.length - 2), in: searchedLabel) else { return nil } 223 | return UUID(uuidString: String(searchedLabel[range])) 224 | } 225 | 226 | var text = searchedLabel 227 | for variable in variables { 228 | text = text.replacingOccurrences(of: "{\(variable.uuidString)}", with: "(.*)") 229 | } 230 | guard let regex = try? NSRegularExpression(pattern: "^\(text)$") else { 231 | assertionFailure() 232 | return false 233 | } 234 | let valueMatches = regex.matches(in: self, range: NSRange(location: 0, length: utf16.count)) 235 | for (index, match) in valueMatches.enumerated() { 236 | guard let range = Range(match.range(at: 1), in: self) else { continue } 237 | let value = self[range] 238 | TestData[variables[index]] = String(value) 239 | } 240 | 241 | return true 242 | } 243 | } 244 | 245 | /// Automatically handles system alerts like push notification permissions as well as user defined UI interruptions. 246 | private func handleInterruptions() { 247 | UIInterruptionsObserver.shared.checkForInterruptions() 248 | } 249 | -------------------------------------------------------------------------------- /Sources/Sisyphos/PageElementIdentifier.swift: -------------------------------------------------------------------------------- 1 | /// An identifier to uniquely identify elements. 2 | /// 3 | /// Elements are identified by their position in the source code. This has the additional benefit that the identifer 4 | /// can be used for helpful debug information. 5 | public struct PageElementIdentifier: Hashable { 6 | /// The source file in which the element is defined. 7 | let file: String 8 | /// The line in the source code where the element is defined. 9 | let line: UInt 10 | /// The column in the source code where the element is defined. 11 | let column: UInt 12 | 13 | /// A special identifier which is used when page elements are created dynamically, e.g. in a automatically created 14 | /// page description of a running app. 15 | static let dynamic: Self = .init(file: "", line: 0, column: 0) 16 | } 17 | -------------------------------------------------------------------------------- /Sources/Sisyphos/PageExistsResults.swift: -------------------------------------------------------------------------------- 1 | /// The results of a check whether a ``Page`` exists or not. 2 | public struct PageExistsResults { 3 | 4 | /// The page's elements that were missing when the check was run. Empty if the page exists. 5 | public let missingElements: [PageElement] 6 | 7 | /// This will contain the full hierarchy of elements known to Sisyphos and doesn't try to only match the simplified 8 | /// requirements of the original page. Therefore it will contain more elements than the page in most situations as 9 | /// it also includes the elements in which the page is not interested. 10 | /// 11 | /// This property is nil if it was not possible to get a snapshot of the app, e.g. because the app wasn't running. 12 | public let actualPage: PageDescription? 13 | 14 | /// Whether or not a page exists, that means that it's currently visible on the screen according to its page 15 | /// description. 16 | public var isExisting: Bool { 17 | missingElements.isEmpty 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /Sources/Sisyphos/QueryIdentifier.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | public struct QueryIdentifier { 4 | let elementType: XCUIElement.ElementType 5 | let identifier: String? 6 | let label: String? 7 | let value: String? 8 | let descendants: [QueryIdentifier] 9 | } 10 | -------------------------------------------------------------------------------- /Sources/Sisyphos/TestData.swift: -------------------------------------------------------------------------------- 1 | import Foundation 2 | 3 | 4 | 5 | @propertyWrapper public struct TestData { 6 | 7 | private static var valueStore: [UUID: String] = [:] 8 | 9 | static var isEvaluatingBody = false 10 | 11 | static let regex = try! NSRegularExpression( 12 | pattern: "\\{([A-F0-9]{8}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{4}-[A-F0-9]{12})\\}", 13 | options: [] 14 | ) 15 | 16 | let variableIdentifier = UUID() 17 | 18 | public var wrappedValue: String { 19 | get { 20 | if Self.isEvaluatingBody { 21 | return "{\(variableIdentifier.uuidString)}" 22 | } 23 | return Self.valueStore[variableIdentifier] ?? "" 24 | } 25 | } 26 | 27 | public init() { 28 | 29 | } 30 | 31 | static subscript(_ identifier: UUID) -> String? { 32 | set { 33 | valueStore[identifier] = newValue 34 | } 35 | get { 36 | valueStore[identifier] 37 | } 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp.xcodeproj/project.pbxproj: -------------------------------------------------------------------------------- 1 | // !$*UTF8*$! 2 | { 3 | archiveVersion = 1; 4 | classes = { 5 | }; 6 | objectVersion = 60; 7 | objects = { 8 | 9 | /* Begin PBXBuildFile section */ 10 | 8C0431E42AC36F2700727A89 /* InterruptionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C0431E32AC36F2700727A89 /* InterruptionTests.swift */; }; 11 | 8C1404352AC1AD5A0064343C /* PageExistsErrorTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C1404342AC1AD5A0064343C /* PageExistsErrorTests.swift */; }; 12 | 8C19AF732AC9B7F800414A52 /* SwitchTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C19AF722AC9B7F800414A52 /* SwitchTests.swift */; }; 13 | 8C19AF752AC9C02000414A52 /* TestDataTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C19AF742AC9C02000414A52 /* TestDataTests.swift */; }; 14 | 8C19AF772AC9F77A00414A52 /* AlertTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C19AF762AC9F77A00414A52 /* AlertTests.swift */; }; 15 | 8C19AF792AC9F9FA00414A52 /* NavigationBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C19AF782AC9F9FA00414A52 /* NavigationBarTests.swift */; }; 16 | 8C19AF7B2ACA011000414A52 /* SecureTextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C19AF7A2ACA011000414A52 /* SecureTextFieldTests.swift */; }; 17 | 8C19AF7D2ACA09F700414A52 /* StaticTextTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C19AF7C2ACA09F700414A52 /* StaticTextTests.swift */; }; 18 | 8C5580BB2AC0F15F00222860 /* Generated.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5580BA2AC0F15F00222860 /* Generated.swift */; }; 19 | 8C5580BD2AC1694B00222860 /* ButtonTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C5580BC2AC1694B00222860 /* ButtonTests.swift */; }; 20 | 8C6BEDF92AC0C8DD00D4B0CD /* XCTestCase+LaunchApp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6BEDF82AC0C8DD00D4B0CD /* XCTestCase+LaunchApp.swift */; }; 21 | 8C6D0E442AB1F17900353087 /* SisyphosTestapp.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6D0E432AB1F17900353087 /* SisyphosTestapp.swift */; }; 22 | 8C6D0E482AB1F17A00353087 /* Assets.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 8C6D0E472AB1F17A00353087 /* Assets.xcassets */; }; 23 | 8C6D0E5F2AB1F17B00353087 /* TabBarTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C6D0E5E2AB1F17B00353087 /* TabBarTests.swift */; }; 24 | 8C6D0E712AB1F2B700353087 /* Sisyphos in Frameworks */ = {isa = PBXBuildFile; productRef = 8C6D0E702AB1F2B700353087 /* Sisyphos */; }; 25 | 8C7D424A2ABE437F00FA8892 /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 8C7D42492ABE437F00FA8892 /* XCTest.framework */; platformFilter = ios; }; 26 | 8C80C2F32AC17DEC0031F00C /* TextFieldTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C80C2F22AC17DEC0031F00C /* TextFieldTests.swift */; }; 27 | 8C80C2F52AC185340031F00C /* CommonHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C80C2F42AC185340031F00C /* CommonHelpers.swift */; }; 28 | 8C80C2F62AC185340031F00C /* CommonHelpers.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8C80C2F42AC185340031F00C /* CommonHelpers.swift */; }; 29 | 8CCF7FEE2AC2144F00A044CA /* PageHierarchyTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CCF7FED2AC2144F00A044CA /* PageHierarchyTests.swift */; }; 30 | 8CE3D95A2ACA0FAB00401224 /* Sisyphos in Frameworks */ = {isa = PBXBuildFile; productRef = 8CE3D9592ACA0FAB00401224 /* Sisyphos */; }; 31 | 8CE3D95D2ACA146400401224 /* CollectionViewTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 8CE3D95C2ACA146400401224 /* CollectionViewTests.swift */; }; 32 | C31078B72AE0414200312CD6 /* OtherTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C31078B62AE0414200312CD6 /* OtherTests.swift */; }; 33 | /* End PBXBuildFile section */ 34 | 35 | /* Begin PBXContainerItemProxy section */ 36 | 8C6D0E5B2AB1F17B00353087 /* PBXContainerItemProxy */ = { 37 | isa = PBXContainerItemProxy; 38 | containerPortal = 8C6D0E382AB1F17900353087 /* Project object */; 39 | proxyType = 1; 40 | remoteGlobalIDString = 8C6D0E3F2AB1F17900353087; 41 | remoteInfo = SisyphosTestapp; 42 | }; 43 | /* End PBXContainerItemProxy section */ 44 | 45 | /* Begin PBXFileReference section */ 46 | 8C0431E32AC36F2700727A89 /* InterruptionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = InterruptionTests.swift; sourceTree = ""; }; 47 | 8C1404342AC1AD5A0064343C /* PageExistsErrorTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageExistsErrorTests.swift; sourceTree = ""; }; 48 | 8C19AF722AC9B7F800414A52 /* SwitchTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SwitchTests.swift; sourceTree = ""; }; 49 | 8C19AF742AC9C02000414A52 /* TestDataTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestDataTests.swift; sourceTree = ""; }; 50 | 8C19AF762AC9F77A00414A52 /* AlertTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AlertTests.swift; sourceTree = ""; }; 51 | 8C19AF782AC9F9FA00414A52 /* NavigationBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NavigationBarTests.swift; sourceTree = ""; }; 52 | 8C19AF7A2ACA011000414A52 /* SecureTextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SecureTextFieldTests.swift; sourceTree = ""; }; 53 | 8C19AF7C2ACA09F700414A52 /* StaticTextTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = StaticTextTests.swift; sourceTree = ""; }; 54 | 8C5580BA2AC0F15F00222860 /* Generated.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Generated.swift; sourceTree = ""; }; 55 | 8C5580BC2AC1694B00222860 /* ButtonTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ButtonTests.swift; sourceTree = ""; }; 56 | 8C6BEDF82AC0C8DD00D4B0CD /* XCTestCase+LaunchApp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "XCTestCase+LaunchApp.swift"; sourceTree = ""; }; 57 | 8C6BEDFA2AC0CB0800D4B0CD /* TestCodeGenerator.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TestCodeGenerator.swift; sourceTree = ""; }; 58 | 8C6D0E402AB1F17900353087 /* SisyphosTestapp.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SisyphosTestapp.app; sourceTree = BUILT_PRODUCTS_DIR; }; 59 | 8C6D0E432AB1F17900353087 /* SisyphosTestapp.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = SisyphosTestapp.swift; sourceTree = ""; }; 60 | 8C6D0E472AB1F17A00353087 /* Assets.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Assets.xcassets; sourceTree = ""; }; 61 | 8C6D0E5A2AB1F17B00353087 /* SisyphosTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = SisyphosTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; 62 | 8C6D0E5E2AB1F17B00353087 /* TabBarTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TabBarTests.swift; sourceTree = ""; }; 63 | 8C7D42492ABE437F00FA8892 /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Platforms/iPhoneOS.platform/Developer/Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; }; 64 | 8C80C2F22AC17DEC0031F00C /* TextFieldTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = TextFieldTests.swift; sourceTree = ""; }; 65 | 8C80C2F42AC185340031F00C /* CommonHelpers.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CommonHelpers.swift; sourceTree = ""; }; 66 | 8CCF7FED2AC2144F00A044CA /* PageHierarchyTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = PageHierarchyTests.swift; sourceTree = ""; }; 67 | 8CD4FE7A2AC6087F00AB9B37 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = ""; }; 68 | 8CE3D95C2ACA146400401224 /* CollectionViewTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CollectionViewTests.swift; sourceTree = ""; }; 69 | C31078B62AE0414200312CD6 /* OtherTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = OtherTests.swift; sourceTree = ""; }; 70 | /* End PBXFileReference section */ 71 | 72 | /* Begin PBXFrameworksBuildPhase section */ 73 | 8C6D0E3D2AB1F17900353087 /* Frameworks */ = { 74 | isa = PBXFrameworksBuildPhase; 75 | buildActionMask = 2147483647; 76 | files = ( 77 | ); 78 | runOnlyForDeploymentPostprocessing = 0; 79 | }; 80 | 8C6D0E572AB1F17B00353087 /* Frameworks */ = { 81 | isa = PBXFrameworksBuildPhase; 82 | buildActionMask = 2147483647; 83 | files = ( 84 | 8CE3D95A2ACA0FAB00401224 /* Sisyphos in Frameworks */, 85 | 8C7D424A2ABE437F00FA8892 /* XCTest.framework in Frameworks */, 86 | 8C6D0E712AB1F2B700353087 /* Sisyphos in Frameworks */, 87 | ); 88 | runOnlyForDeploymentPostprocessing = 0; 89 | }; 90 | /* End PBXFrameworksBuildPhase section */ 91 | 92 | /* Begin PBXGroup section */ 93 | 8C6D0E372AB1F17900353087 = { 94 | isa = PBXGroup; 95 | children = ( 96 | 8C6BEDFA2AC0CB0800D4B0CD /* TestCodeGenerator.swift */, 97 | 8C6D0E6D2AB1F29400353087 /* Packages */, 98 | 8C6D0E422AB1F17900353087 /* SisyphosTestapp */, 99 | 8C6D0E5D2AB1F17B00353087 /* SisyphosTests */, 100 | 8C6D0E412AB1F17900353087 /* Products */, 101 | 8C6D0E6F2AB1F2B700353087 /* Frameworks */, 102 | ); 103 | sourceTree = ""; 104 | }; 105 | 8C6D0E412AB1F17900353087 /* Products */ = { 106 | isa = PBXGroup; 107 | children = ( 108 | 8C6D0E402AB1F17900353087 /* SisyphosTestapp.app */, 109 | 8C6D0E5A2AB1F17B00353087 /* SisyphosTests.xctest */, 110 | ); 111 | name = Products; 112 | sourceTree = ""; 113 | }; 114 | 8C6D0E422AB1F17900353087 /* SisyphosTestapp */ = { 115 | isa = PBXGroup; 116 | children = ( 117 | 8CD4FE7A2AC6087F00AB9B37 /* Info.plist */, 118 | 8C6D0E432AB1F17900353087 /* SisyphosTestapp.swift */, 119 | 8C5580BA2AC0F15F00222860 /* Generated.swift */, 120 | 8C6D0E472AB1F17A00353087 /* Assets.xcassets */, 121 | ); 122 | path = SisyphosTestapp; 123 | sourceTree = ""; 124 | }; 125 | 8C6D0E5D2AB1F17B00353087 /* SisyphosTests */ = { 126 | isa = PBXGroup; 127 | children = ( 128 | 8C7D424B2ABE463D00FA8892 /* Meta */, 129 | 8C19AF762AC9F77A00414A52 /* AlertTests.swift */, 130 | 8C5580BC2AC1694B00222860 /* ButtonTests.swift */, 131 | 8CE3D95C2ACA146400401224 /* CollectionViewTests.swift */, 132 | 8C19AF782AC9F9FA00414A52 /* NavigationBarTests.swift */, 133 | 8C19AF7A2ACA011000414A52 /* SecureTextFieldTests.swift */, 134 | 8C19AF7C2ACA09F700414A52 /* StaticTextTests.swift */, 135 | 8C19AF722AC9B7F800414A52 /* SwitchTests.swift */, 136 | 8C6D0E5E2AB1F17B00353087 /* TabBarTests.swift */, 137 | 8C80C2F22AC17DEC0031F00C /* TextFieldTests.swift */, 138 | 8C1404342AC1AD5A0064343C /* PageExistsErrorTests.swift */, 139 | 8CCF7FED2AC2144F00A044CA /* PageHierarchyTests.swift */, 140 | 8C0431E32AC36F2700727A89 /* InterruptionTests.swift */, 141 | 8C19AF742AC9C02000414A52 /* TestDataTests.swift */, 142 | C31078B62AE0414200312CD6 /* OtherTests.swift */, 143 | ); 144 | path = SisyphosTests; 145 | sourceTree = ""; 146 | }; 147 | 8C6D0E6D2AB1F29400353087 /* Packages */ = { 148 | isa = PBXGroup; 149 | children = ( 150 | ); 151 | name = Packages; 152 | sourceTree = ""; 153 | }; 154 | 8C6D0E6F2AB1F2B700353087 /* Frameworks */ = { 155 | isa = PBXGroup; 156 | children = ( 157 | 8C7D42492ABE437F00FA8892 /* XCTest.framework */, 158 | ); 159 | name = Frameworks; 160 | sourceTree = ""; 161 | }; 162 | 8C7D424B2ABE463D00FA8892 /* Meta */ = { 163 | isa = PBXGroup; 164 | children = ( 165 | 8C6BEDF82AC0C8DD00D4B0CD /* XCTestCase+LaunchApp.swift */, 166 | 8C80C2F42AC185340031F00C /* CommonHelpers.swift */, 167 | ); 168 | path = Meta; 169 | sourceTree = ""; 170 | }; 171 | /* End PBXGroup section */ 172 | 173 | /* Begin PBXNativeTarget section */ 174 | 8C6D0E3F2AB1F17900353087 /* SisyphosTestapp */ = { 175 | isa = PBXNativeTarget; 176 | buildConfigurationList = 8C6D0E642AB1F17B00353087 /* Build configuration list for PBXNativeTarget "SisyphosTestapp" */; 177 | buildPhases = ( 178 | 8CE3D95B2ACA107100401224 /* Generate Test Code */, 179 | 8C6D0E3C2AB1F17900353087 /* Sources */, 180 | 8C6D0E3D2AB1F17900353087 /* Frameworks */, 181 | 8C6D0E3E2AB1F17900353087 /* Resources */, 182 | ); 183 | buildRules = ( 184 | ); 185 | dependencies = ( 186 | ); 187 | name = SisyphosTestapp; 188 | packageProductDependencies = ( 189 | ); 190 | productName = SisyphosTestapp; 191 | productReference = 8C6D0E402AB1F17900353087 /* SisyphosTestapp.app */; 192 | productType = "com.apple.product-type.application"; 193 | }; 194 | 8C6D0E592AB1F17B00353087 /* SisyphosTests */ = { 195 | isa = PBXNativeTarget; 196 | buildConfigurationList = 8C6D0E6A2AB1F17B00353087 /* Build configuration list for PBXNativeTarget "SisyphosTests" */; 197 | buildPhases = ( 198 | 8C6D0E562AB1F17B00353087 /* Sources */, 199 | 8C6D0E572AB1F17B00353087 /* Frameworks */, 200 | 8C6D0E582AB1F17B00353087 /* Resources */, 201 | ); 202 | buildRules = ( 203 | ); 204 | dependencies = ( 205 | 8C6D0E5C2AB1F17B00353087 /* PBXTargetDependency */, 206 | ); 207 | name = SisyphosTests; 208 | packageProductDependencies = ( 209 | 8C6D0E702AB1F2B700353087 /* Sisyphos */, 210 | 8CE3D9592ACA0FAB00401224 /* Sisyphos */, 211 | ); 212 | productName = SisyphosTestappUITests; 213 | productReference = 8C6D0E5A2AB1F17B00353087 /* SisyphosTests.xctest */; 214 | productType = "com.apple.product-type.bundle.ui-testing"; 215 | }; 216 | /* End PBXNativeTarget section */ 217 | 218 | /* Begin PBXProject section */ 219 | 8C6D0E382AB1F17900353087 /* Project object */ = { 220 | isa = PBXProject; 221 | attributes = { 222 | BuildIndependentTargetsInParallel = 1; 223 | LastSwiftUpdateCheck = 1430; 224 | LastUpgradeCheck = 1430; 225 | TargetAttributes = { 226 | 8C6D0E3F2AB1F17900353087 = { 227 | CreatedOnToolsVersion = 14.3.1; 228 | }; 229 | 8C6D0E592AB1F17B00353087 = { 230 | CreatedOnToolsVersion = 14.3.1; 231 | TestTargetID = 8C6D0E3F2AB1F17900353087; 232 | }; 233 | }; 234 | }; 235 | buildConfigurationList = 8C6D0E3B2AB1F17900353087 /* Build configuration list for PBXProject "SisyphosTestapp" */; 236 | compatibilityVersion = "Xcode 14.0"; 237 | developmentRegion = en; 238 | hasScannedForEncodings = 0; 239 | knownRegions = ( 240 | en, 241 | Base, 242 | ); 243 | mainGroup = 8C6D0E372AB1F17900353087; 244 | packageReferences = ( 245 | 8CE3D9582ACA0FAB00401224 /* XCLocalSwiftPackageReference "../.." */, 246 | ); 247 | productRefGroup = 8C6D0E412AB1F17900353087 /* Products */; 248 | projectDirPath = ""; 249 | projectRoot = ""; 250 | targets = ( 251 | 8C6D0E3F2AB1F17900353087 /* SisyphosTestapp */, 252 | 8C6D0E592AB1F17B00353087 /* SisyphosTests */, 253 | ); 254 | }; 255 | /* End PBXProject section */ 256 | 257 | /* Begin PBXResourcesBuildPhase section */ 258 | 8C6D0E3E2AB1F17900353087 /* Resources */ = { 259 | isa = PBXResourcesBuildPhase; 260 | buildActionMask = 2147483647; 261 | files = ( 262 | 8C6D0E482AB1F17A00353087 /* Assets.xcassets in Resources */, 263 | ); 264 | runOnlyForDeploymentPostprocessing = 0; 265 | }; 266 | 8C6D0E582AB1F17B00353087 /* Resources */ = { 267 | isa = PBXResourcesBuildPhase; 268 | buildActionMask = 2147483647; 269 | files = ( 270 | ); 271 | runOnlyForDeploymentPostprocessing = 0; 272 | }; 273 | /* End PBXResourcesBuildPhase section */ 274 | 275 | /* Begin PBXShellScriptBuildPhase section */ 276 | 8CE3D95B2ACA107100401224 /* Generate Test Code */ = { 277 | isa = PBXShellScriptBuildPhase; 278 | alwaysOutOfDate = 1; 279 | buildActionMask = 2147483647; 280 | files = ( 281 | ); 282 | inputFileListPaths = ( 283 | ); 284 | inputPaths = ( 285 | ); 286 | name = "Generate Test Code"; 287 | outputFileListPaths = ( 288 | ); 289 | outputPaths = ( 290 | ); 291 | runOnlyForDeploymentPostprocessing = 0; 292 | shellPath = /bin/sh; 293 | shellScript = "${PROJECT_DIR}/TestCodeGenerator.swift\n"; 294 | }; 295 | /* End PBXShellScriptBuildPhase section */ 296 | 297 | /* Begin PBXSourcesBuildPhase section */ 298 | 8C6D0E3C2AB1F17900353087 /* Sources */ = { 299 | isa = PBXSourcesBuildPhase; 300 | buildActionMask = 2147483647; 301 | files = ( 302 | 8C80C2F52AC185340031F00C /* CommonHelpers.swift in Sources */, 303 | 8C5580BB2AC0F15F00222860 /* Generated.swift in Sources */, 304 | 8C6D0E442AB1F17900353087 /* SisyphosTestapp.swift in Sources */, 305 | ); 306 | runOnlyForDeploymentPostprocessing = 0; 307 | }; 308 | 8C6D0E562AB1F17B00353087 /* Sources */ = { 309 | isa = PBXSourcesBuildPhase; 310 | buildActionMask = 2147483647; 311 | files = ( 312 | 8C19AF772AC9F77A00414A52 /* AlertTests.swift in Sources */, 313 | C31078B72AE0414200312CD6 /* OtherTests.swift in Sources */, 314 | 8CE3D95D2ACA146400401224 /* CollectionViewTests.swift in Sources */, 315 | 8C0431E42AC36F2700727A89 /* InterruptionTests.swift in Sources */, 316 | 8C19AF732AC9B7F800414A52 /* SwitchTests.swift in Sources */, 317 | 8C19AF792AC9F9FA00414A52 /* NavigationBarTests.swift in Sources */, 318 | 8C5580BD2AC1694B00222860 /* ButtonTests.swift in Sources */, 319 | 8C6D0E5F2AB1F17B00353087 /* TabBarTests.swift in Sources */, 320 | 8C6BEDF92AC0C8DD00D4B0CD /* XCTestCase+LaunchApp.swift in Sources */, 321 | 8C1404352AC1AD5A0064343C /* PageExistsErrorTests.swift in Sources */, 322 | 8C19AF7D2ACA09F700414A52 /* StaticTextTests.swift in Sources */, 323 | 8C80C2F62AC185340031F00C /* CommonHelpers.swift in Sources */, 324 | 8C19AF752AC9C02000414A52 /* TestDataTests.swift in Sources */, 325 | 8C19AF7B2ACA011000414A52 /* SecureTextFieldTests.swift in Sources */, 326 | 8C80C2F32AC17DEC0031F00C /* TextFieldTests.swift in Sources */, 327 | 8CCF7FEE2AC2144F00A044CA /* PageHierarchyTests.swift in Sources */, 328 | ); 329 | runOnlyForDeploymentPostprocessing = 0; 330 | }; 331 | /* End PBXSourcesBuildPhase section */ 332 | 333 | /* Begin PBXTargetDependency section */ 334 | 8C6D0E5C2AB1F17B00353087 /* PBXTargetDependency */ = { 335 | isa = PBXTargetDependency; 336 | target = 8C6D0E3F2AB1F17900353087 /* SisyphosTestapp */; 337 | targetProxy = 8C6D0E5B2AB1F17B00353087 /* PBXContainerItemProxy */; 338 | }; 339 | /* End PBXTargetDependency section */ 340 | 341 | /* Begin XCBuildConfiguration section */ 342 | 8C6D0E622AB1F17B00353087 /* Debug */ = { 343 | isa = XCBuildConfiguration; 344 | buildSettings = { 345 | ALWAYS_SEARCH_USER_PATHS = NO; 346 | CLANG_ANALYZER_NONNULL = YES; 347 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 348 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 349 | CLANG_ENABLE_MODULES = YES; 350 | CLANG_ENABLE_OBJC_ARC = YES; 351 | CLANG_ENABLE_OBJC_WEAK = YES; 352 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 353 | CLANG_WARN_BOOL_CONVERSION = YES; 354 | CLANG_WARN_COMMA = YES; 355 | CLANG_WARN_CONSTANT_CONVERSION = YES; 356 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 357 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 358 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 359 | CLANG_WARN_EMPTY_BODY = YES; 360 | CLANG_WARN_ENUM_CONVERSION = YES; 361 | CLANG_WARN_INFINITE_RECURSION = YES; 362 | CLANG_WARN_INT_CONVERSION = YES; 363 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 364 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 365 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 366 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 367 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 368 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 369 | CLANG_WARN_STRICT_PROTOTYPES = YES; 370 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 371 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 372 | CLANG_WARN_UNREACHABLE_CODE = YES; 373 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 374 | COPY_PHASE_STRIP = NO; 375 | DEBUG_INFORMATION_FORMAT = dwarf; 376 | ENABLE_STRICT_OBJC_MSGSEND = YES; 377 | ENABLE_TESTABILITY = YES; 378 | GCC_C_LANGUAGE_STANDARD = gnu11; 379 | GCC_DYNAMIC_NO_PIC = NO; 380 | GCC_NO_COMMON_BLOCKS = YES; 381 | GCC_OPTIMIZATION_LEVEL = 0; 382 | GCC_PREPROCESSOR_DEFINITIONS = ( 383 | "DEBUG=1", 384 | "$(inherited)", 385 | ); 386 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 387 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 388 | GCC_WARN_UNDECLARED_SELECTOR = YES; 389 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 390 | GCC_WARN_UNUSED_FUNCTION = YES; 391 | GCC_WARN_UNUSED_VARIABLE = YES; 392 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 393 | MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; 394 | MTL_FAST_MATH = YES; 395 | ONLY_ACTIVE_ARCH = YES; 396 | SDKROOT = iphoneos; 397 | SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG; 398 | SWIFT_OPTIMIZATION_LEVEL = "-Onone"; 399 | }; 400 | name = Debug; 401 | }; 402 | 8C6D0E632AB1F17B00353087 /* Release */ = { 403 | isa = XCBuildConfiguration; 404 | buildSettings = { 405 | ALWAYS_SEARCH_USER_PATHS = NO; 406 | CLANG_ANALYZER_NONNULL = YES; 407 | CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; 408 | CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; 409 | CLANG_ENABLE_MODULES = YES; 410 | CLANG_ENABLE_OBJC_ARC = YES; 411 | CLANG_ENABLE_OBJC_WEAK = YES; 412 | CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; 413 | CLANG_WARN_BOOL_CONVERSION = YES; 414 | CLANG_WARN_COMMA = YES; 415 | CLANG_WARN_CONSTANT_CONVERSION = YES; 416 | CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; 417 | CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; 418 | CLANG_WARN_DOCUMENTATION_COMMENTS = YES; 419 | CLANG_WARN_EMPTY_BODY = YES; 420 | CLANG_WARN_ENUM_CONVERSION = YES; 421 | CLANG_WARN_INFINITE_RECURSION = YES; 422 | CLANG_WARN_INT_CONVERSION = YES; 423 | CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; 424 | CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; 425 | CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; 426 | CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; 427 | CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; 428 | CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; 429 | CLANG_WARN_STRICT_PROTOTYPES = YES; 430 | CLANG_WARN_SUSPICIOUS_MOVE = YES; 431 | CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; 432 | CLANG_WARN_UNREACHABLE_CODE = YES; 433 | CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; 434 | COPY_PHASE_STRIP = NO; 435 | DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; 436 | ENABLE_NS_ASSERTIONS = NO; 437 | ENABLE_STRICT_OBJC_MSGSEND = YES; 438 | GCC_C_LANGUAGE_STANDARD = gnu11; 439 | GCC_NO_COMMON_BLOCKS = YES; 440 | GCC_WARN_64_TO_32_BIT_CONVERSION = YES; 441 | GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; 442 | GCC_WARN_UNDECLARED_SELECTOR = YES; 443 | GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; 444 | GCC_WARN_UNUSED_FUNCTION = YES; 445 | GCC_WARN_UNUSED_VARIABLE = YES; 446 | IPHONEOS_DEPLOYMENT_TARGET = 16.4; 447 | MTL_ENABLE_DEBUG_INFO = NO; 448 | MTL_FAST_MATH = YES; 449 | SDKROOT = iphoneos; 450 | SWIFT_COMPILATION_MODE = wholemodule; 451 | SWIFT_OPTIMIZATION_LEVEL = "-O"; 452 | VALIDATE_PRODUCT = YES; 453 | }; 454 | name = Release; 455 | }; 456 | 8C6D0E652AB1F17B00353087 /* Debug */ = { 457 | isa = XCBuildConfiguration; 458 | buildSettings = { 459 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 460 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 461 | CODE_SIGN_STYLE = Automatic; 462 | CURRENT_PROJECT_VERSION = 1; 463 | DEVELOPMENT_TEAM = ""; 464 | ENABLE_PREVIEWS = YES; 465 | GENERATE_INFOPLIST_FILE = YES; 466 | INFOPLIST_FILE = SisyphosTestapp/Info.plist; 467 | INFOPLIST_KEY_CFBundleDisplayName = SisyphosTestApp; 468 | INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "For testing purposes"; 469 | INFOPLIST_KEY_NSUserTrackingUsageDescription = "For testing purposes"; 470 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 471 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 472 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 473 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 474 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 475 | LD_RUNPATH_SEARCH_PATHS = ( 476 | "$(inherited)", 477 | "@executable_path/Frameworks", 478 | ); 479 | MARKETING_VERSION = 1.0; 480 | PRODUCT_BUNDLE_IDENTIFIER = net.stuehrk.lukas.sisyphos.SisyphosTestapp; 481 | PRODUCT_NAME = "$(TARGET_NAME)"; 482 | SWIFT_EMIT_LOC_STRINGS = YES; 483 | SWIFT_VERSION = 5.0; 484 | TARGETED_DEVICE_FAMILY = "1,2"; 485 | }; 486 | name = Debug; 487 | }; 488 | 8C6D0E662AB1F17B00353087 /* Release */ = { 489 | isa = XCBuildConfiguration; 490 | buildSettings = { 491 | ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; 492 | ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; 493 | CODE_SIGN_STYLE = Automatic; 494 | CURRENT_PROJECT_VERSION = 1; 495 | DEVELOPMENT_TEAM = ""; 496 | ENABLE_PREVIEWS = YES; 497 | GENERATE_INFOPLIST_FILE = YES; 498 | INFOPLIST_FILE = SisyphosTestapp/Info.plist; 499 | INFOPLIST_KEY_CFBundleDisplayName = SisyphosTestApp; 500 | INFOPLIST_KEY_NSPhotoLibraryAddUsageDescription = "For testing purposes"; 501 | INFOPLIST_KEY_NSUserTrackingUsageDescription = "For testing purposes"; 502 | INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; 503 | INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; 504 | INFOPLIST_KEY_UILaunchScreen_Generation = YES; 505 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 506 | INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; 507 | LD_RUNPATH_SEARCH_PATHS = ( 508 | "$(inherited)", 509 | "@executable_path/Frameworks", 510 | ); 511 | MARKETING_VERSION = 1.0; 512 | PRODUCT_BUNDLE_IDENTIFIER = net.stuehrk.lukas.sisyphos.SisyphosTestapp; 513 | PRODUCT_NAME = "$(TARGET_NAME)"; 514 | SWIFT_EMIT_LOC_STRINGS = YES; 515 | SWIFT_VERSION = 5.0; 516 | TARGETED_DEVICE_FAMILY = "1,2"; 517 | }; 518 | name = Release; 519 | }; 520 | 8C6D0E6B2AB1F17B00353087 /* Debug */ = { 521 | isa = XCBuildConfiguration; 522 | buildSettings = { 523 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 524 | CODE_SIGN_STYLE = Automatic; 525 | CURRENT_PROJECT_VERSION = 1; 526 | DEVELOPMENT_TEAM = ""; 527 | GENERATE_INFOPLIST_FILE = YES; 528 | MARKETING_VERSION = 1.0; 529 | PRODUCT_BUNDLE_IDENTIFIER = net.stuehrk.lukas.sisyphos.SisyphosTestappUITests; 530 | PRODUCT_NAME = "$(TARGET_NAME)"; 531 | SWIFT_EMIT_LOC_STRINGS = NO; 532 | SWIFT_VERSION = 5.0; 533 | TARGETED_DEVICE_FAMILY = "1,2"; 534 | TEST_TARGET_NAME = SisyphosTestapp; 535 | }; 536 | name = Debug; 537 | }; 538 | 8C6D0E6C2AB1F17B00353087 /* Release */ = { 539 | isa = XCBuildConfiguration; 540 | buildSettings = { 541 | ALWAYS_EMBED_SWIFT_STANDARD_LIBRARIES = YES; 542 | CODE_SIGN_STYLE = Automatic; 543 | CURRENT_PROJECT_VERSION = 1; 544 | DEVELOPMENT_TEAM = ""; 545 | GENERATE_INFOPLIST_FILE = YES; 546 | MARKETING_VERSION = 1.0; 547 | PRODUCT_BUNDLE_IDENTIFIER = net.stuehrk.lukas.sisyphos.SisyphosTestappUITests; 548 | PRODUCT_NAME = "$(TARGET_NAME)"; 549 | SWIFT_EMIT_LOC_STRINGS = NO; 550 | SWIFT_VERSION = 5.0; 551 | TARGETED_DEVICE_FAMILY = "1,2"; 552 | TEST_TARGET_NAME = SisyphosTestapp; 553 | }; 554 | name = Release; 555 | }; 556 | /* End XCBuildConfiguration section */ 557 | 558 | /* Begin XCConfigurationList section */ 559 | 8C6D0E3B2AB1F17900353087 /* Build configuration list for PBXProject "SisyphosTestapp" */ = { 560 | isa = XCConfigurationList; 561 | buildConfigurations = ( 562 | 8C6D0E622AB1F17B00353087 /* Debug */, 563 | 8C6D0E632AB1F17B00353087 /* Release */, 564 | ); 565 | defaultConfigurationIsVisible = 0; 566 | defaultConfigurationName = Release; 567 | }; 568 | 8C6D0E642AB1F17B00353087 /* Build configuration list for PBXNativeTarget "SisyphosTestapp" */ = { 569 | isa = XCConfigurationList; 570 | buildConfigurations = ( 571 | 8C6D0E652AB1F17B00353087 /* Debug */, 572 | 8C6D0E662AB1F17B00353087 /* Release */, 573 | ); 574 | defaultConfigurationIsVisible = 0; 575 | defaultConfigurationName = Release; 576 | }; 577 | 8C6D0E6A2AB1F17B00353087 /* Build configuration list for PBXNativeTarget "SisyphosTests" */ = { 578 | isa = XCConfigurationList; 579 | buildConfigurations = ( 580 | 8C6D0E6B2AB1F17B00353087 /* Debug */, 581 | 8C6D0E6C2AB1F17B00353087 /* Release */, 582 | ); 583 | defaultConfigurationIsVisible = 0; 584 | defaultConfigurationName = Release; 585 | }; 586 | /* End XCConfigurationList section */ 587 | 588 | /* Begin XCLocalSwiftPackageReference section */ 589 | 8CE3D9582ACA0FAB00401224 /* XCLocalSwiftPackageReference "../.." */ = { 590 | isa = XCLocalSwiftPackageReference; 591 | relativePath = ../..; 592 | }; 593 | /* End XCLocalSwiftPackageReference section */ 594 | 595 | /* Begin XCSwiftPackageProductDependency section */ 596 | 8C6D0E702AB1F2B700353087 /* Sisyphos */ = { 597 | isa = XCSwiftPackageProductDependency; 598 | productName = Sisyphos; 599 | }; 600 | 8CE3D9592ACA0FAB00401224 /* Sisyphos */ = { 601 | isa = XCSwiftPackageProductDependency; 602 | productName = Sisyphos; 603 | }; 604 | /* End XCSwiftPackageProductDependency section */ 605 | }; 606 | rootObject = 8C6D0E382AB1F17900353087 /* Project object */; 607 | } 608 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp.xcodeproj/project.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp.xcodeproj/project.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp.xcodeproj/xcshareddata/xcschemes/SisyphosTestapp.xcscheme: -------------------------------------------------------------------------------- 1 | 2 | 5 | 8 | 9 | 15 | 21 | 22 | 23 | 24 | 25 | 31 | 32 | 35 | 41 | 42 | 43 | 44 | 45 | 55 | 57 | 63 | 64 | 65 | 66 | 72 | 74 | 80 | 81 | 82 | 83 | 85 | 86 | 89 | 90 | 91 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp/Assets.xcassets/AccentColor.colorset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "colors" : [ 3 | { 4 | "idiom" : "universal" 5 | } 6 | ], 7 | "info" : { 8 | "author" : "xcode", 9 | "version" : 1 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp/Assets.xcassets/AppIcon.appiconset/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "images" : [ 3 | { 4 | "idiom" : "universal", 5 | "platform" : "ios", 6 | "size" : "1024x1024" 7 | } 8 | ], 9 | "info" : { 10 | "author" : "xcode", 11 | "version" : 1 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp/Assets.xcassets/Contents.json: -------------------------------------------------------------------------------- 1 | { 2 | "info" : { 3 | "author" : "xcode", 4 | "version" : 1 5 | } 6 | } 7 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp/Generated.swift: -------------------------------------------------------------------------------- 1 | /// ⚠️ AUTOGENERATED CODE. DO NOT EDIT. 2 | import SwiftUI 3 | import Photos 4 | 5 | 6 | func registerTestViews() { 7 | collect(fileName: "/SisyphosTests/OtherTests.swift", line: 8) { 8 | 9 | Color.green 10 | .frame(width: 100, height: 100, alignment: .center) 11 | .accessibilityIdentifier("Identifier") 12 | 13 | } 14 | collect(fileName: "/SisyphosTests/OtherTests.swift", line: 24) { 15 | 16 | Color.green 17 | .frame(width: 100, height: 100, alignment: .center) 18 | .accessibilityLabel("Label") 19 | 20 | } 21 | collect(fileName: "/SisyphosTests/OtherTests.swift", line: 40) { 22 | 23 | Color.green 24 | .frame(width: 100, height: 100, alignment: .center) 25 | .accessibilityLabel("Label") 26 | .accessibilityIdentifier("Identifier") 27 | 28 | } 29 | collect(fileName: "/SisyphosTests/OtherTests.swift", line: 57) { 30 | 31 | VStack { 32 | Text("Some Text") 33 | } 34 | .accessibilityElement() 35 | .accessibilityIdentifier("Stack") 36 | 37 | } 38 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 8) { 39 | 40 | TabView { 41 | NavigationStack { 42 | Text("First Text") 43 | Button(action: {}) { 44 | Text("Button") 45 | }.padding() 46 | Text("Second Text") 47 | List([1, 2, 3, 4], id: \.self) { number in 48 | Text("\(number)") 49 | } 50 | .navigationTitle("Test App") 51 | } 52 | .tabItem { 53 | Text("First Tab") 54 | } 55 | 56 | Text("Second Page") 57 | .tabItem { 58 | Text("Second Tab") 59 | } 60 | } 61 | 62 | } 63 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 66) { 64 | 65 | TabView { 66 | NavigationStack { 67 | Text("First Text") 68 | Button(action: {}) { 69 | Text("Button") 70 | }.padding() 71 | Text("Second Text") 72 | List([1, 2, 3, 4], id: \.self) { number in 73 | Text("\(number)") 74 | } 75 | .navigationTitle("Test App") 76 | } 77 | .tabItem { 78 | Text("First Tab") 79 | } 80 | 81 | Text("Second Page") 82 | .tabItem { 83 | Text("Second Tab") 84 | } 85 | } 86 | 87 | } 88 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 108) { 89 | 90 | Text("First Text") 91 | 92 | } 93 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 127) { 94 | 95 | Text("First Text") 96 | Text("Conditional Second Text") 97 | 98 | } 99 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 135) { 100 | 101 | Text("First Text") 102 | 103 | } 104 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 153) { 105 | 106 | Text("First Text") 107 | Text("Second Text") 108 | 109 | } 110 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 162) { 111 | 112 | Text("First Text") 113 | Text("Second Text") 114 | 115 | } 116 | collect(fileName: "/SisyphosTests/PageHierarchyTests.swift", line: 182) { 117 | 118 | Text("First Text") 119 | Text("Alternative Second Text") 120 | 121 | } 122 | collect(fileName: "/SisyphosTests/SwitchTests.swift", line: 8) { 123 | 124 | Toggle("Some Toggle", isOn: binding(initialValue: false)) 125 | 126 | } 127 | collect(fileName: "/SisyphosTests/SwitchTests.swift", line: 22) { 128 | 129 | Toggle("", isOn: binding(initialValue: false)) 130 | .accessibilityIdentifier("the_switch") 131 | 132 | } 133 | collect(fileName: "/SisyphosTests/CollectionViewTests.swift", line: 8) { 134 | 135 | List { 136 | Text("First Cell") 137 | Group { 138 | Text("Second Cell") 139 | Text("Still Second Cell") 140 | } 141 | SwiftUI.Button("Third Cell", action: {}) 142 | } 143 | 144 | } 145 | collect(fileName: "/SisyphosTests/StaticTextTests.swift", line: 8) { 146 | 147 | Text("Hello") 148 | 149 | } 150 | collect(fileName: "/SisyphosTests/StaticTextTests.swift", line: 22) { 151 | 152 | Text("Hello") 153 | 154 | } 155 | collect(fileName: "/SisyphosTests/StaticTextTests.swift", line: 36) { 156 | 157 | Text("Hello") 158 | .accessibilityIdentifier("the_text") 159 | 160 | } 161 | collect(fileName: "/SisyphosTests/StaticTextTests.swift", line: 44) { 162 | 163 | Text("Hello") 164 | 165 | } 166 | collect(fileName: "/SisyphosTests/StaticTextTests.swift", line: 58) { 167 | 168 | Text("Hello") 169 | .accessibilityIdentifier("the_text") 170 | 171 | } 172 | collect(fileName: "/SisyphosTests/SecureTextFieldTests.swift", line: 8) { 173 | 174 | SecureField("Enter password", text: binding(initialValue: "")) 175 | 176 | } 177 | collect(fileName: "/SisyphosTests/SecureTextFieldTests.swift", line: 22) { 178 | 179 | SecureField("Enter password", text: binding(initialValue: "")) 180 | .accessibilityIdentifier("the_securefield") 181 | 182 | } 183 | collect(fileName: "/SisyphosTests/InterruptionTests.swift", line: 17) { 184 | 185 | Button(action: { 186 | UIApplication.shared.accessibilityLabel = "Button was pressed" 187 | }) { 188 | Text("The Button") 189 | } 190 | .alert( 191 | "Some Alert", 192 | isPresented: binding(initialValue: true), 193 | actions: { 194 | Button(action: {}) { Text("OK") } 195 | }, 196 | message: { 197 | Text("This is some alert.") 198 | } 199 | ) 200 | 201 | } 202 | collect(fileName: "/SisyphosTests/InterruptionTests.swift", line: 66) { 203 | 204 | Button(action: { 205 | UIApplication.shared.accessibilityLabel = "Button was pressed" 206 | }) { 207 | Text("The Button") 208 | } 209 | .onAppear { 210 | PHPhotoLibrary.requestAuthorization(for: .addOnly) { _ in } 211 | } 212 | 213 | } 214 | collect(fileName: "/SisyphosTests/InterruptionTests.swift", line: 97) { 215 | 216 | Text("Test for denying") 217 | .onAppear { 218 | PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in 219 | UIApplication.shared.accessibilityLabel = "\(status)" 220 | } 221 | } 222 | 223 | } 224 | collect(fileName: "/SisyphosTests/InterruptionTests.swift", line: 112) { 225 | 226 | Text("Test for denying") 227 | .onAppear { 228 | PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in 229 | UIApplication.shared.accessibilityLabel = "\(status)" 230 | } 231 | } 232 | 233 | } 234 | collect(fileName: "/SisyphosTests/NavigationBarTests.swift", line: 9) { 235 | 236 | NavigationStack { 237 | Text("Welcome!") 238 | .navigationTitle("Hello NavigationBar!") 239 | } 240 | 241 | } 242 | collect(fileName: "/SisyphosTests/NavigationBarTests.swift", line: 28) { 243 | 244 | NavigationStack(path: binding(initialValue: [1, 2, 3, 4])) { 245 | Group { 246 | Text("Initial Page") 247 | NavigationLink("Let's start", value: 1) 248 | } 249 | .navigationTitle("Start") 250 | .navigationDestination(for: Int.self) { number in 251 | Text("Page \(number)") 252 | .navigationTitle("Page \(number)") 253 | NavigationLink("next page", value: number + 1) 254 | } 255 | } 256 | 257 | } 258 | collect(fileName: "/SisyphosTests/AlertTests.swift", line: 8) { 259 | 260 | Text("App") 261 | .alert( 262 | "Some Alert", 263 | isPresented: binding(initialValue: true), 264 | actions: { 265 | Button(action: {}) { Text("OK") } 266 | }, 267 | message: { 268 | Text("This is some alert.") 269 | } 270 | ) 271 | 272 | } 273 | collect(fileName: "/SisyphosTests/ButtonTests.swift", line: 8) { 274 | 275 | SwiftUI.Button(action: {}) { 276 | Text("Some Button") 277 | } 278 | .accessibilityIdentifier("some_button") 279 | 280 | } 281 | collect(fileName: "/SisyphosTests/ButtonTests.swift", line: 25) { 282 | 283 | SwiftUI.Button(action: {}) { 284 | Text("Some Button") 285 | } 286 | .accessibilityIdentifier("some_button") 287 | 288 | } 289 | collect(fileName: "/SisyphosTests/ButtonTests.swift", line: 42) { 290 | 291 | SwiftUI.Button(action: {}) { 292 | Text("Some Button") 293 | } 294 | .accessibilityIdentifier("some_button") 295 | 296 | } 297 | collect(fileName: "/SisyphosTests/ButtonTests.swift", line: 59) { 298 | 299 | SwiftUI.Button(action: { 300 | UIApplication.shared.accessibilityLabel = "Button tapped" 301 | }) { 302 | Text("Some Button") 303 | } 304 | .accessibilityIdentifier("some_button") 305 | 306 | SwiftUI.Button(action: {}) { 307 | Text("Button that should not be matched") 308 | } 309 | 310 | } 311 | collect(fileName: "/SisyphosTests/PageExistsErrorTests.swift", line: 8) { 312 | 313 | Text("Text") 314 | 315 | } 316 | collect(fileName: "/SisyphosTests/PageExistsErrorTests.swift", line: 24) { 317 | 318 | Text("Text") 319 | 320 | } 321 | collect(fileName: "/SisyphosTests/TestDataTests.swift", line: 8) { 322 | 323 | Text("Test: Some Value") 324 | 325 | } 326 | collect(fileName: "/SisyphosTests/TestDataTests.swift", line: 26) { 327 | 328 | SwiftUI.Button("Buy now for 7.77 EUR", action: {}) 329 | 330 | } 331 | collect(fileName: "/SisyphosTests/TextFieldTests.swift", line: 9) { 332 | 333 | SwiftUI.TextField("Enter text", text: appValueBinding()) 334 | .accessibilityIdentifier("the_textfield") 335 | 336 | } 337 | collect(fileName: "/SisyphosTests/TextFieldTests.swift", line: 24) { 338 | 339 | SwiftUI.TextField("Enter text", text: binding(initialValue: "Some Value")) 340 | .accessibilityIdentifier("the_textfield") 341 | 342 | } 343 | collect(fileName: "/SisyphosTests/TextFieldTests.swift", line: 39) { 344 | 345 | Text("Some Text") 346 | SwiftUI.TextField("Enter text", text: appValueBinding()) 347 | .accessibilityIdentifier("the_textfield") 348 | Button("Some button", action: {}) 349 | 350 | } 351 | collect(fileName: "/SisyphosTests/TextFieldTests.swift", line: 56) { 352 | 353 | SwiftUI.TextField("Enter text", text: appValueBinding()) 354 | 355 | } 356 | collect(fileName: "/SisyphosTests/TextFieldTests.swift", line: 76) { 357 | 358 | SwiftUI.TextField("Enter text", text: appValueBinding()) 359 | 360 | } 361 | collect(fileName: "/SisyphosTests/TabBarTests.swift", line: 9) { 362 | 363 | TabView { 364 | Text("First Screen") 365 | .tabItem { 366 | Text("First") 367 | } 368 | Text("Second Screen") 369 | .tabItem { 370 | Text("Second") 371 | } 372 | } 373 | 374 | } 375 | collect(fileName: "/SisyphosTests/TabBarTests.swift", line: 36) { 376 | 377 | TabView { 378 | Text("First Screen") 379 | .tabItem { 380 | Text("First") 381 | } 382 | Text("Second Screen") 383 | .tabItem { 384 | Text("Second") 385 | } 386 | } 387 | 388 | } 389 | 390 | } -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTestapp/SisyphosTestapp.swift: -------------------------------------------------------------------------------- 1 | import SwiftUI 2 | 3 | @main 4 | struct SisyphosTestapp: App { 5 | var displayedTestView: AnyView { 6 | registerTestViews() 7 | let fileName = ProcessInfo.processInfo.arguments[1] 8 | let lineNumber = Int(ProcessInfo.processInfo.arguments[2])! 9 | return availableTestViews[Key(fileName: fileName, lineNumber: lineNumber)]! 10 | } 11 | 12 | var body: some Scene { 13 | WindowGroup { 14 | displayedTestView 15 | } 16 | } 17 | } 18 | 19 | 20 | struct Key: Hashable { 21 | let fileName: String 22 | let lineNumber: Int 23 | } 24 | 25 | var availableTestViews: [Key: AnyView] = [:] 26 | 27 | func collect(fileName: String, line: Int, @ViewBuilder view: () -> V) { 28 | let key = Key(fileName: fileName, lineNumber: line) 29 | availableTestViews[key] = AnyView(view()) 30 | } 31 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/AlertTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class AlertTests: XCTestCase { 7 | func testAlertIdentifierByChildren() { 8 | launchTestApp { 9 | Text("App") 10 | .alert( 11 | "Some Alert", 12 | isPresented: binding(initialValue: true), 13 | actions: { 14 | Button(action: {}) { Text("OK") } 15 | }, 16 | message: { 17 | Text("This is some alert.") 18 | } 19 | ) 20 | } 21 | 22 | struct ExpectedPage: Page { 23 | var body: PageDescription { 24 | Alert { 25 | StaticText("Some Alert") 26 | StaticText("This is some alert.") 27 | } 28 | } 29 | } 30 | let expectedPage = ExpectedPage() 31 | expectedPage.waitForExistence() 32 | } 33 | } 34 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/ButtonTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class ButtonTests: XCTestCase { 7 | func testButtonThatHasIdentifierWithLabelIdentifyByIdentifier() { 8 | launchTestApp(swiftUI: { 9 | SwiftUI.Button(action: {}) { 10 | Text("Some Button") 11 | } 12 | .accessibilityIdentifier("some_button") 13 | }) 14 | 15 | struct ExpectedPage: Page { 16 | var body: PageDescription { 17 | Sisyphos.Button(identifier: "some_button") 18 | } 19 | } 20 | let expectedPage = ExpectedPage() 21 | expectedPage.waitForExistence() 22 | } 23 | 24 | func testButtonThatHasIdentifierWithLabelIdentifyByLabel() { 25 | launchTestApp(swiftUI: { 26 | SwiftUI.Button(action: {}) { 27 | Text("Some Button") 28 | } 29 | .accessibilityIdentifier("some_button") 30 | }) 31 | 32 | struct ExpectedPage: Page { 33 | var body: PageDescription { 34 | Sisyphos.Button(label: "Some Button") 35 | } 36 | } 37 | let expectedPage = ExpectedPage() 38 | expectedPage.waitForExistence() 39 | } 40 | 41 | func testButtonThatHasIdentifierWithLabelIdentifyByLabelAndIdentifier() { 42 | launchTestApp(swiftUI: { 43 | SwiftUI.Button(action: {}) { 44 | Text("Some Button") 45 | } 46 | .accessibilityIdentifier("some_button") 47 | }) 48 | 49 | struct ExpectedPage: Page { 50 | var body: PageDescription { 51 | Sisyphos.Button(identifier: "some_button", label: "Some Button") 52 | } 53 | } 54 | let expectedPage = ExpectedPage() 55 | expectedPage.waitForExistence() 56 | } 57 | 58 | func testButtonInteractionTap() { 59 | let app = launchTestApp(swiftUI: { 60 | SwiftUI.Button(action: { 61 | UIApplication.shared.accessibilityLabel = "Button tapped" 62 | }) { 63 | Text("Some Button") 64 | } 65 | .accessibilityIdentifier("some_button") 66 | 67 | SwiftUI.Button(action: {}) { 68 | Text("Button that should not be matched") 69 | } 70 | }) 71 | 72 | struct ExpectedPage: Page { 73 | let button = Sisyphos.Button(identifier: "some_button") 74 | 75 | var body: PageDescription { 76 | button 77 | } 78 | } 79 | let expectedPage = ExpectedPage() 80 | expectedPage.waitForExistence() 81 | 82 | expectedPage.button.tap() 83 | XCTAssertEqual(app.label, "Button tapped") 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/CollectionViewTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class CollectionViewTests: XCTestCase { 7 | func testCollectionView() { 8 | launchTestApp { 9 | List { 10 | Text("First Cell") 11 | Group { 12 | Text("Second Cell") 13 | Text("Still Second Cell") 14 | } 15 | SwiftUI.Button("Third Cell", action: {}) 16 | } 17 | } 18 | 19 | struct ExpectedPage: Page { 20 | var body: PageDescription { 21 | CollectionView { 22 | Cell { 23 | StaticText("First Cell") 24 | } 25 | Cell { 26 | StaticText("Second Cell") 27 | StaticText("Still Second Cell") 28 | } 29 | Cell { 30 | Sisyphos.Button(label: "Third Cell") 31 | } 32 | } 33 | } 34 | } 35 | } 36 | } 37 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/InterruptionTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | import Photos 5 | 6 | 7 | final class InterruptionTests: XCTestCase { 8 | 9 | override func setUp() { 10 | super.setUp() 11 | 12 | XCUIApplication().resetAuthorizationStatus(for: .photos) 13 | disableDefaultXCUIInterruptionHandlers() 14 | } 15 | 16 | func testInterruption() { 17 | launchTestApp { 18 | Button(action: { 19 | UIApplication.shared.accessibilityLabel = "Button was pressed" 20 | }) { 21 | Text("The Button") 22 | } 23 | .alert( 24 | "Some Alert", 25 | isPresented: binding(initialValue: true), 26 | actions: { 27 | Button(action: {}) { Text("OK") } 28 | }, 29 | message: { 30 | Text("This is some alert.") 31 | } 32 | ) 33 | } 34 | 35 | struct ExpectedInterruption: Page { 36 | let okButton = Sisyphos.Button(label: "OK") 37 | var body: PageDescription { 38 | Alert { 39 | StaticText("This is some alert.") 40 | okButton 41 | } 42 | } 43 | } 44 | 45 | struct ExpectedPage: Page { 46 | let button = Sisyphos.Button(label: "The Button") 47 | var body: PageDescription { 48 | button 49 | } 50 | } 51 | 52 | var wasInterrupted = false 53 | addUIInterruptionMonitor(page: ExpectedInterruption()) { page in 54 | wasInterrupted = true 55 | page.okButton.tap() 56 | } 57 | let expectedPage = ExpectedPage() 58 | expectedPage.waitForExistence() 59 | 60 | expectedPage.button.tap() 61 | 62 | XCTAssertTrue(wasInterrupted) 63 | } 64 | 65 | func testSystemPromptWithDefaultHandler() { 66 | launchTestApp { 67 | Button(action: { 68 | UIApplication.shared.accessibilityLabel = "Button was pressed" 69 | }) { 70 | Text("The Button") 71 | } 72 | .onAppear { 73 | PHPhotoLibrary.requestAuthorization(for: .addOnly) { _ in } 74 | } 75 | } 76 | 77 | var wasInterrupted: Bool = false 78 | addUIInterruptionMonitor(page: DefaultPermissionAlert()) { permissionAlert in 79 | wasInterrupted = true 80 | permissionAlert.disallowButton.tap() 81 | } 82 | 83 | struct ExpectedPage: Page { 84 | let button = Sisyphos.Button(label: "The Button") 85 | var body: PageDescription { 86 | button 87 | } 88 | } 89 | let expectedPage = ExpectedPage() 90 | expectedPage.waitForExistence() 91 | expectedPage.button.tap() 92 | 93 | XCTAssertTrue(wasInterrupted) 94 | } 95 | 96 | func testDefaultPermissionPromptTapDeny() { 97 | let app = launchTestApp { 98 | Text("Test for denying") 99 | .onAppear { 100 | PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in 101 | UIApplication.shared.accessibilityLabel = "\(status)" 102 | } 103 | } 104 | } 105 | let permissionAlert = DefaultPermissionAlert() 106 | permissionAlert.waitForExistence() 107 | permissionAlert.disallowButton.tap() 108 | XCTAssertEqual("\(PHAuthorizationStatus.denied)", app.label) 109 | } 110 | 111 | func testDefaultPermissionPromptTapAllow() { 112 | let app = launchTestApp { 113 | Text("Test for denying") 114 | .onAppear { 115 | PHPhotoLibrary.requestAuthorization(for: .addOnly) { status in 116 | UIApplication.shared.accessibilityLabel = "\(status)" 117 | } 118 | } 119 | } 120 | let permissionAlert = DefaultPermissionAlert() 121 | permissionAlert.waitForExistence() 122 | permissionAlert.allowButton.tap() 123 | XCTAssertEqual("\(PHAuthorizationStatus.authorized)", app.label) 124 | } 125 | } 126 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/Meta/CommonHelpers.swift: -------------------------------------------------------------------------------- 1 | /// This file contains helpers that are required in both the test app (because there the functionality is needed) and 2 | /// the tests target (because it still needs to compile when we define the SwiftUI views in the tests). Because of this, 3 | /// this file is part of both targets. 4 | import SwiftUI 5 | 6 | 7 | /// A binding that uses `UIApplication.shared` as backing for its value. This is handy to read values in the test. 8 | func appValueBinding(initialValue: String = "") -> Binding { 9 | UIApplication.shared.accessibilityLabel = initialValue 10 | return Binding( 11 | get: { UIApplication.shared.accessibilityLabel ?? ""}, 12 | set: { value, _ in 13 | UIApplication.shared.accessibilityLabel = value 14 | } 15 | ) 16 | } 17 | 18 | 19 | func binding(initialValue: Value) -> Binding { 20 | var store: Value = initialValue 21 | return Binding( 22 | get: { store }, 23 | set: { value, _ in 24 | store = value 25 | } 26 | ) 27 | } 28 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/Meta/XCTestCase+LaunchApp.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | 4 | 5 | extension XCTestCase { 6 | /// Launches the test app and the test app will display the given SwiftUI view hierarchy. 7 | /// 8 | /// > Note: It's not really possible to send SwiftUI view instances to the test app. The test app runs in a 9 | /// different process. So this is a hack on top of a hack. What we do is that we simply take the file and 10 | /// line number from where this method is called and pass it to the test app as launch arguments. The test app 11 | /// knows the source code of the SwiftUI views that were defined in this file at this line in this method call. 12 | /// It knows it from the `TestCodeGenerator.swift` parser which parses the source code of all SwiftUI views in 13 | /// the tests and copies them together with the meta information (file, line) into the test app. So the tests 14 | /// and the test app both contain identical copies of the source of the SwiftUI views. Then the test app can 15 | /// create instances of the SwiftUI views that have the exact same syntax. 16 | @discardableResult 17 | func launchTestApp( 18 | @ViewBuilder swiftUI: () -> V, 19 | file: StaticString = #file, 20 | line: UInt = #line 21 | ) -> XCUIApplication { 22 | let rootDirectory = URL(fileURLWithPath: #filePath) 23 | .deletingLastPathComponent() 24 | .deletingLastPathComponent() 25 | .deletingLastPathComponent() 26 | .path 27 | let fileString = String(describing: file) 28 | guard fileString.hasPrefix(rootDirectory) else { preconditionFailure() } 29 | let app = XCUIApplication() 30 | app.launchArguments = [String(fileString.dropFirst(rootDirectory.count)), String(line)] 31 | app.launch() 32 | 33 | return app 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/NavigationBarTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class NavigationBarTests: XCTestCase { 7 | 8 | func testNavigationBarIdentifiedWithContents() { 9 | launchTestApp { 10 | NavigationStack { 11 | Text("Welcome!") 12 | .navigationTitle("Hello NavigationBar!") 13 | } 14 | } 15 | 16 | struct ExpectedPage: Page { 17 | var body: PageDescription { 18 | NavigationBar { 19 | StaticText("Hello NavigationBar!") 20 | } 21 | } 22 | } 23 | let expectedPage = ExpectedPage() 24 | expectedPage.waitForExistence() 25 | } 26 | 27 | func testNavigationBarInteraction() { 28 | launchTestApp { 29 | NavigationStack(path: binding(initialValue: [1, 2, 3, 4])) { 30 | Group { 31 | Text("Initial Page") 32 | NavigationLink("Let's start", value: 1) 33 | } 34 | .navigationTitle("Start") 35 | .navigationDestination(for: Int.self) { number in 36 | Text("Page \(number)") 37 | .navigationTitle("Page \(number)") 38 | NavigationLink("next page", value: number + 1) 39 | } 40 | } 41 | } 42 | 43 | struct ExpectedPage4: Page { 44 | let backButton = Sisyphos.Button(label: "Page 3") 45 | var body: PageDescription { 46 | NavigationBar { 47 | backButton 48 | StaticText("Page 4") 49 | } 50 | } 51 | } 52 | let expectedPage4 = ExpectedPage4() 53 | expectedPage4.waitForExistence() 54 | expectedPage4.backButton.tap() 55 | 56 | struct ExpectedPage3: Page { 57 | let backButton = Sisyphos.Button(label: "Page 2") 58 | var body: PageDescription { 59 | NavigationBar { 60 | backButton 61 | StaticText("Page 3") 62 | } 63 | } 64 | } 65 | let expectedPage3 = ExpectedPage3() 66 | expectedPage3.waitForExistence() 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/OtherTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | @testable import Sisyphos 4 | 5 | 6 | final class OtherTests: XCTestCase { 7 | func testOtherElementCapturedWithIdentifier() { 8 | launchTestApp { 9 | Color.green 10 | .frame(width: 100, height: 100, alignment: .center) 11 | .accessibilityIdentifier("Identifier") 12 | } 13 | 14 | struct ExpectedPage: Page { 15 | var body: PageDescription { 16 | Other(identifier: "Identifier") 17 | } 18 | } 19 | let expectedPage = ExpectedPage() 20 | expectedPage.waitForExistence() 21 | } 22 | 23 | func testOtherElementCapturedWithLabel() { 24 | launchTestApp { 25 | Color.green 26 | .frame(width: 100, height: 100, alignment: .center) 27 | .accessibilityLabel("Label") 28 | } 29 | 30 | struct ExpectedPage: Page { 31 | var body: PageDescription { 32 | Other(label: "Label") 33 | } 34 | } 35 | let expectedPage = ExpectedPage() 36 | expectedPage.waitForExistence() 37 | } 38 | 39 | func testOtherElementCapturedWithBothLabelAndIdentifier() { 40 | launchTestApp { 41 | Color.green 42 | .frame(width: 100, height: 100, alignment: .center) 43 | .accessibilityLabel("Label") 44 | .accessibilityIdentifier("Identifier") 45 | } 46 | 47 | struct ExpectedPage: Page { 48 | var body: PageDescription { 49 | Other(identifier: "Identifier", label: "Label") 50 | } 51 | } 52 | let expectedPage = ExpectedPage() 53 | expectedPage.waitForExistence() 54 | } 55 | 56 | func testOtherElementWithChildren() { 57 | launchTestApp { 58 | VStack { 59 | Text("Some Text") 60 | } 61 | .accessibilityElement() 62 | .accessibilityIdentifier("Stack") 63 | } 64 | 65 | struct ExpectedPage: Page { 66 | var body: PageDescription { 67 | Other(identifier: "Stack") { 68 | StaticText("Some Text") 69 | } 70 | } 71 | } 72 | let expectedPage = ExpectedPage() 73 | expectedPage.waitForExistence() 74 | } 75 | } 76 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/PageExistsErrorTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class PageExistsErrorTests: XCTestCase { 7 | func testMissingElementAbove() { 8 | launchTestApp { 9 | Text("Text") 10 | } 11 | 12 | struct NotExpectedPage: Page { 13 | var body: PageDescription { 14 | Button(label: "Button") 15 | StaticText("Text") 16 | } 17 | } 18 | XCTExpectFailure(options: createAssertionOptions(missingElement: "Button", definedAtOffset: -4, column: 23)) 19 | let notExpectedPage = NotExpectedPage() 20 | notExpectedPage.waitForExistence(timeout: 1) 21 | } 22 | 23 | func testMissingElementBelow() { 24 | launchTestApp { 25 | Text("Text") 26 | } 27 | 28 | struct NotExpectedPage: Page { 29 | var body: PageDescription { 30 | StaticText("Text") 31 | Button(label: "Button") 32 | } 33 | } 34 | XCTExpectFailure(options: createAssertionOptions(missingElement: "Button", definedAtOffset: -3, column: 23)) 35 | let notExpectedPage = NotExpectedPage() 36 | notExpectedPage.waitForExistence(timeout: 1) 37 | } 38 | } 39 | 40 | 41 | private func createAssertionOptions( 42 | missingElement: String, 43 | definedAtOffset lineOffset: Int, 44 | column: UInt, 45 | file: StaticString = #file, 46 | line: UInt = #line 47 | ) -> XCTExpectedFailure.Options { 48 | let expectedLine: UInt 49 | if lineOffset > 0 { 50 | expectedLine = line + UInt(lineOffset) 51 | } else { 52 | expectedLine = line - UInt(abs(lineOffset)) 53 | } 54 | let expectedText = "failed - Page NotExpectedPage didn\'t exist after 1.0s\n⛔️ missing element \(missingElement), defined at \(file) \(expectedLine):\(column)" 55 | let options = XCTExpectedFailure.Options() 56 | options.issueMatcher = { issue in 57 | return issue.type == .assertionFailure 58 | && issue.compactDescription == expectedText 59 | } 60 | return options 61 | } 62 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/PageHierarchyTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class PageHierarchyTests: XCTestCase { 7 | func testComplexHierarchy() { 8 | launchTestApp { 9 | TabView { 10 | NavigationStack { 11 | Text("First Text") 12 | Button(action: {}) { 13 | Text("Button") 14 | }.padding() 15 | Text("Second Text") 16 | List([1, 2, 3, 4], id: \.self) { number in 17 | Text("\(number)") 18 | } 19 | .navigationTitle("Test App") 20 | } 21 | .tabItem { 22 | Text("First Tab") 23 | } 24 | 25 | Text("Second Page") 26 | .tabItem { 27 | Text("Second Tab") 28 | } 29 | } 30 | } 31 | 32 | struct ExpectedPage: Page { 33 | var body: PageDescription { 34 | NavigationBar { 35 | StaticText("Test App") 36 | } 37 | StaticText("First Text") 38 | Button(label: "Button") 39 | StaticText("Second Text") 40 | CollectionView { 41 | Cell { 42 | StaticText("1") 43 | } 44 | Cell { 45 | StaticText("2") 46 | } 47 | Cell { 48 | StaticText("3") 49 | } 50 | Cell { 51 | StaticText("4") 52 | } 53 | } 54 | TabBar { 55 | Button(label: "First Tab") 56 | Button(label: "Second Tab") 57 | } 58 | } 59 | 60 | } 61 | let expectedPage = ExpectedPage() 62 | expectedPage.waitForExistence() 63 | } 64 | 65 | func testHierarchyWithOmmittedElements() { 66 | launchTestApp { 67 | TabView { 68 | NavigationStack { 69 | Text("First Text") 70 | Button(action: {}) { 71 | Text("Button") 72 | }.padding() 73 | Text("Second Text") 74 | List([1, 2, 3, 4], id: \.self) { number in 75 | Text("\(number)") 76 | } 77 | .navigationTitle("Test App") 78 | } 79 | .tabItem { 80 | Text("First Tab") 81 | } 82 | 83 | Text("Second Page") 84 | .tabItem { 85 | Text("Second Tab") 86 | } 87 | } 88 | } 89 | 90 | struct ExpectedPage: Page { 91 | var body: PageDescription { 92 | NavigationBar { 93 | StaticText("Test App") 94 | } 95 | CollectionView { 96 | Cell { 97 | StaticText("3") 98 | } 99 | } 100 | } 101 | 102 | } 103 | let expectedPage = ExpectedPage() 104 | expectedPage.waitForExistence() 105 | } 106 | 107 | func testConditionals() { 108 | launchTestApp { 109 | Text("First Text") 110 | } 111 | 112 | struct ExpectedPage: Page { 113 | var isSomeVariable = false 114 | var body: PageDescription { 115 | StaticText("First Text") 116 | if isSomeVariable { 117 | StaticText("Conditional Second Text") 118 | } 119 | } 120 | } 121 | var expectedPage = ExpectedPage() 122 | expectedPage.waitForExistence() 123 | 124 | expectedPage.isSomeVariable = true 125 | XCTAssertFalse(expectedPage.exists().isExisting) 126 | 127 | launchTestApp { 128 | Text("First Text") 129 | Text("Conditional Second Text") 130 | } 131 | expectedPage.waitForExistence() 132 | } 133 | 134 | func testOptionals() { 135 | launchTestApp { 136 | Text("First Text") 137 | } 138 | 139 | struct ExpectedPage: Page { 140 | var maybeElement: StaticText? 141 | 142 | var body: PageDescription { 143 | StaticText("First Text") 144 | maybeElement 145 | } 146 | } 147 | var expectedPage = ExpectedPage() 148 | expectedPage.waitForExistence() 149 | 150 | expectedPage.maybeElement = StaticText("Second Text") 151 | XCTAssertFalse(expectedPage.exists().isExisting) 152 | 153 | launchTestApp { 154 | Text("First Text") 155 | Text("Second Text") 156 | } 157 | 158 | expectedPage.waitForExistence() 159 | } 160 | 161 | func testIfElse() { 162 | launchTestApp { 163 | Text("First Text") 164 | Text("Second Text") 165 | } 166 | 167 | struct ExpectedPage: Page { 168 | var isAlternative = false 169 | 170 | var body: PageDescription { 171 | StaticText("First Text") 172 | if isAlternative { 173 | StaticText("Alternative Second Text") 174 | } else { 175 | StaticText("Second Text") 176 | } 177 | } 178 | } 179 | var expectedPage = ExpectedPage() 180 | expectedPage.waitForExistence() 181 | 182 | launchTestApp { 183 | Text("First Text") 184 | Text("Alternative Second Text") 185 | } 186 | 187 | expectedPage.isAlternative = true 188 | expectedPage.waitForExistence() 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/SecureTextFieldTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class SecureTextFieldTests: XCTestCase { 7 | func testSecureTextFieldIdentifiedByType() { 8 | launchTestApp { 9 | SecureField("Enter password", text: binding(initialValue: "")) 10 | } 11 | 12 | struct ExpectedPage: Page { 13 | var body: PageDescription { 14 | SecureTextField() 15 | } 16 | } 17 | let expectedPage = ExpectedPage() 18 | expectedPage.waitForExistence() 19 | } 20 | 21 | func testSecureTextFieldIdentifiedByIdentifier() { 22 | launchTestApp { 23 | SecureField("Enter password", text: binding(initialValue: "")) 24 | .accessibilityIdentifier("the_securefield") 25 | } 26 | 27 | struct ExpectedPage: Page { 28 | var body: PageDescription { 29 | SecureTextField(identifier: "the_securefield") 30 | } 31 | } 32 | let expectedPage = ExpectedPage() 33 | expectedPage.waitForExistence() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/StaticTextTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class StaticTextTests: XCTestCase { 7 | func testStaticText() { 8 | launchTestApp { 9 | Text("Hello") 10 | } 11 | 12 | struct ExpectedPage: Page { 13 | var body: PageDescription { 14 | StaticText("Hello") 15 | } 16 | } 17 | let expectedPage = ExpectedPage() 18 | expectedPage.waitForExistence() 19 | } 20 | 21 | func testStaticTextAdditionallyIdentifiedByIdentifier() { 22 | launchTestApp { 23 | Text("Hello") 24 | } 25 | 26 | struct ExpectedPage: Page { 27 | var body: PageDescription { 28 | StaticText(identifier: "the_text", "Hello") 29 | } 30 | } 31 | let expectedPage = ExpectedPage() 32 | XCTExpectFailure("The identifier is missing in the test app") { 33 | expectedPage.waitForExistence(timeout: 2) 34 | } 35 | 36 | launchTestApp { 37 | Text("Hello") 38 | .accessibilityIdentifier("the_text") 39 | } 40 | expectedPage.waitForExistence() 41 | } 42 | 43 | func testStaticTextOnlyIdentifiedByIdentifier() { 44 | launchTestApp { 45 | Text("Hello") 46 | } 47 | 48 | struct ExpectedPage: Page { 49 | var body: PageDescription { 50 | StaticText(identifier: "the_text") 51 | } 52 | } 53 | let expectedPage = ExpectedPage() 54 | XCTExpectFailure("The identifier is missing in the test app") { 55 | expectedPage.waitForExistence(timeout: 2) 56 | } 57 | 58 | launchTestApp { 59 | Text("Hello") 60 | .accessibilityIdentifier("the_text") 61 | } 62 | expectedPage.waitForExistence() 63 | } 64 | } 65 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/SwitchTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class SwitchTests: XCTestCase { 7 | func testSwitchThatHasOnlyLabelIdentifiedByLabel() { 8 | launchTestApp { 9 | Toggle("Some Toggle", isOn: binding(initialValue: false)) 10 | } 11 | 12 | struct ExpectedPage: Page { 13 | var body: PageDescription { 14 | Switch(label: "Some Toggle") 15 | } 16 | } 17 | let expectedPage = ExpectedPage() 18 | expectedPage.waitForExistence() 19 | } 20 | 21 | func testSwitchThatHasOnlyIdentifierIdentifiedByIdentifier() { 22 | launchTestApp { 23 | Toggle("", isOn: binding(initialValue: false)) 24 | .accessibilityIdentifier("the_switch") 25 | } 26 | 27 | struct ExpectedPage: Page { 28 | var body: PageDescription { 29 | Switch(identifier: "the_switch") 30 | } 31 | } 32 | let expectedPage = ExpectedPage() 33 | expectedPage.waitForExistence() 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/TabBarTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import Sisyphos 3 | import SwiftUI 4 | 5 | 6 | final class TabBarTests: XCTestCase { 7 | 8 | func testTabBar() { 9 | launchTestApp(swiftUI: { 10 | TabView { 11 | Text("First Screen") 12 | .tabItem { 13 | Text("First") 14 | } 15 | Text("Second Screen") 16 | .tabItem { 17 | Text("Second") 18 | } 19 | } 20 | }) 21 | 22 | struct ExpectedPage: Page { 23 | var body: PageDescription { 24 | StaticText("First Screen") 25 | TabBar { 26 | Button(label: "First") 27 | Button(label: "Second") 28 | } 29 | } 30 | } 31 | let expectedPage = ExpectedPage() 32 | expectedPage.waitForExistence() 33 | } 34 | 35 | func testTabBarInteraction() { 36 | launchTestApp(swiftUI: { 37 | TabView { 38 | Text("First Screen") 39 | .tabItem { 40 | Text("First") 41 | } 42 | Text("Second Screen") 43 | .tabItem { 44 | Text("Second") 45 | } 46 | } 47 | }) 48 | 49 | struct FirstPage: Page { 50 | let firstTab = Button(label: "First") 51 | let secondTab = Button(label: "Second") 52 | 53 | var body: PageDescription { 54 | StaticText("First Screen") 55 | TabBar { 56 | firstTab 57 | secondTab 58 | } 59 | } 60 | } 61 | struct SecondPage: Page { 62 | let firstTab = Button(label: "First") 63 | let secondTab = Button(label: "Second") 64 | 65 | var body: PageDescription { 66 | StaticText("Second Screen") 67 | TabBar { 68 | firstTab 69 | secondTab 70 | } 71 | } 72 | } 73 | let firstPage = FirstPage() 74 | firstPage.waitForExistence() 75 | firstPage.secondTab.tap() 76 | let secondPage = SecondPage() 77 | secondPage.waitForExistence() 78 | secondPage.firstTab.tap() 79 | firstPage.waitForExistence() 80 | } 81 | } 82 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/TestDataTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class TestDataTests: XCTestCase { 7 | func testTestDataExtractsValueFromStaticText() { 8 | launchTestApp { 9 | Text("Test: Some Value") 10 | } 11 | 12 | struct ExpectedPage: Page { 13 | @TestData var value: String 14 | 15 | var body: PageDescription { 16 | StaticText("Test: \(value)") 17 | } 18 | } 19 | let expectedPage = ExpectedPage() 20 | expectedPage.waitForExistence() 21 | 22 | XCTAssertEqual(expectedPage.value, "Some Value") 23 | } 24 | 25 | func testTestDataExtractsValueFromButton() { 26 | launchTestApp { 27 | SwiftUI.Button("Buy now for 7.77 EUR", action: {}) 28 | } 29 | 30 | struct ExpectedPage: Page { 31 | @TestData var price: String 32 | 33 | var body: PageDescription { 34 | Button(label: "Buy now for \(price)") 35 | } 36 | } 37 | let expectedPage = ExpectedPage() 38 | expectedPage.waitForExistence() 39 | 40 | XCTAssertEqual(expectedPage.price, "7.77 EUR") 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/SisyphosTests/TextFieldTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | import SwiftUI 3 | import Sisyphos 4 | 5 | 6 | final class TextFieldTests: XCTestCase { 7 | 8 | func testTextFieldWithValueAndIdentifierIdentifiedByIdentifier() { 9 | launchTestApp { 10 | SwiftUI.TextField("Enter text", text: appValueBinding()) 11 | .accessibilityIdentifier("the_textfield") 12 | } 13 | 14 | struct ExpectedPage: Page { 15 | var body: PageDescription { 16 | Sisyphos.TextField(identifier: "the_textfield") 17 | } 18 | } 19 | let expectedPage = ExpectedPage() 20 | expectedPage.waitForExistence() 21 | } 22 | 23 | func testTextFieldWithValueAndIdentifierIdentifiedByValue() { 24 | launchTestApp { 25 | SwiftUI.TextField("Enter text", text: binding(initialValue: "Some Value")) 26 | .accessibilityIdentifier("the_textfield") 27 | } 28 | 29 | struct ExpectedPage: Page { 30 | var body: PageDescription { 31 | Sisyphos.TextField(value: "Some Value") 32 | } 33 | } 34 | let expectedPage = ExpectedPage() 35 | expectedPage.waitForExistence() 36 | } 37 | 38 | func testTextFieldWithValueAndIdentifierIdentifiedByTypeOnly() { 39 | launchTestApp { 40 | Text("Some Text") 41 | SwiftUI.TextField("Enter text", text: appValueBinding()) 42 | .accessibilityIdentifier("the_textfield") 43 | Button("Some button", action: {}) 44 | } 45 | 46 | struct ExpectedPage: Page { 47 | var body: PageDescription { 48 | Sisyphos.TextField() 49 | } 50 | } 51 | let expectedPage = ExpectedPage() 52 | expectedPage.waitForExistence() 53 | } 54 | 55 | func testTextFieldInteractionTypeText() { 56 | let app = launchTestApp { 57 | SwiftUI.TextField("Enter text", text: appValueBinding()) 58 | } 59 | 60 | struct ExpectedPage: Page { 61 | let textField = Sisyphos.TextField() 62 | 63 | var body: PageDescription { 64 | textField 65 | } 66 | } 67 | let expectedPage = ExpectedPage() 68 | expectedPage.waitForExistence() 69 | expectedPage.textField.type(text: "Hello from test") 70 | 71 | XCTAssertEqual(app.label, "Hello from test") 72 | XCTAssertEqual(app.textFields.firstMatch.value as? String, "Hello from test") 73 | } 74 | 75 | func testTextFieldInteractionTap() { 76 | let app = launchTestApp { 77 | SwiftUI.TextField("Enter text", text: appValueBinding()) 78 | } 79 | struct ExpectedPage: Page { 80 | let textField = Sisyphos.TextField() 81 | 82 | var body: PageDescription { 83 | textField 84 | } 85 | } 86 | let expectedPage = ExpectedPage() 87 | expectedPage.waitForExistence() 88 | 89 | XCTAssertFalse(app.textFields.firstMatch.hasKeyboardFocus) 90 | expectedPage.textField.tap() 91 | XCTAssertTrue(app.textFields.firstMatch.hasKeyboardFocus) 92 | } 93 | } 94 | 95 | 96 | extension XCUIElement { 97 | var hasKeyboardFocus: Bool { 98 | value(forKey: "hasKeyboardFocus") as? Bool ?? false 99 | } 100 | } 101 | -------------------------------------------------------------------------------- /Tests/SisyphosTestApp/TestCodeGenerator.swift: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env xcrun --sdk macosx swift 2 | import Foundation 3 | 4 | let directory = URL(fileURLWithPath: #filePath).deletingLastPathComponent() 5 | var contents: String = "" 6 | 7 | let enumerator = FileManager.default.enumerator( 8 | at: directory.appending(path: "SisyphosTests"), 9 | includingPropertiesForKeys: [], 10 | options: [.skipsHiddenFiles, .skipsPackageDescendants] 11 | )! 12 | for case let fileUrl as URL in enumerator { 13 | guard fileUrl.lastPathComponent.hasSuffix("Tests.swift") else { 14 | print("Skipping \(fileUrl.lastPathComponent)") 15 | continue 16 | } 17 | print("parsing \(fileUrl.lastPathComponent)") 18 | var parser = Parser(fileUrl: fileUrl) 19 | parser.parse() 20 | contents.append(parser.generatedCode) 21 | } 22 | 23 | try! """ 24 | /// ⚠️ AUTOGENERATED CODE. DO NOT EDIT. 25 | import SwiftUI 26 | import Photos 27 | 28 | 29 | func registerTestViews() { 30 | \(contents) 31 | } 32 | """.write(to: URL(fileURLWithPath: "\(directory.path)/SisyphosTestapp/Generated.swift"), atomically: true, encoding: .utf8) 33 | 34 | struct Scanner { 35 | let contents: String 36 | let lastIndex: String.Index 37 | var index: String.Index 38 | 39 | init(contents: String) { 40 | self.contents = contents 41 | self.index = contents.startIndex 42 | self.lastIndex = contents.index(before: contents.endIndex) 43 | } 44 | 45 | mutating func next() -> Character? { 46 | guard let nextIndex = contents.index(index, offsetBy: 1, limitedBy: lastIndex) else { 47 | return nil 48 | } 49 | index = nextIndex 50 | return contents[nextIndex] 51 | } 52 | 53 | func peek(n: Int) -> Substring { 54 | guard let startIndex = contents.index(index, offsetBy: 1, limitedBy: lastIndex) else { 55 | return contents[index.. String { 155 | guard self.hasPrefix(prefix) else { 156 | assertionFailure() 157 | return self 158 | } 159 | return String(self.dropFirst(prefix.count)) 160 | } 161 | } 162 | -------------------------------------------------------------------------------- /Tests/SisyphosTests/Internal/NSPredicateTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Sisyphos 3 | 4 | 5 | final class NSPredicateTests: XCTestCase { 6 | func testPredicateDescription() { 7 | let predicate = NSPredicate(format: "%@ > 42") 8 | let sisyphosPredicate = NSPredicate( 9 | step: .init( 10 | elementType: .alert, 11 | identifier: "expected identifier", 12 | label: "expected label", 13 | value: "expected value" 14 | ) 15 | ) 16 | 17 | XCTAssertTrue(predicate.description.starts(with: " PageDescription) -> PageDescription { 8 | page() 9 | } 10 | 11 | func testConditionals() { 12 | func createPage(condition: Bool) -> PageDescription { 13 | page { 14 | StaticText("First Element") 15 | if condition { 16 | TextField() 17 | StaticText("Third Element") 18 | } 19 | } 20 | } 21 | 22 | let firstPage = createPage(condition: false) 23 | XCTAssertEqual(firstPage.elements.count, 1) 24 | XCTAssertTrue(firstPage.elements.first is StaticText) 25 | 26 | 27 | let secondPage = createPage(condition: true) 28 | XCTAssertEqual(secondPage.elements.count, 3) 29 | XCTAssertTrue(secondPage.elements[0] is StaticText) 30 | XCTAssertTrue(secondPage.elements[1] is TextField) 31 | XCTAssertTrue(secondPage.elements[2] is StaticText) 32 | } 33 | 34 | func testOptionals() { 35 | func createPage(maybeElement: StaticText?) -> PageDescription { 36 | page { 37 | maybeElement 38 | } 39 | } 40 | 41 | let firstPage = createPage(maybeElement: nil) 42 | XCTAssertEqual(firstPage.elements.count, 0) 43 | 44 | let secondPage = createPage(maybeElement: StaticText("First Element")) 45 | XCTAssertEqual(secondPage.elements.count, 1) 46 | XCTAssertTrue(secondPage.elements[0] is StaticText) 47 | } 48 | 49 | func testNavigationBar() throws { 50 | let page = page { 51 | NavigationBar { 52 | StaticText("First Element") 53 | TextField() 54 | } 55 | } 56 | 57 | XCTAssertEqual(page.elements.count, 1) 58 | let navBar = try XCTUnwrap(page.elements.first as? NavigationBar) 59 | XCTAssertEqual(navBar.elements.count, 2) 60 | XCTAssertTrue(navBar.elements[0] is StaticText) 61 | XCTAssertTrue(navBar.elements[1] is TextField) 62 | } 63 | } 64 | -------------------------------------------------------------------------------- /Tests/SisyphosTests/SisyphosTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import Sisyphos 3 | 4 | final class SisyphosTests: XCTestCase { 5 | func testExample() throws { 6 | 7 | } 8 | } 9 | --------------------------------------------------------------------------------