├── .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 | 
4 | 
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 | 
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 | > 
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 | 
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 |
--------------------------------------------------------------------------------