diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..1f6e2c2 --- /dev/null +++ b/.gitignore @@ -0,0 +1,29 @@ +!default.mode1v3 +!default.mode2v3 +!default.pbxuser +!default.perspectivev3 +*.hmap +*.mode1v3 +*.mode2v3 +*.moved-aside +*.pbxuser +*.perspectivev3 +*.xccheckout +*.xcscmblueprint +*.xcuserstate +._* +.AppleDouble +.build/ +.DS_Store +.LSOverride +build/ +Carthage/Build +coverage.txt +DerivedData +Icon +run-tests +xcuserdata +Packages/ +Package.resolved +.swiftpm +*.xcodeproj diff --git a/.swiftlint.yml b/.swiftlint.yml new file mode 100644 index 0000000..64639f7 --- /dev/null +++ b/.swiftlint.yml @@ -0,0 +1,9 @@ +line_length: + - 110 +disabled_rules: + - identifier_name + - nesting + - opening_brace + - file_length +trailing_comma: + mandatory_comma: true diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..1038792 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,11 @@ +# Change Log +All notable changes to this project will be documented in this file. +`Hammer` adheres to [Semantic Versioning](http://semver.org/). + +## [0.9.1](https://github.com/lyft/Hammer/releases/tag/0.9.1) +- Add Makefile +- Add swiftlint +- Add new docs + +## [0.9.0](https://github.com/lyft/Hammer/releases/tag/0.9.0) +- Initial release diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..bbea566 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1 @@ +This project is governed by [Lyft's code of conduct](https://github.com/lyft/code-of-conduct). All contributors and participants agree to abide by its terms. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..1d5b452 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,10 @@ +## Contributing + +1. Fork the repo. +1. Generate the project by running `make`. + - The project cannot be generated using SwiftPM because it requires TestHost to run tests. +1. Run the tests. You can do this from the command line with `make test`. Make sure to run on an iPad if testing stylus. +1. Add tests if you are adding a feature or fixing a bug. +1. Make your tests pass. +1. Add an entry to the `CHANGELOG.md` +1. Push to your fork and submit a pull request! diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d154945 --- /dev/null +++ b/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright 2021 Lyft, Inc. + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..51b4bbb --- /dev/null +++ b/Makefile @@ -0,0 +1,40 @@ +.DEFAULT_GOAL := generate + +# Install Tasks + +install-lint: + brew list swiftlint &>/dev/null || brew install swiftlint + +install-xcodegen: + brew list xcodegen &>/dev/null || brew install xcodegen + +# Run Tasks + +generate: install-xcodegen + xcodegen generate + +test: lint test-iPad + +lint: install-lint + swiftlint lint --strict 2>/dev/null + +test-iPad: + set -o pipefail && \ + xcodebuild \ + -project Hammer.xcodeproj \ + -scheme Hammer \ + -destination "name=iPad Pro (12.9-inch) (4th generation)" \ + test + +test-iPhone: + set -o pipefail && \ + xcodebuild \ + -project Hammer.xcodeproj \ + -scheme Hammer \ + -destination "name=iPhone 11" \ + test + +# List all targets (from https://stackoverflow.com/questions/4219255/how-do-you-get-the-list-of-targets-in-a-makefile) + +list: + @$(MAKE) -pRrq -f $(lastword $(MAKEFILE_LIST)) : 2>/dev/null | awk -v RS= -F: '/^# File/,/^# Finished Make data base/ {if ($$1 !~ "^[#.]") {print $$1}}' | sort | egrep -v -e '^[^[:alnum:]]' -e '^$@$$' diff --git a/NOTICE b/NOTICE new file mode 100644 index 0000000..bbcd9f7 --- /dev/null +++ b/NOTICE @@ -0,0 +1,4 @@ +Hammer +Copyright 2021 Lyft Inc. + +This product includes software developed at Lyft Inc. diff --git a/Package.swift b/Package.swift new file mode 100644 index 0000000..7d379c5 --- /dev/null +++ b/Package.swift @@ -0,0 +1,19 @@ +// swift-tools-version:5.3 + +import PackageDescription + +let package = Package( + name: "Hammer", + platforms: [ + .iOS(.v11), + ], + products: [ + .library(name: "Hammer", targets: ["Hammer"]), + ], + targets: [ + .target(name: "Hammer"), + + // Disabled because SPM does not support running on TestHost yet + // .testTarget(name: "HammerTests", dependencies: ["Hammer"]), + ] +) diff --git a/README.md b/README.md new file mode 100644 index 0000000..3016601 --- /dev/null +++ b/README.md @@ -0,0 +1,164 @@ +

+

Hammer

+

If you can't touch this, it's Hammer time!

+ +

Demo

+ +
+ Table of Contents +
    +
  1. Introduction
  2. +
  3. Installation
  4. +
  5. Usage
  6. +
  7. License
  8. +
+
+ +## Introduction + +Hammer is a touch and keyboard synthesis library for emulating user interaction events. It enables new ways of triggering UI actions in unit tests, replicating a real world environment as much as possible. + +⚠️ IMPORTANT: This library makes extensive use of private APIs and should never be included in a production app. + +## Installation + +#### With [SwiftPM](https://swift.org/package-manager) + +```swift +.package(url: "https://github.com/lyft/Hammer.git", from: "0.9.0") +``` + +## Usage + +Hammer allows you to simulate fingers, stylus and keyboard events. It also provides various convenience methods to simulate higher level user interactions. + +To be able to send events to a view you must first create an `EventGenerator`: + +```swift +// Initialize for an existing UIWindow, ensure that the window is key and visible. +let eventGenerator = EventGenerator(window: myWindow) + +// Initialize for a UIView, automatically wrapping it in a temporary window. +let eventGenerator = EventGenerator(view: myView) + +// Initialize for a UIViewController, automatically wrapping it in a temporary window. +let eventGenerator = EventGenerator(viewController: myViewController) +``` + +When simulating finger or stylus touches, there are multiple ways of specifying a touch location: +1. Default: If you don't specify a location it will use the center of the screen. +2. Point: A CGPoint in screen coordinates. +3. View: A reference to a UIView or UIViewController, the location will be the center of the visible part of the view. +4. Identifier: An accessibility identifier string of a view, the location will be the center of the visible part of the view. + +By default, Hammer will display simulated touches over the view. You can change this behavior for your event generator. + +```swift +eventGenerator.showTouches = false +``` + +### Simulating Fingers + +Fingers are the most common method of user interaction on iOS. Hammer supports handling multiple fingers on the screen simultaneously, up to the limit on the device. You can specify the specific finger index you would like to use, if unspecified it will choose the most appropriate one automatically. + +Primitive events are the basic building blocks of user interactions, they can be combined together to create full gestures. Some methods will allow you to specify a duration and will interpolate the changes during that time. + +```swift +try eventGenerator.fingerDown(at: CGPoint(x: 10, y: 10)) +try eventGenerator.fingerMove(to: CGPoint(x: 20, y: 10), duration: 0.5) +try eventGenerator.fingerUp() +``` + +For convenience, Hammer provides many higher level gestures. If you don't specify a location it will automatically default to the center of the view. + +```swift +try eventGenerator.fingerTap() +try eventGenerator.fingerDoubleTap() +try eventGenerator.fingerLongPress() +try eventGenerator.twoFingerTap() +``` + +Many advanced gestures are also available. + +```swift +try eventGenerator.fingerDrag(from: CGPoint(x: 10, y: 10), to: CGPoint(x: 20, y: 10), duration: 0.5) +try eventGenerator.fingerPinch(fromDistance: 100, toDistance: 50, duration: 0.5) +try eventGenerator.fingerRotate(angle: .pi, duration: 0.5) +``` + +### Simulating Stylus + +Stylus is available when running on an iPad. It allows for additional properties like pressure, altitude and azimouth to be specified. + +Similar to fingers, primitive events are the basic building blocks of stylus interactions. + +```swift +try eventGenerator.stylusDown(at: CGPoint(x: 10, y: 10), azimuth: 0, altitude: 0, pressure: 0.5) +try eventGenerator.stylusMove(to: CGPoint(x: 20, y: 10), duration: 0.5) +try eventGenerator.stylusUp() +``` + +Hammer also provides many higher level gestures for Stylus. If you don't specify a location it will automatically default to the center of the view. + +```swift +try eventGenerator.stylusTap() +try eventGenerator.stylusDoubleTap() +try eventGenerator.stylusLongPress() +``` + +### Simulating Keyboard + +Keyboard methods take an explicit `KeyboardKey` object or a `Character`. Characters will be mapped to their closest keybaord key, you must wrap them with a shift key modifier if needed. This means that specifying a lowercase "a" character is equivalent to speciifying an uppercase "A", this is also true for keys with symbols. + +```swift +// Explicit `KeyboardKey` +try eventGenerator.keyDown(.letterA) +try eventGenerator.keyUp(.letterA) + +// Automatic `Character` mapping +try eventGenerator.keyDown("a") +try eventGenerator.keyUp("a") + +// Convenience key down and up events +try eventGenerator.keyPress(.letterA) +try eventGenerator.keyPress("a") +``` + +To type characters or longer strings and get automatic shift wrapping you can use the `keyType()` methods. + +```swift +try eventGenerator.keyType("This will type the string as specified, including symbols!") +``` + +### Finding a subview + +When running on a full screen app or testing navigation, specifying a CGPoint in screen coordinates can be difficult. For this, Hammer provides convenience methods to find views in the hierarchy by their accessibility identifier. + +```swift +let myButton = try eventGenerator.viewWithIdentifier("my_button", ofType: UIButton.self) +``` + +This method will throw an error if the view was not found in the hierarchy. If you're testing navigation or screen changes and you need to wait until the view appears, you can add a timeout. This will wait until the hierarchy has updated and return the view. + +```swift +let myButton = try eventGenerator.viewWithIdentifier("my_button", ofType: UIButton.self, timeout: 1) +``` + +### Waiting + +You will often need to wait for the simulator to finish displaying something on the screen or for an animation to end. Hammer provides multiple methods to wait until a view is visible on screen or if a control is hittable + +```swift +try eventGenerator.wait(untilVisible: "myLabel", timeout: 1) +try eventGenerator.wait(untilHittable: "myButton", timeout: 1) +``` + +## License + +Hammer is released under the Apache License. See [LICENSE](./LICENSE) diff --git a/Sources/Hammer/AppleInternal/AppleInternal+BackBoardServices.swift b/Sources/Hammer/AppleInternal/AppleInternal+BackBoardServices.swift new file mode 100644 index 0000000..b7f1b3e --- /dev/null +++ b/Sources/Hammer/AppleInternal/AppleInternal+BackBoardServices.swift @@ -0,0 +1,22 @@ +import CoreFoundation +import Darwin + +private let kBackBoardServicesPath + = "/System/Library/PrivateFrameworks/BackBoardServices.framework/BackBoardServices" + +struct BackBoardServices { + typealias CHIDEventSetDigitizerInfo = @convention(c) ( + _ digitizerEvent: IOHIDEvent, _ contextID: UInt32, _ systemGestureIsPossible: Bool, + _ isSystemGestureStateChangeEvent: Bool, _ displayUUID: CFString?, + _ initialTouchTimestamp: CFTimeInterval, _ maxForce: Float) -> Void + + let eventSetDigitizerInfo: CHIDEventSetDigitizerInfo + + static let shared = BackBoardServices() + + private init() { + let handle = dlopen(kBackBoardServicesPath, RTLD_NOW) + self.eventSetDigitizerInfo = unsafeBitCast(dlsym(handle, "BKSHIDEventSetDigitizerInfo"), + to: CHIDEventSetDigitizerInfo.self) + } +} diff --git a/Sources/Hammer/AppleInternal/AppleInternal+IOHID.swift b/Sources/Hammer/AppleInternal/AppleInternal+IOHID.swift new file mode 100644 index 0000000..ba80a30 --- /dev/null +++ b/Sources/Hammer/AppleInternal/AppleInternal+IOHID.swift @@ -0,0 +1,207 @@ +// swiftlint:disable type_name + +import CoreGraphics +import Foundation + +private let kIOKitPath + = "/System/Library/Frameworks/IOKit.framework/IOKit" + +let kIOHIDEventOptionNone: CFOptionFlags = 0 +let kIOHIDTransducerTouch: CFOptionFlags = 0x00020000 + +@objc protocol IOHIDEvent: NSObjectProtocol {} +@objc protocol IOHIDEventSystemClient: NSObjectProtocol {} + +struct IOHID { + typealias IOHIDEventSystemClientEventCallback = (_ target: Any?, _ refcon: Any?, + _ queue: AnyObject, _ event: IOHIDEvent) -> Void + + typealias IOHIDEventCreateDigitizerEvent = @convention(c) ( + _ allocator: CFAllocator?, _ timestamp: UInt64, + _ transducer_type: DigitizerTransducerType.RawValue, _ index: UInt32, _ identifier: UInt32, + _ eventMask: DigitizerEventMask.RawValue, _ buttonEvent: UInt32, + _ x: CGFloat, _ y: CGFloat, _ z: CGFloat, _ pressure: CGFloat, _ twist: CGFloat, + _ isRange: Bool, _ isTouch: Bool, _ options: CFOptionFlags) -> IOHIDEvent + + typealias IOHIDEventCreateDigitizerFingerEvent = @convention(c) ( + _ allocator: CFAllocator?, _ timestamp: UInt64, _ identifier: UInt32, _ fingerIndex: UInt32, + _ eventMask: DigitizerEventMask.RawValue, + _ x: CGFloat, _ y: CGFloat, _ z: CGFloat, _ pressure: CGFloat, _ twist: CGFloat, + _ isRange: Bool, _ isTouch: Bool, _ options: CFOptionFlags) -> IOHIDEvent + + typealias IOHIDEventCreateDigitizerStylusEvent = @convention(c) ( + _ allocator: CFAllocator?, _ timestamp: UInt64, _ identifier: UInt32, _ index: UInt32, + _ eventMask: DigitizerEventMask.RawValue, _ buttonMask: UInt32, + _ x: CGFloat, _ y: CGFloat, _ z: CGFloat, _ tipPressure: CGFloat, _ barrelPressure: CGFloat, + _ twist: CGFloat, _ altitude: CGFloat, _ azimuth: CGFloat, + _ isRange: Bool, _ isTouch: Bool, _ options: CFOptionFlags) -> IOHIDEvent + + typealias IOHIDEventCreateKeyboardEvent = @convention(c) ( + _ allocator: CFAllocator?, _ timestamp: UInt64, _ identifier: UInt32, _ usage: UInt32, + _ isKeyDown: Bool, _ options: CFOptionFlags) -> IOHIDEvent + + typealias IOHIDEventCreateVendorDefinedEvent = @convention(c) ( + _ allocator: CFAllocator?, _ timestamp: UInt64, _ usagePage: UInt32, _ usage: UInt32, + _ version: UInt32, _ data: [UInt8], _ length: Int, _ options: CFOptionFlags) -> IOHIDEvent + + typealias IOHIDEventSystemClientCreate = @convention(c) ( + _ allocator: CFAllocator?) -> IOHIDEventSystemClient + typealias IOHIDEventSystemClientScheduleWithRunLoop = @convention(c) ( + _ client: IOHIDEventSystemClient, _ runloop: CFRunLoop, _ mode: CFRunLoopMode.RawValue) -> Void + typealias IOHIDEventSystemClientRegisterEventCallback = @convention(c) ( + _ client: IOHIDEventSystemClient, _ callback: @escaping IOHIDEventSystemClientEventCallback, + _ target: Any?, _ refcon: Any?) -> Void + + typealias IOHIDEventGetIntegerValue = @convention(c) ( + _ event: IOHIDEvent, _ field: UInt32) -> Int + typealias IOHIDEventSetIntegerValue = @convention(c) ( + _ event: IOHIDEvent, _ field: UInt32, _ value: Int) -> Void + typealias IOHIDEventGetFloatValue = @convention(c) ( + _ event: IOHIDEvent, _ field: UInt32) -> CGFloat + typealias IOHIDEventSetFloatValue = @convention(c) ( + _ event: IOHIDEvent, _ field: UInt32, _ value: CGFloat) -> Void + typealias IOHIDEventGetDataValue = @convention(c) ( + _ event: IOHIDEvent, _ field: UInt32) -> Data + typealias IOHIDEventAppendEvent = @convention(c) ( + _ event: IOHIDEvent, _ subevent: IOHIDEvent, _ options: CFOptionFlags) -> Void + typealias IOHIDEventSetSenderID = @convention(c) ( + _ event: IOHIDEvent, _ senderId: UInt64) -> Void + typealias IOHIDEventGetType = @convention(c) ( + _ event: IOHIDEvent) -> EventType.RawValue + + let createDigitizerEvent: IOHIDEventCreateDigitizerEvent + let createDigitizerFingerEvent: IOHIDEventCreateDigitizerFingerEvent + let createDigitizerStylusEvent: IOHIDEventCreateDigitizerStylusEvent + let createKeyboardEvent: IOHIDEventCreateKeyboardEvent + let createVendorDefinedEvent: IOHIDEventCreateVendorDefinedEvent + + let eventSystemClientCreate: IOHIDEventSystemClientCreate + let eventSystemClientScheduleWithRunLoop: IOHIDEventSystemClientScheduleWithRunLoop + let eventSystemClientRegisterEventCallback: IOHIDEventSystemClientRegisterEventCallback + + let eventGetIntegerValue: IOHIDEventGetIntegerValue + let eventSetIntegerValue: IOHIDEventSetIntegerValue + let eventGetFloatValue: IOHIDEventGetFloatValue + let eventSetFloatValue: IOHIDEventSetFloatValue + let eventGetDataValue: IOHIDEventGetDataValue + let eventAppendEvent: IOHIDEventAppendEvent + let eventSetSenderID: IOHIDEventSetSenderID + let eventGetType: IOHIDEventGetType + + static let shared = IOHID() + + private init() { + let handle = dlopen(kIOKitPath, RTLD_NOW) + self.createDigitizerEvent = unsafeBitCast(dlsym(handle, "IOHIDEventCreateDigitizerEvent"), + to: IOHIDEventCreateDigitizerEvent.self) + self.createDigitizerFingerEvent = unsafeBitCast(dlsym(handle, "IOHIDEventCreateDigitizerFingerEvent"), + to: IOHIDEventCreateDigitizerFingerEvent.self) + self.createDigitizerStylusEvent = unsafeBitCast(dlsym(handle, "IOHIDEventCreateDigitizerStylusEvent"), + to: IOHIDEventCreateDigitizerStylusEvent.self) + self.createKeyboardEvent = unsafeBitCast(dlsym(handle, "IOHIDEventCreateKeyboardEvent"), + to: IOHIDEventCreateKeyboardEvent.self) + self.createVendorDefinedEvent = unsafeBitCast(dlsym(handle, "IOHIDEventCreateVendorDefinedEvent"), + to: IOHIDEventCreateVendorDefinedEvent.self) + + self.eventSystemClientCreate + = unsafeBitCast(dlsym(handle, "IOHIDEventSystemClientCreate"), + to: IOHIDEventSystemClientCreate.self) + self.eventSystemClientScheduleWithRunLoop + = unsafeBitCast(dlsym(handle, "IOHIDEventSystemClientScheduleWithRunLoop"), + to: IOHIDEventSystemClientScheduleWithRunLoop.self) + self.eventSystemClientRegisterEventCallback + = unsafeBitCast(dlsym(handle, "IOHIDEventSystemClientRegisterEventCallback"), + to: IOHIDEventSystemClientRegisterEventCallback.self) + + self.eventGetIntegerValue = unsafeBitCast(dlsym(handle, "IOHIDEventGetIntegerValue"), + to: IOHIDEventGetIntegerValue.self) + self.eventSetIntegerValue = unsafeBitCast(dlsym(handle, "IOHIDEventSetIntegerValue"), + to: IOHIDEventSetIntegerValue.self) + self.eventGetFloatValue = unsafeBitCast(dlsym(handle, "IOHIDEventGetFloatValue"), + to: IOHIDEventGetFloatValue.self) + self.eventSetFloatValue = unsafeBitCast(dlsym(handle, "IOHIDEventSetFloatValue"), + to: IOHIDEventSetFloatValue.self) + self.eventGetDataValue = unsafeBitCast(dlsym(handle, "IOHIDEventGetDataValue"), + to: IOHIDEventGetDataValue.self) + self.eventAppendEvent = unsafeBitCast(dlsym(handle, "IOHIDEventAppendEvent"), + to: IOHIDEventAppendEvent.self) + self.eventSetSenderID = unsafeBitCast(dlsym(handle, "IOHIDEventSetSenderID"), + to: IOHIDEventSetSenderID.self) + self.eventGetType = unsafeBitCast(dlsym(handle, "IOHIDEventGetType"), + to: IOHIDEventGetType.self) + } +} + +extension IOHID { + enum DigitizerTransducerType: UInt32 { + case stylus = 0 + // case puck = 1 + // case finger = 2 + case hand = 3 + } +} + +extension IOHID { + enum Page: UInt32 { + case keyboardOrKeypad = 0x07 + case vendorDefinedStart = 0xFF00 + } +} + +extension IOHID { + enum EventType: UInt32 { + case null = 0 + case vendorDefined = 1 + case button = 2 + case keyboard = 3 + case translation = 4 + case rotation = 5 + case scroll = 6 + case scale = 7 + case zoom = 8 + case velocity = 9 + case orientation = 10 + case digitizer = 11 + case swipe = 16 + case force = 32 + } + + enum EventField { + enum VendorDefined: UInt32 { // (1 << 16) + case usagePage = 0x10000 + case usage = 0x10001 + case version = 0x10002 + case dataLength = 0x10003 + case data = 0x10004 + } + + enum Digitizer: UInt32 { // (11 << 16) + case x = 0xB0000 + case y = 0xB0001 + case majorRadius = 0xB0014 + case minorRadius = 0xB0015 + case isDisplayIntegrated = 0xB0019 + } + } +} + +extension IOHID { + struct DigitizerEventMask: OptionSet { + let rawValue: UInt32 + + static let range = DigitizerEventMask(rawValue: 1 << 0) + static let touch = DigitizerEventMask(rawValue: 1 << 1) + static let position = DigitizerEventMask(rawValue: 1 << 2) + static let identity = DigitizerEventMask(rawValue: 1 << 5) + static let attribute = DigitizerEventMask(rawValue: 1 << 6) + static let cancel = DigitizerEventMask(rawValue: 1 << 7) + static let start = DigitizerEventMask(rawValue: 1 << 8) + + static let estimatedAltitude = DigitizerEventMask(rawValue: 1 << 28) + static let estimatedAzimuth = DigitizerEventMask(rawValue: 1 << 29) + static let estimatedPressure = DigitizerEventMask(rawValue: 1 << 30) + } +} + +let kGSEventPathInfoInRange: UInt8 = (1 << 0) +let kGSEventPathInfoInTouch: UInt8 = (1 << 1) diff --git a/Sources/Hammer/AppleInternal/AppleInternal+UIKit.swift b/Sources/Hammer/AppleInternal/AppleInternal+UIKit.swift new file mode 100644 index 0000000..258d61f --- /dev/null +++ b/Sources/Hammer/AppleInternal/AppleInternal+UIKit.swift @@ -0,0 +1,52 @@ +import Foundation +import UIKit + +@objc protocol UIApplicationPrivate: NSObjectProtocol { + @objc(_enqueueHIDEvent:) + func enqueue(_ event: IOHIDEvent) + + @objc(_touchesEvent) + var touchesEvent: UIEvent { get } +} + +@objc protocol UIWindowPrivate: NSObjectProtocol { + @objc(_contextId) + var contextId: UInt32 { get } +} + +extension UIApplication { + typealias HIDEventCallback = (_ event: IOHIDEvent) -> Void + + private static var hidEventCallbacks = [HIDEventCallback]() + + @objc + private func swizzledHandleHIDEvent(_ event: IOHIDEvent) { + // Calling this really calls the original un-swizzled method + self.swizzledHandleHIDEvent(event) + + UIApplication.hidEventCallbacks.forEach { $0(event) } + } + + private static let runOnce: () = { + class_addProtocol(UIApplication.self, UIApplicationPrivate.self) + class_addProtocol(UIWindow.self, UIWindowPrivate.self) + + let originalMethod = class_getInstanceMethod( + UIApplication.self, NSSelectorFromString("_handleHIDEvent:")) + let swizzledMethod = class_getInstanceMethod( + UIApplication.self, #selector(UIApplication.swizzledHandleHIDEvent(_:))) + if let originalMethod = originalMethod, let swizzledMethod = swizzledMethod { + method_exchangeImplementations(originalMethod, swizzledMethod) + } else { + preconditionFailure("Failed to swizzle _handleHIDEvent") + } + }() + + static func swizzle() { + self.runOnce + } + + static func registerForHIDEvents(callback: @escaping HIDEventCallback) { + self.hidEventCallbacks.append(callback) + } +} diff --git a/Sources/Hammer/DebugWindow/DebugVisualizerWindow.swift b/Sources/Hammer/DebugWindow/DebugVisualizerWindow.swift new file mode 100644 index 0000000..6ac404b --- /dev/null +++ b/Sources/Hammer/DebugWindow/DebugVisualizerWindow.swift @@ -0,0 +1,106 @@ +import Foundation +import UIKit + +private let kWindowLevel = UIWindow.Level(rawValue: UIWindow.Level.alert.rawValue + 100) + +private let kStylusColor = UIColor(hue: 0.800, saturation: 1, brightness: 0.8, alpha: 1) +private let kFingerColors: [FingerIndex: UIColor] = [ + .rightThumb: UIColor(hue: 0.00, saturation: 1, brightness: 0.8, alpha: 1), + .rightIndex: UIColor(hue: 0.50, saturation: 1, brightness: 0.8, alpha: 1), + .rightMiddle: UIColor(hue: 0.25, saturation: 1, brightness: 0.8, alpha: 1), + .rightRing: UIColor(hue: 0.75, saturation: 1, brightness: 0.8, alpha: 1), + .rightLittle: UIColor(hue: 0.125, saturation: 1, brightness: 0.8, alpha: 1), + .leftThumb: UIColor(hue: 0.375, saturation: 1, brightness: 0.8, alpha: 1), + .leftIndex: UIColor(hue: 0.625, saturation: 1, brightness: 0.8, alpha: 1), + .leftMiddle: UIColor(hue: 0.875, saturation: 1, brightness: 0.8, alpha: 1), + .leftRing: UIColor(hue: 0.437, saturation: 1, brightness: 0.8, alpha: 1), + .leftLittle: UIColor(hue: 0.937, saturation: 1, brightness: 0.8, alpha: 1), +] + +final class DebugVisualizerWindow: UIWindow { + private let stylusView = TouchView.initializeForStylus() + private let fingerViews = Dictionary(uniqueKeysWithValues: FingerIndex.defaultOrder.map { index in + (index, TouchView.initializeForFinger(index: index)) + }) + + override var canBecomeFirstResponder: Bool { + return false + } + + init() { + super.init(frame: .zero) + + self.windowLevel = kWindowLevel + self.backgroundColor = .clear + self.isUserInteractionEnabled = false + self.isAccessibilityElement = false + self.isHidden = false + + self.fingerViews.values.forEach(self.addSubview(_:)) + if UIDevice.current.supportsStylus { + self.addSubview(self.stylusView) + } + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func update(fingerIndex: FingerIndex, location: CGPoint?) { + self.fingerViews[fingerIndex]?.configure(location: location) + self.layoutIfNeeded() + } + + func update(stylusLocation location: CGPoint?) { + self.stylusView.configure(location: location) + self.layoutIfNeeded() + } +} + +private final class TouchView: UIView { + private static let viewSize: CGFloat = 20 + + private let label = UILabel() + + private init(text: String, color: UIColor) { + super.init(frame: CGRect(x: 0, y: 0, width: TouchView.viewSize, height: TouchView.viewSize)) + self.isUserInteractionEnabled = false + self.isAccessibilityElement = false + self.layer.cornerRadius = TouchView.viewSize / 2 + + self.label.textColor = .white + self.label.textAlignment = .center + self.label.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(self.label) + self.label.centerXAnchor.constraint(equalTo: self.centerXAnchor).isActive = true + self.label.centerYAnchor.constraint(equalTo: self.centerYAnchor).isActive = true + + self.label.text = text + self.backgroundColor = color + + self.isHidden = true + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + static func initializeForFinger(index: FingerIndex) -> TouchView { + return TouchView(text: "\(index.rawValue)", color: kFingerColors[index] ?? .black) + } + + static func initializeForStylus() -> TouchView { + return TouchView(text: "✐", color: kStylusColor) + } + + func configure(location: CGPoint?) { + if let location = location { + self.center = location + self.isHidden = false + } else { + self.isHidden = true + } + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Hand/EventGenerator+Hand.swift b/Sources/Hammer/EventGenerator/EventGenerator+Hand/EventGenerator+Hand.swift new file mode 100644 index 0000000..a7d970a --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Hand/EventGenerator+Hand.swift @@ -0,0 +1,571 @@ +import CoreGraphics +import Foundation +import UIKit + +private let kDefaultRadius: CGFloat = 5 + +extension EventGenerator { + public static let fingerLiftDelay: TimeInterval = 0.05 + public static let longPressHoldDelay: TimeInterval = 2.0 + public static let multiTapInterval: TimeInterval = 0.15 + public static let fingerMoveInterval: TimeInterval = 1 / 60 + public static let pinchDuration: TimeInterval = 0.15 + + public static let twoFingerDistance: CGFloat = 20 + public static let rotationDistance: CGFloat = 100 + public static let pinchLargeDistance: CGFloat = 200 + public static let pinchSmallDistance: CGFloat = 20 + + // MARK: - Base Actions + + /// Sends a finger down event. + /// + /// - parameter indices: The finger indices to touch down, must match the number of locations. + /// - parameter locations: The locations where to touch down. + public func fingerDown(_ indices: [FingerIndex?] = .automatic, at locations: [HammerLocatable]) throws { + let indices = try self.fillNextFingerIndices(indices, withExpected: locations.count) + let locations = try locations.map { try $0.hitPoint(for: self) } + try self.checkPointsAreHittable(locations) + try self.sendEvent(hand: HandInfo(fingers: zip(locations, indices).map { location, index in + FingerInfo(fingerIndex: index, location: location, phase: .began, + pressure: 0, twist: 0, majorRadius: kDefaultRadius, minorRadius: kDefaultRadius) + })) + } + + /// Sends a finger down event. + /// + /// - parameter index: The finger index to touch down. + /// - parameter location: The location where to touch down. Nil to use the center. + public func fingerDown(_ index: FingerIndex? = .automatic, at location: HammerLocatable? = nil) throws { + try self.fingerDown([index], at: [location ?? self.defaultTouchLocation]) + } + + /// Sends a finger up event. + /// + /// Unless specified, the finger indices will be the last fingers that touched down. + /// + /// - parameter indices: The finger indices to touch up. + public func fingerUp(_ indices: [FingerIndex?] = .automatic) throws { + let indices = try self.fillExistingFingerIndices(indices, withMinimum: 1) + let locations = self.activeTouches.fingers(forIndices: indices).map(\.location) + try self.sendEvent(hand: HandInfo(fingers: zip(locations, indices).map { location, index in + return FingerInfo(fingerIndex: index, location: location, phase: .ended, + pressure: 0, twist: 0, majorRadius: kDefaultRadius, minorRadius: kDefaultRadius) + })) + } + + /// Sends a finger up event. + /// + /// Unless specified, the finger index will be the last finger that touched down. + /// + /// - parameter index: The finger index to touch up. + public func fingerUp(_ index: FingerIndex?) throws { + try self.fingerUp([index]) + } + + // MARK: - Tap Actions + + /// Sends a finger tap event. + /// + /// - parameter index: The finger index to use for the tap. + /// - parameter location: The location where to tap. Nil to use the center. + /// - parameter tapCount: The number of taps to perform. + /// - parameter interval: The interval between taps, if more than one. + public func fingerTap(_ index: FingerIndex? = .automatic, at location: HammerLocatable? = nil, + numberOfTimes tapCount: Int = 1, + interval: TimeInterval = EventGenerator.multiTapInterval) throws + { + for i in 0.. 0 else { + try self.fingerMove(indices, to: locations) + return + } + + let indices = try self.fillExistingFingerIndices(indices, withMinimum: locations.count) + let locations = try locations.map { try $0.hitPoint(for: self) } + let startLocations = self.activeTouches.fingers(forIndices: indices).map(\.location) + + let startTime = Date() + var elapsed: TimeInterval = 0 + + while elapsed < (duration - EventGenerator.fingerMoveInterval) { + elapsed = Date().timeIntervalSince(startTime) + let interval = elapsed / duration + + let nextLocations = zip(startLocations, locations).map { startLocation, endLocation in + return curveInterpolation(from: startLocation, to: endLocation, time: interval) + } + + try self.fingerMove(indices, to: nextLocations) + self.sleep(EventGenerator.fingerMoveInterval) + } + + try self.fingerMove(indices, to: locations) + } + + /// Sends a finger move event, interpolating between the changes for the specified duration. + /// + /// Unless specified, the finger indices will be the last fingers that touched down. + /// + /// - parameter index: The finger index to move. + /// - parameter location: The new location of the finger. + /// - parameter duration: The time to interpolate between the changes. + public func fingerMove(_ index: FingerIndex? = .automatic, to location: HammerLocatable, + duration: TimeInterval) throws + { + try self.fingerMove([index], to: [location], duration: duration) + } + + /// Sends a finger drag event, interpolating between the changes for the specified duration. + /// + /// - parameter index: The finger index to use for dragging. + /// - parameter startPoint: The start location of the finger for touch down. + /// - parameter endPoint: The end location of the finger for touch up. + /// - parameter duration: The time to interpolate between the changes. + public func fingerDrag(_ index: FingerIndex? = .automatic, from startPoint: HammerLocatable, + to endPoint: HammerLocatable, duration: TimeInterval) throws + { + let index = try index ?? self.nextFingerIndex() + try self.fingerDown(index, at: startPoint) + try self.fingerMove(index, to: endPoint, duration: duration) + try self.fingerUp(index) + } + + // MARK: - Two Finger Actions + + /// Sends a two finger down event. + /// + /// - parameter indices: The finger indices to touch down, must be two indices. + /// - parameter location: The center location between the two fingers. Nil to use the center. + /// - parameter distance: The distance between the two fingers. + /// - parameter radians: The angle in radians of the two fingers. An angle of zero is assumed to be + /// horizontal and positive angle moves clockwise. + public func twoFingerDown(_ indices: [FingerIndex?] = .automatic, at location: HammerLocatable? = nil, + withDistance distance: CGFloat = EventGenerator.twoFingerDistance, + angle radians: CGFloat = 0) throws + { + let indices = try self.fillNextFingerIndices(indices, withExpected: 2) + let location = try location?.hitPoint(for: self) ?? self.defaultTouchLocation + try self.fingerDown(indices, at: location.twoWayOffset(distance, angle: radians)) + } + + /// Sends a two finger up event. + /// + /// Unless specified, the finger indices will be the last fingers that touched down. + /// + /// - parameter indices: The finger indices to touch up, must be two indices. + public func twoFingerUp(_ indices: [FingerIndex?] = .automatic) throws { + let indices = try self.fillExistingFingerIndices(indices, withMinimum: 2) + try self.fingerUp(indices) + } + + /// Sends a two finger move event. + /// + /// Unless specified, the finger indices will be the last fingers that touched down. + /// + /// - parameter indices: The finger indices to move, must be two indices. + /// - parameter location: The new center location between the two fingers. + /// - parameter distance: The new distance between the two fingers. + /// - parameter radians: The new angle in radians of the two fingers. An angle of zero is assumed to be + /// horizontal and positive angle moves clockwise. + public func twoFingerMove(_ indices: [FingerIndex?] = .automatic, to location: HammerLocatable, + withDistance distance: CGFloat = EventGenerator.twoFingerDistance, + angle radians: CGFloat = 0) throws + { + let indices = try self.fillExistingFingerIndices(indices, withMinimum: 2) + let location = try location.hitPoint(for: self) + try self.fingerMove(indices, to: location.twoWayOffset(distance, angle: radians)) + } + + /// Sends a two finger move event, interpolating between the changes for the specified duration. + /// + /// Unless specified, the finger indices will be the last fingers that touched down. + /// + /// - parameter indices: The finger indices to move, must be two indices. + /// - parameter location: The new center location between the two fingers. + /// - parameter distance: The new distance between the two fingers. + /// - parameter radians: The new angle in radians of the two fingers. An angle of zero is assumed to be + /// horizontal and positive angle moves clockwise. + /// - parameter duration: The time to interpolate between the changes. + public func twoFingerMove(_ indices: [FingerIndex?] = .automatic, to location: HammerLocatable, + withDistance distance: CGFloat = EventGenerator.twoFingerDistance, + angle radians: CGFloat = 0, duration: TimeInterval) throws + { + let location = try location.hitPoint(for: self) + try self.fingerMove(indices, to: location.twoWayOffset(distance, angle: radians), duration: duration) + } + + /// Sends a two finger tap event. + /// + /// - parameter indices: The finger indices to use for the tap, must be two indices. + /// - parameter location: The center location between the two fingers. Nil to use the center. + /// - parameter distance: The distance between the two fingers. + /// - parameter radians: The angle in radians of the two fingers. An angle of zero is assumed to be + /// horizontal and positive angle moves clockwise. + public func twoFingerTap(_ indices: [FingerIndex?] = .automatic, at location: HammerLocatable? = nil, + withDistance distance: CGFloat = EventGenerator.twoFingerDistance, + angle radians: CGFloat = 0) throws + { + let indices = try self.fillNextFingerIndices(indices, withExpected: 2) + try self.twoFingerDown(indices, at: location, withDistance: distance, angle: radians) + self.sleep(EventGenerator.fingerLiftDelay) + try self.twoFingerUp(indices) + } + + // MARK: - Pinch Actions + + /// Sends a two finger pinch event, interpolating between the changes for the specified duration. + /// + /// - parameter indices: The finger indices to pinch, must be two indices. + /// - parameter location: The center location between the two fingers. + /// - parameter startDistance: The initial distance between the two fingers. + /// - parameter endDistance: The final distance between the two fingers. + /// - parameter radians: The angle in radians of the two fingers. An angle of zero is assumed to be + /// horizontal and positive angle moves clockwise. + /// - parameter duration: The time to interpolate between the changes. + public func fingerPinch(_ indices: [FingerIndex?] = .automatic, at location: HammerLocatable? = nil, + fromDistance startDistance: CGFloat, toDistance endDistance: CGFloat, + angle radians: CGFloat = 0, duration: TimeInterval) throws + { + let indices = try self.fillNextFingerIndices(indices, withExpected: 2) + let location = try location?.hitPoint(for: self) ?? self.defaultTouchLocation + let startLocations = location.twoWayOffset(startDistance, angle: radians) + let endLocations = location.twoWayOffset(endDistance, angle: radians) + try self.fingerDown(indices, at: startLocations) + try self.fingerMove(indices, to: endLocations, duration: duration) + try self.fingerUp(indices) + } + + /// Sends a two finger pinch event shrinking the distance between fingers and interpolating between the + /// changes for the specified duration. + /// + /// - parameter indices: The finger indices to pinch, must be two indices. + /// - parameter location: The center location between the two fingers. + /// - parameter duration: The time to interpolate between the changes. + public func fingerPinchClose(_ indices: [FingerIndex?] = .automatic, at location: HammerLocatable? = nil, + duration: TimeInterval = EventGenerator.pinchDuration) throws + { + try self.fingerPinch(indices, at: location, + fromDistance: EventGenerator.pinchLargeDistance, + toDistance: EventGenerator.pinchSmallDistance, + duration: duration) + } + + /// Sends a two finger pinch event increasing the distance between fingers and interpolating between the + /// changes for the specified duration. + /// + /// - parameter indices: The finger indices to pinch, must be two indices. + /// - parameter location: The center location between the two fingers. + /// - parameter duration: The time to interpolate between the changes. + public func fingerPinchOpen(_ indices: [FingerIndex?] = .automatic, at location: HammerLocatable? = nil, + duration: TimeInterval = EventGenerator.pinchDuration) throws + { + try self.fingerPinch(indices, at: location, + fromDistance: EventGenerator.pinchSmallDistance, + toDistance: EventGenerator.pinchLargeDistance, + duration: duration) + } + + // MARK: - Rotate Actions + + /// Sends a finger move event pivoting around an anchor. + /// + /// Unless specified, the finger indices will be the last fingers that touched down. + /// + /// - parameter indices: The finger indices to pivot. + /// - parameter anchor: The location to use as the anchor for pivoting. + /// - parameter radians: The angle in radians to pivot. An angle of zero is assumed to be horizontal and + /// positive angle moves clockwise. + public func fingerPivot(_ indices: [FingerIndex?] = .automatic, aroundAnchor anchor: HammerLocatable, + angle radians: CGFloat) throws + { + let indices = try self.fillExistingFingerIndices(indices, withMinimum: 1) + let anchor = try anchor.hitPoint(for: self) + let locations = self.activeTouches.fingers(forIndices: indices).map(\.location) + try self.fingerMove(indices, to: locations.map { $0.pivot(anchor: anchor, angle: radians) }) + } + + /// Sends a finger move event pivoting around an anchor and interpolating between the changes for the + /// specified duration. + /// + /// Unless specified, the finger indices will be the last fingers that touched down. + /// + /// - parameter indices: The finger indices to pivot. + /// - parameter anchor: The location to use as the anchor for pivoting. + /// - parameter radians: The angle in radians to pivot. An angle of zero is assumed to be horizontal and + /// positive angle moves clockwise. + /// - parameter duration: The time to interpolate between the changes. + public func fingerPivot(_ indices: [FingerIndex?] = .automatic, aroundAnchor anchor: HammerLocatable, + byAngle radians: CGFloat, duration: TimeInterval) throws + { + guard duration > 0 else { + try self.fingerPivot(indices, aroundAnchor: anchor, angle: radians) + return + } + + let indices = try self.fillExistingFingerIndices(indices, withMinimum: 1) + let anchor = try anchor.hitPoint(for: self) + let startLocations = self.activeTouches.fingers(forIndices: indices).map(\.location) + + let startTime = Date() + var elapsed: TimeInterval = 0 + + while elapsed < (duration - EventGenerator.fingerMoveInterval) { + elapsed = Date().timeIntervalSince(startTime) + let interval = elapsed / duration + + let radians = curveInterpolation(from: 0, to: radians, time: interval) + let nextLocations = startLocations.map { $0.pivot(anchor: anchor, angle: radians) } + + try self.fingerMove(indices, to: nextLocations) + self.sleep(EventGenerator.fingerMoveInterval) + } + + try self.fingerMove(indices, to: startLocations.map { $0.pivot(anchor: anchor, angle: radians) }) + } + + /// Sends a finger move event rotating a between to angles and interpolating between the changes for the + /// specified duration. + /// + /// - parameter indices: The finger indices to rotate, must be two indices. + /// - parameter location: The center location between the two fingers. + /// - parameter distance: The distance between the two fingers. + /// - parameter startRadians: The initial angle in radians for touch down. An angle of zero is assumed to + /// be horizontal and positive angle moves clockwise. + /// - parameter endRadians: The final angle in radians for touch up. An angle of zero is assumed to + /// be horizontal and positive angle moves clockwise. + /// - parameter duration: The time to interpolate between the changes. + public func fingerRotate(_ indices: [FingerIndex?] = .automatic, at location: HammerLocatable? = nil, + withDistance distance: CGFloat = EventGenerator.rotationDistance, + fromAngle startRadians: CGFloat, toAngle endRadians: CGFloat, + duration: TimeInterval) throws + { + let indices = try self.fillNextFingerIndices(indices, withExpected: 2) + let location = try location?.hitPoint(for: self) ?? self.defaultTouchLocation + try self.fingerDown(indices, at: location.twoWayOffset(distance, angle: startRadians)) + try self.fingerPivot(indices, aroundAnchor: location, byAngle: endRadians - startRadians, + duration: duration) + try self.fingerUp(indices) + } + + /// Sends a finger move event rotating a specified angle and interpolating between the changes for the + /// specified duration. + /// + /// - parameter indices: The finger indices to rotate, must be two indices. + /// - parameter location: The center location between the two fingers. + /// - parameter distance: The distance between the two fingers. + /// - parameter radians: The angle in radians for the rotation, staring from zero. An angle of zero is + /// assumed to be horizontal and positive angle moves clockwise. + /// - parameter duration: The time to interpolate between the changes. + public func fingerRotate(_ indices: [FingerIndex?] = .automatic, at location: HammerLocatable? = nil, + withDistance distance: CGFloat = EventGenerator.rotationDistance, + angle radians: CGFloat, duration: TimeInterval) throws + { + try self.fingerRotate(indices, at: location, withDistance: distance, + fromAngle: 0, toAngle: radians, duration: duration) + } + + // MARK: - Event + + /// Sends a hand event. + /// + /// - parameter hand: The event to send. + private func sendEvent(hand: HandInfo) throws { + let machTime = mach_absolute_time() + let isTouching = hand.isTouching + + let event = IOHID.shared.createDigitizerEvent( + kCFAllocatorDefault, machTime, + IOHID.DigitizerTransducerType.hand.rawValue, 0, 0, + hand.eventMask.rawValue, + 0, 0, 0, 0, 0, 0, + false, isTouching, + kIOHIDEventOptionNone) + + IOHID.shared.eventSetIntegerValue(event, IOHID.EventField.Digitizer.isDisplayIntegrated.rawValue, 1) + IOHID.shared.eventSetSenderID(event, self.senderId) + + for finger in hand.fingers { + let identifier = try self.identifier(for: finger) + let subEvent = IOHID.shared.createDigitizerFingerEvent( + kCFAllocatorDefault, machTime, + identifier, finger.fingerIndex.rawValue, + finger.eventMask.rawValue, + finger.location.x, finger.location.y, 0, + finger.pressure, finger.twist, + isTouching, isTouching, + kIOHIDEventOptionNone) + + IOHID.shared.eventSetFloatValue(subEvent, IOHID.EventField.Digitizer.majorRadius.rawValue, + finger.majorRadius) + IOHID.shared.eventSetFloatValue(subEvent, IOHID.EventField.Digitizer.minorRadius.rawValue, + finger.minorRadius) + IOHID.shared.eventAppendEvent(event, subEvent, 0) + + self.debugWindow.update(fingerIndex: finger.fingerIndex, + location: finger.phase.isTouching ? finger.location : nil) + } + + try self.sendEvent(event, wait: true) + } + + private func identifier(for finger: FingerInfo) throws -> UInt32 { + let existingIdentifier = self.activeTouches.identifier(forFingerIndex: finger.fingerIndex) + switch finger.phase { + case .began: + guard existingIdentifier == nil else { + throw HammerError.touchForFingerAlreadyExists(index: finger.fingerIndex) + } + + let identifier = self.nextEventId() + try self.activeTouches.append(finger: finger, forIdentifier: identifier) + return identifier + case .moved, .stationary: + guard let existingIdentifier = existingIdentifier else { + throw HammerError.touchForFingerDoesNotExist(index: finger.fingerIndex) + } + + return existingIdentifier + case .ended, .cancelled: + guard let existingIdentifier = existingIdentifier else { + throw HammerError.touchForFingerDoesNotExist(index: finger.fingerIndex) + } + + self.activeTouches.remove(forIdentifier: existingIdentifier) + return existingIdentifier + case .regionEntered, .regionMoved, .regionExited: + throw HammerError.unsupportedTouchPhase(finger.phase) + @unknown default: + throw HammerError.unsupportedTouchPhase(finger.phase) + } + } + + private func nextFingerIndex() throws -> FingerIndex { + if let nextIndex = try self.fillNextFingerIndices(.automatic, withExpected: 1).first { + return nextIndex + } else { + throw HammerError.fingerLimitReached(limit: FingerIndex.defaultOrder.count) + } + } + + private func fillNextFingerIndices(_ indices: [FingerIndex?], + withExpected expected: Int) throws -> [FingerIndex] + { + if indices.count > 0 && indices.count != expected { + throw HammerError.invalidFingerCount(count: indices.count, expected: expected) + } + + var indices = indices + while indices.count < expected { + indices.append(.automatic) + } + + let activeFingerIndices = self.activeTouches.fingers.map(\.fingerIndex) + let unusedFingersIndices = FingerIndex.defaultOrder + .filter { !activeFingerIndices.contains($0) } + .filter { !indices.contains($0) } + + let nilCount = indices.filter({ $0 == .automatic }).count + guard nilCount <= unusedFingersIndices.count else { + throw HammerError.fingerLimitReached(limit: FingerIndex.defaultOrder.count) + } + + var nextIndices = unusedFingersIndices.prefix(nilCount) + return indices.compactMap { $0 ?? nextIndices.popFirst() } + } + + private func fillExistingFingerIndices(_ indices: [FingerIndex?], + withMinimum minimum: Int) throws -> [FingerIndex] + { + if indices.count > 0 && indices.count < minimum { + throw HammerError.invalidFingerCount(count: indices.count, expected: minimum) + } + + var indices = indices + while indices.count < minimum { + indices.append(.automatic) + } + + let activeFingerIndices = self.activeTouches.fingers.map(\.fingerIndex) + + guard indices.count <= activeFingerIndices.count else { + throw HammerError.fingerLimitReached(limit: FingerIndex.defaultOrder.count) + } + + let nilCount = indices.filter({ $0 == .automatic }).count + var nextIndices = activeFingerIndices.filter { !indices.contains($0) }.suffix(nilCount) + return indices.compactMap { $0 ?? nextIndices.popFirst() } + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Hand/FingerIndex.swift b/Sources/Hammer/EventGenerator/EventGenerator+Hand/FingerIndex.swift new file mode 100644 index 0000000..ebcc11a --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Hand/FingerIndex.swift @@ -0,0 +1,23 @@ +import UIKit + +public enum FingerIndex: UInt32, CaseIterable { + case rightThumb = 1 + case rightIndex = 2 + case rightMiddle = 3 + case rightRing = 4 + case rightLittle = 5 + + case leftThumb = 6 + case leftIndex = 7 + case leftMiddle = 8 + case leftRing = 9 + case leftLittle = 10 + + public static let automatic: FingerIndex? = nil + + static let defaultOrder = Array(FingerIndex.allCases.prefix(UIDevice.current.maxNumberOfFingers)) +} + +extension Array where Element == FingerIndex? { + public static let automatic: [FingerIndex?] = [] +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Hand/HandInfo.swift b/Sources/Hammer/EventGenerator/EventGenerator+Hand/HandInfo.swift new file mode 100644 index 0000000..b247a56 --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Hand/HandInfo.swift @@ -0,0 +1,37 @@ +import UIKit + +struct HandInfo { + let fingers: [FingerInfo] + + var isTouching: Bool { + return self.fingers.contains(where: \.isTouching) + } + + var eventMask: IOHID.DigitizerEventMask { + // Only touch and attribute are applicable + return self.fingers + .map(\.eventMask) + .reduce(IOHID.DigitizerEventMask()) { $0.union($1) } + .intersection([.touch, .attribute]) + } +} + +struct FingerInfo { + let fingerIndex: FingerIndex + let location: CGPoint + let phase: UITouch.Phase + + let pressure: CGFloat + let twist: CGFloat + + let majorRadius: CGFloat + let minorRadius: CGFloat + + var eventMask: IOHID.DigitizerEventMask { + return self.phase.eventMask.union(self.pressure > 0 ? .attribute : []) + } + + var isTouching: Bool { + return self.phase.isTouching + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Keyboard/EventGenerator+Keyboard.swift b/Sources/Hammer/EventGenerator/EventGenerator+Keyboard/EventGenerator+Keyboard.swift new file mode 100644 index 0000000..3c1dbc2 --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Keyboard/EventGenerator+Keyboard.swift @@ -0,0 +1,148 @@ +import Foundation + +extension EventGenerator { + public static let keyTypeInterval: TimeInterval = 0.02 + + // MARK: - Base Actions + + /// Sends a key down event. + /// + /// NOTE: The character is a representation for the key irrespective of any modifier keys. + /// + /// - parameter character: The character representing the key to press down. + public func keyDown(_ character: Character) throws { + let keyInfo = try KeyboardKey.fromCharacter(character) + try self.keyDown(keyInfo.key) + } + + /// Sends a key down event. + /// + /// - parameter key: The key to press down. + public func keyDown(_ key: KeyboardKey) throws { + try self.sendKeyboardEvent(key: key, isKeyDown: true) + } + + /// Sends a key up event. + /// + /// NOTE: The character is a representation for the key irrespective of any modifier keys. + /// + /// - parameter character: The character representing the key to release. + public func keyUp(_ character: Character) throws { + let keyInfo = try KeyboardKey.fromCharacter(character) + try self.keyUp(keyInfo.key) + } + + /// Sends a key up event. + /// + /// - parameter key: The key to release. + public func keyUp(_ key: KeyboardKey) throws { + try self.sendKeyboardEvent(key: key, isKeyDown: false) + } + + // MARK: - Press Actions + + /// Sends a key press event. + /// + /// NOTE: The character is a representation for the key irrespective of any modifier keys. To apply + /// modifier keys automatically use the `keyType()` method instead. + /// + /// - parameter character: The character representing the key to press. + public func keyPress(_ character: Character) throws { + let keyInfo = try KeyboardKey.fromCharacter(character) + try self.keyPress(keyInfo.key) + } + + /// Sends a key press event. + /// + /// - parameter key: The key to press. + public func keyPress(_ key: KeyboardKey) throws { + try self.keyDown(key) + try self.keyUp(key) + } + + /// Sends a key press event a specified number times. + /// + /// NOTE: The character is a representation for the key irrespective of any modifier keys. To apply + /// modifier keys automatically use the `keyType()` method instead. + /// + /// - parameter character: The character representing the key to press. + /// - parameter numberOfTimes: The number of times to press the key. + /// - parameter interval: The interval between key presses. + public func keyPress(_ character: Character, numberOfTimes: Int, + interval: TimeInterval = EventGenerator.keyTypeInterval) throws + { + let keyInfo = try KeyboardKey.fromCharacter(character) + try self.keyPress(keyInfo.key, numberOfTimes: numberOfTimes, interval: interval) + } + + /// Sends a key press event a specified number times. + /// + /// - parameter key: The key to press. + /// - parameter numberOfTimes: The number of times to press the key. + /// - parameter interval: The interval between key presses. + public func keyPress(_ key: KeyboardKey, numberOfTimes: Int, + interval: TimeInterval = EventGenerator.keyTypeInterval) throws + { + for i in 0..", "?", "~", +] + +private let kAlternateCharacterKeys: [Character: KeyboardKey] = [ + "`": .graveAccentAndTilde, + "~": .graveAccentAndTilde, + "!": .number1, + "@": .number2, + "#": .number3, + "$": .number4, + "%": .number5, + "^": .number6, + "&": .number7, + "*": .number8, + "(": .number9, + ")": .number0, + "0": .number0, + "-": .hyphen, + "_": .hyphen, + "=": .equalSign, + "+": .equalSign, + "[": .openBracket, + "{": .openBracket, + "]": .closeBracket, + "}": .closeBracket, + "\\": .backslash, + "|": .backslash, + ";": .semicolon, + ":": .semicolon, + "\'": .quote, + "\"": .quote, + ",": .comma, + "<": .comma, + ".": .period, + ">": .period, + "/": .slash, + "?": .slash, + " ": .spacebar, + "\r": .returnOrEnter, + "\n": .returnOrEnter, + "\t": .tab, +] + +extension KeyboardKey { + static func fromCharacter(_ character: Character) throws -> (key: KeyboardKey, shift: Bool) { + guard character.isASCII else { + throw HammerError.unknownKeyForCharacter(character) + } + + let uppercaseAlphabeticOffset = UInt32(Character("A").asciiValue ?? 0) - KeyboardKey.letterA.rawValue + let lowercaseAlphabeticOffset = UInt32(Character("a").asciiValue ?? 0) - KeyboardKey.letterA.rawValue + let numericNonZeroOffset = UInt32(Character("1").asciiValue ?? 0) - KeyboardKey.number1.rawValue + let characterCode = UInt32(character.asciiValue ?? 0) + + // Handle alphanumeric characters and basic symbols. + if characterCode >= 97 && characterCode <= 122 { // Handle a-z. + if let key = KeyboardKey(rawValue: characterCode - lowercaseAlphabeticOffset) { + return (key: key, shift: false) + } + } else if characterCode >= 65 && characterCode <= 90 { // Handle A-Z. + if let key = KeyboardKey(rawValue: characterCode - uppercaseAlphabeticOffset) { + return (key: key, shift: true) + } + } else if characterCode >= 49 && characterCode <= 57 { // Handle 1-9. + if let key = KeyboardKey(rawValue: characterCode - numericNonZeroOffset) { + return (key: key, shift: false) + } + } + + // Handle all other cases. + guard let key = kAlternateCharacterKeys[character] else { + throw HammerError.unknownKeyForCharacter(character) + } + + return (key: key, shift: kShiftSymbolCharacters.contains(character)) + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Marker.swift b/Sources/Hammer/EventGenerator/EventGenerator+Marker.swift new file mode 100644 index 0000000..3d528b3 --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Marker.swift @@ -0,0 +1,36 @@ +import CoreFoundation +import Darwin + +extension EventGenerator { + func sendMarkerEvent(withCompletionBlock completion: @escaping CompletionHandler) throws { + let eventId = self.nextEventId() + self.eventCallbacks[eventId] = completion + + let eventIdBytes = withUnsafeBytes(of: Int(eventId), Array.init) + let markerEvent = IOHID.shared.createVendorDefinedEvent( + kCFAllocatorDefault, mach_absolute_time(), + IOHID.Page.vendorDefinedStart.rawValue + 100, + 0, 1, + eventIdBytes, MemoryLayout.size(ofValue: eventIdBytes), + kIOHIDEventOptionNone) + + // NOTE: This should not be needed. It is a workaround because the previous method doesn't seem to be + // setting the data correctly + IOHID.shared.eventSetIntegerValue(markerEvent, IOHID.EventField.VendorDefined.data.rawValue, + Int(eventId)) + + try self.sendEvent(markerEvent, wait: false) + } + + func markerEventReceived(_ event: IOHIDEvent) { + guard IOHID.shared.eventGetType(event) == IOHID.EventType.vendorDefined.rawValue else { + return + } + + let callbackIDRaw = IOHID.shared.eventGetIntegerValue(event, + IOHID.EventField.VendorDefined.data.rawValue) + let callbackID = UInt32(callbackIDRaw) + let completionBlock = self.eventCallbacks.removeValue(forKey: callbackID) + completionBlock?() + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Stylus/EventGenerator+Stylus.swift b/Sources/Hammer/EventGenerator/EventGenerator+Stylus/EventGenerator+Stylus.swift new file mode 100644 index 0000000..359aa53 --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Stylus/EventGenerator+Stylus.swift @@ -0,0 +1,230 @@ +import CoreGraphics +import Foundation +import UIKit + +private let kStylusFingerId: UInt32 = 0 + +extension EventGenerator { + // MARK: - Base Actions + + /// Sends a stylus down event. + /// + /// - parameter location: The location where to touch down. Nil to use the center. + /// - parameter azimuth: The azimuth of the stylus in radians where 0 is true north. + /// - parameter altitude: The altitude of the stylus in radians where 0 is straight down. + /// - parameter pressure: The pressure of the touch, from 0 to 1. + public func stylusDown(at location: HammerLocatable? = nil, + azimuth: CGFloat = 0, altitude: CGFloat = 0, pressure: CGFloat = 0) throws + { + let location = try location?.hitPoint(for: self) ?? self.defaultTouchLocation + try self.checkPointsAreHittable([location]) + try self.sendEvent(stylus: StylusInfo(location: location, phase: .began, + pressure: pressure, twist: 0, + altitude: altitude, azimuth: azimuth)) + } + + /// Sends a stylus up event. + public func stylusUp() throws { + guard let location = self.activeTouches.stylus?.location else { + throw HammerError.touchForStylusDoesNotExist + } + + try self.sendEvent(stylus: StylusInfo(location: location, phase: .ended, pressure: 0, twist: 0, + altitude: 0, azimuth: 0)) + } + + // MARK: - Tap Actions + + /// Sends a stylus tap event. + /// + /// - parameter location: The location where to tap. Nil to use the center. + /// - parameter tapCount: The number of taps to perform. + /// - parameter interval: The interval between taps, if more than one. + /// - parameter azimuth: The azimuth of the stylus in radians where 0 is true north. + /// - parameter altitude: The altitude of the stylus in radians where 0 is straight down. + /// - parameter pressure: The pressure of the touch, from 0 to 1. + public func stylusTap(at location: HammerLocatable? = nil, numberOfTimes tapCount: Int, + interval: TimeInterval = EventGenerator.multiTapInterval, + azimuth: CGFloat = 0, altitude: CGFloat = 0, pressure: CGFloat = 0) throws + { + for i in 0.. UInt32 { + let existingIdentifier = self.activeTouches.stylusIdentifier + switch stylus.phase { + case .began: + guard existingIdentifier == nil else { + throw HammerError.touchForStylusAlreadyExists + } + + let identifier = self.nextEventId() + try self.activeTouches.set(stylus: stylus, forIdentifier: identifier) + return identifier + case .moved, .stationary: + guard let existingIdentifier = existingIdentifier else { + throw HammerError.touchForStylusDoesNotExist + } + + return existingIdentifier + case .ended, .cancelled: + guard let existingIdentifier = existingIdentifier else { + throw HammerError.touchForStylusDoesNotExist + } + + self.activeTouches.remove(forIdentifier: existingIdentifier) + return existingIdentifier + case .regionEntered, .regionMoved, .regionExited: + throw HammerError.unsupportedTouchPhase(stylus.phase) + @unknown default: + throw HammerError.unsupportedTouchPhase(stylus.phase) + } + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator+Stylus/StylusInfo.swift b/Sources/Hammer/EventGenerator/EventGenerator+Stylus/StylusInfo.swift new file mode 100644 index 0000000..80a73f2 --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator+Stylus/StylusInfo.swift @@ -0,0 +1,17 @@ +import Foundation +import UIKit + +struct StylusInfo { + let location: CGPoint + let phase: UITouch.Phase + + let pressure: CGFloat + let twist: CGFloat + + let altitude: CGFloat + let azimuth: CGFloat + + var eventMask: IOHID.DigitizerEventMask { + return self.phase.eventMask.union(.attribute) + } +} diff --git a/Sources/Hammer/EventGenerator/EventGenerator.swift b/Sources/Hammer/EventGenerator/EventGenerator.swift new file mode 100644 index 0000000..1e0eceb --- /dev/null +++ b/Sources/Hammer/EventGenerator/EventGenerator.swift @@ -0,0 +1,189 @@ +import CoreGraphics +import Foundation +import UIKit + +private enum Storage { + static var latestEventId: UInt32 = 0 +} + +/// Class for generating fake User Interaction events. +public final class EventGenerator { + typealias CompletionHandler = () -> Void + + /// The window for the events + public let window: UIWindow + + var activeTouches = TouchStorage() + var debugWindow = DebugVisualizerWindow() + var eventCallbacks = [UInt32: CompletionHandler]() + private var isUsingCustomWindow: Bool = false + + /// The default sender id for all events. + /// + /// Can be any value except 0. + public var senderId: UInt64 = 0x0000000123456789 + + /// If the generated touches should be displayed over the view. + public var showTouches: Bool { + get { self.debugWindow.isHidden == false } + set { self.debugWindow.isHidden = !newValue } + } + + /// The default location for touches when it's not specified. + var defaultTouchLocation: CGPoint { + self.window.bounds.center + } + + /// Initialize an event generator for a specified UIWindow. + /// + /// - parameter window: The window to receive events. + public init(window: UIWindow) throws { + self.window = window + self.window.layoutIfNeeded() + self.debugWindow.frame = self.window.frame + + UIApplication.swizzle() + UIApplication.registerForHIDEvents { [weak self] event in + self?.markerEventReceived(event) + } + + try self.waitUntilWindowIsReady() + } + + /// Initialize an event generator for a specified UIViewController. + /// + /// Event Generator will temporarily create a wrapper UIWindow to send touches. + /// + /// - parameter viewController: The viewController to receive events. + public convenience init(viewController: UIViewController) throws { + let window = UIWindow(wrapping: viewController) + + if #available(iOS 13.0, *) { + window.backgroundColor = .systemBackground + } else { + window.backgroundColor = .white + } + + window.makeKeyAndVisible() + window.layoutIfNeeded() + + try self.init(window: window) + self.isUsingCustomWindow = true + } + + /// Initialize an event generator for a specified UIView. + /// + /// Event Generator will temporarily create a wrapper UIWindow to send touches. + /// + /// - parameter view: The view to receive events. + public convenience init(view: UIView) throws { + try self.init(viewController: UIViewController(wrapping: view)) + } + + deinit { + if self.isUsingCustomWindow { + self.window.isHidden = true + self.window.rootViewController = nil + self.debugWindow.isHidden = true + self.debugWindow.rootViewController = nil + if #available(iOS 13.0, *) { + self.window.windowScene = nil + self.debugWindow.windowScene = nil + } + } + } + + /// Waits until the window is ready to receive user interaction events. + /// + /// - parameter timeout: The maximum time to wait for the window to be ready. + public func waitUntilWindowIsReady(timeout: TimeInterval = 2) throws { + do { + try self.wait(until: self.isWindowReady, timeout: timeout) + } catch { + throw HammerError.windowIsNotReadyForInteraction + } + } + + /// Returns if the window is ready to receive user interaction events + public var isWindowReady: Bool { + guard UIApplication.shared.keyWindow == self.window + && self.window.isKeyWindow + && self.window.isHidden == false + && self.window.isUserInteractionEnabled + && self.window.rootViewController?.viewIfLoaded != nil + && self.window.rootViewController?.isBeingPresented == false + && self.window.rootViewController?.isBeingDismissed == false + && self.window.rootViewController?.isMovingToParent == false + && self.window.rootViewController?.isMovingFromParent == false else + { + return false + } + + if #available(iOS 13.0, *) { + guard self.window.windowScene?.activationState == .foregroundActive else { + return false + } + } else { + guard !UIApplication.shared.isIgnoringInteractionEvents else { + return false + } + } + + return true + } + + /// Gets the next event ID to use. Event IDs are global and sequential. + /// + /// - returns: The next event ID. + func nextEventId() -> UInt32 { + Storage.latestEventId += 1 + return Storage.latestEventId + } + + /// Sends a user interaction event. + /// + /// - parameter event: The event to send. + /// - parameter wait: If we should wait until the event has finished being sent. + func sendEvent(_ event: IOHIDEvent, wait: Bool) throws { + guard let window = self.window as? UIWindow & UIWindowPrivate else { + throw HammerError.unableToAccessPrivateApi(type: "UIWindow", method: "Protocol") + } + + guard let app = UIApplication.shared as? UIApplication & UIApplicationPrivate else { + throw HammerError.unableToAccessPrivateApi(type: "UIApplication", method: "Protocol") + } + + let contextID = window.contextId + guard contextID != 0 else { + throw HammerError.unableToAccessPrivateApi(type: "UIWindow", method: "ContextID") + } + + BackBoardServices.shared.eventSetDigitizerInfo(event, contextID, false, false, nil, 0, 0) + + app.enqueue(event) + + if wait { + try self.waitForEvents() + } + } + + // MARK: - Sleep + + /// Sleeps the current thread until the events have finished sending. + private func waitForEvents() throws { + let runLoop = CFRunLoopGetCurrent() + try self.sendMarkerEvent { CFRunLoopStop(runLoop) } + CFRunLoopRun() + } + + /// Sleeps the current thread for the specified duration. + /// + /// - parameter duration: The duration to sleep. + func sleep(_ duration: TimeInterval) { + guard duration > 0 else { + return + } + + CFRunLoopRunInMode(.defaultMode, duration, false) + } +} diff --git a/Sources/Hammer/EventGenerator/HammerLocatable.swift b/Sources/Hammer/EventGenerator/HammerLocatable.swift new file mode 100644 index 0000000..9f5a991 --- /dev/null +++ b/Sources/Hammer/EventGenerator/HammerLocatable.swift @@ -0,0 +1,35 @@ +import UIKit + +public protocol HammerLocatable { + func hitPoint(for eventGenerator: EventGenerator) throws -> CGPoint +} + +extension CGPoint: HammerLocatable { + public func hitPoint(for eventGenerator: EventGenerator) throws -> CGPoint { + return self + } +} + +extension CGRect: HammerLocatable { + public func hitPoint(for eventGenerator: EventGenerator) throws -> CGPoint { + return self.center + } +} + +extension UIView: HammerLocatable { + public func hitPoint(for eventGenerator: EventGenerator) throws -> CGPoint { + return try eventGenerator.hitPoint(forView: self) + } +} + +extension UIViewController: HammerLocatable { + public func hitPoint(for eventGenerator: EventGenerator) throws -> CGPoint { + return try self.view.hitPoint(for: eventGenerator) + } +} + +extension String: HammerLocatable { + public func hitPoint(for eventGenerator: EventGenerator) throws -> CGPoint { + return try eventGenerator.viewWithIdentifier(self).hitPoint(for: eventGenerator) + } +} diff --git a/Sources/Hammer/Utilties/CoreGraphic+Extensions.swift b/Sources/Hammer/Utilties/CoreGraphic+Extensions.swift new file mode 100644 index 0000000..cf69374 --- /dev/null +++ b/Sources/Hammer/Utilties/CoreGraphic+Extensions.swift @@ -0,0 +1,83 @@ +import CoreGraphics +import Foundation + +extension CGPoint { + /// Calculates the offset point by translating using the specified x and y values. + /// + /// - parameter x: The offset in the horizontal direction, positive means to the right. + /// - parameter y: The offset in the vertical direction, positive means down. + /// + /// - returns: The offset point. + func offset(x: CGFloat, y: CGFloat) -> CGPoint { + return CGPoint(x: self.x + x, y: self.y + y) + } + + /// Calculates the offset point by moving a specified distance along an angle. A zero angle is assumed + /// to be straight to the right. + /// + /// - parameter distance: The distance to move in the angle. + /// - parameter radians: The angle to move in, 0 means straight to the right. + /// + /// - returns: The offset point. + func offset(_ distance: CGFloat, angle radians: CGFloat) -> CGPoint { + return self.offset(x: distance * cos(radians), y: distance * sin(radians)) + } + + /// Calculates the offset point by moving a specified distance at an angle. The distance will be split + /// between both directions. A zero angle is assumed to be straight to the right. + /// + /// - parameter distance: The distance to move in the angle + /// - parameter radians: The angle to move in, 0 means straight to the right. + /// + /// - returns: The offset point. + func twoWayOffset(_ distance: CGFloat, angle radians: CGFloat) -> [CGPoint] { + return [ + self.offset(distance / 2, angle: radians), + self.offset(distance / 2, angle: .pi - radians), + ] + } + + /// Calculates a new point by pivoting around an anchor by a specified angle. + /// + /// - parameter anchor: The point to pivot around. + /// - parameter radians: The angle to rotate. + /// + /// - returns: The offset point. + func pivot(anchor: CGPoint, angle radians: CGFloat) -> CGPoint { + return CGPoint(x: anchor.x + (self.x - anchor.x) * cos(radians) - (self.y - anchor.y) * sin(radians), + y: anchor.y + (self.x - anchor.x) * sin(radians) + (self.y - anchor.y) * cos(radians)) + } +} + +extension CGRect { + /// Convenience getter for the center of a rect. + var center: CGPoint { + return CGPoint(x: self.midX, y: self.midY) + } + + /// Returns if the specified rect is visible. + /// + /// - parameter rect: The rect to check in self coordinate space. + /// - parameter visibility: How determine if the rect is visible. + /// + /// - returns: If the rect is visible + func isVisible(_ rect: CGRect, visibility: EventGenerator.Visibility = .partial) -> Bool { + switch visibility { + case .partial: + return self.intersects(rect) + case .center: + return self.contains(rect.center) + case .full: + return self.contains(rect) + } + } +} + +func curveInterpolation(from start: CGFloat, to end: CGFloat, time: TimeInterval) -> CGFloat { + return start + (end - start) * CGFloat(sin(sin(time * .pi / 2) * time * .pi / 2)) +} + +func curveInterpolation(from start: CGPoint, to end: CGPoint, time: TimeInterval) -> CGPoint { + return CGPoint(x: curveInterpolation(from: start.x, to: end.x, time: time), + y: curveInterpolation(from: start.y, to: end.y, time: time)) +} diff --git a/Sources/Hammer/Utilties/HammerError.swift b/Sources/Hammer/Utilties/HammerError.swift new file mode 100644 index 0000000..6c0c374 --- /dev/null +++ b/Sources/Hammer/Utilties/HammerError.swift @@ -0,0 +1,32 @@ +import UIKit + +public enum HammerError: Error { + case windowIsNotReadyForInteraction + + case deviceDoesNotSupportTouches + case deviceDoesNotSupportStylus + + case touchForFingerAlreadyExists(index: FingerIndex) + case touchForFingerDoesNotExist(index: FingerIndex) + case fingerLimitReached(limit: Int) + case ranOutOfFingersForTouchUp + case invalidFingerCount(count: Int, expected: Int) + + case touchForStylusAlreadyExists + case touchForStylusDoesNotExist + + case unknownKeyForCharacter(Character) + + case unsupportedTouchPhase(UITouch.Phase) + + case unableToAccessPrivateApi(type: String, method: String) + + case viewIsNotInHierarchy + case viewIsNotVisible + case viewIsNotHittable + case pointIsNotHittable(point: CGPoint) + + case unableToFindView(identifier: String) + case invalidViewType(identifier: String) + case waitConditionTimeout +} diff --git a/Sources/Hammer/Utilties/Subviews.swift b/Sources/Hammer/Utilties/Subviews.swift new file mode 100644 index 0000000..3fdd10a --- /dev/null +++ b/Sources/Hammer/Utilties/Subviews.swift @@ -0,0 +1,269 @@ +import Foundation +import UIKit + +extension EventGenerator { + /// How to calculate visibility of a view + public enum Visibility { + /// Any part of the view is visible + case partial + + /// The center point of the view is visible + case center + + /// The whole view is visible + case full + } + + /// Searches the view's subviews recursively for the first one that has the specified identifier. + /// + /// NOTE: This uses a level order traversal with complexity O(n), where n is number of nodes in the tree. + /// + /// - parameter accessibilityIdentifier: The identifier to match. + /// + /// - throws: And error if the view is not found. + /// + /// - returns: The view. + public func viewWithIdentifier(_ accessibilityIdentifier: String) throws -> UIView { + var queue: [UIView] = [self.window] + while !queue.isEmpty { + let levelQueue = queue + queue = [] + + for node in levelQueue { + if node.accessibilityIdentifier == accessibilityIdentifier { + return node + } + + queue.append(contentsOf: node.subviews) + } + } + + throw HammerError.unableToFindView(identifier: accessibilityIdentifier) + } + + /// Searches the view's subviews recursively for the first one that has the specified identifier. + /// + /// NOTE: This uses a level order traversal with complexity O(n), where n is number of nodes in the tree. + /// + /// - parameter accessibilityIdentifier: The identifier to match. + /// - parameter type: The type of the view. + /// + /// - throws: And error if the view is not found or is of an invalid type. + /// + /// - returns: The view. + public func viewWithIdentifier(_ accessibilityIdentifier: String, + ofType type: T.Type) throws -> T + { + let view = try self.viewWithIdentifier(accessibilityIdentifier) + if let typedView = view as? T { + return typedView + } else { + throw HammerError.invalidViewType(identifier: accessibilityIdentifier) + } + } + + /// Searches the view's subviews recursively for the first one that has the specified identifier. If the + /// view does not exist it will check again multiple times until the specified timeout. + /// + /// NOTE: This uses a level order traversal with complexity O(n), where n is number of nodes in the tree. + /// + /// - parameter accessibilityIdentifier: The identifier to match. + /// - parameter timeout: The maximum time to wait for the closure to return an object. + /// - parameter checkInterval: How often should the closure be checked. + /// + /// - throws: And error if the view is not found. + /// + /// - returns: The view. + public func viewWithIdentifier(_ accessibilityIdentifier: String, + timeout: TimeInterval, + checkInterval: TimeInterval = 0.1) throws -> UIView + { + do { + return try self.wait(until: { + do { + return try self.viewWithIdentifier(accessibilityIdentifier) + } catch HammerError.unableToFindView { + return nil + } catch { + throw error + } + }(), timeout: timeout, checkInterval: checkInterval) + } catch HammerError.waitConditionTimeout { + throw HammerError.unableToFindView(identifier: accessibilityIdentifier) + } catch { + throw error + } + } + + /// Searches the view's subviews recursively for the first one that has the specified identifier. If the + /// view does not exist it will check again multiple times until the specified timeout. + /// + /// NOTE: This uses a level order traversal with complexity O(n), where n is number of nodes in the tree. + /// + /// - parameter accessibilityIdentifier: The identifier to match. + /// - parameter type: The type of the view. + /// - parameter timeout: The maximum time to wait for the closure to return an object. + /// - parameter checkInterval: How often should the closure be checked. + /// + /// - throws: And error if the view is not found. + /// + /// - returns: The view. + public func viewWithIdentifier(_ accessibilityIdentifier: String, ofType type: T.Type, + timeout: TimeInterval, + checkInterval: TimeInterval = 0.1) throws -> T + { + do { + return try self.wait(until: { + do { + return try self.viewWithIdentifier(accessibilityIdentifier, ofType: type) + } catch HammerError.unableToFindView { + return nil + } catch { + throw error + } + }(), timeout: timeout, checkInterval: checkInterval) + } catch HammerError.waitConditionTimeout { + throw HammerError.unableToFindView(identifier: accessibilityIdentifier) + } catch { + throw error + } + } + + /// Returns if the specified view is visible. + /// + /// NOTE: This will also return false if the view for the accessibility identifier is not found. + /// + /// - parameter accessibilityIdentifier: The identifier to check. + /// - parameter visibility: How determine if the view is visible. + /// + /// - returns: If the view is visible + public func viewIsVisible(_ accessibilityIdentifier: String, visibility: Visibility = .partial) -> Bool { + guard let view = try? self.viewWithIdentifier(accessibilityIdentifier) else { + return false + } + + return self.viewIsVisible(view, visibility: visibility) + } + + /// Returns if the specified view is visible. + /// + /// - parameter view: The view to check. + /// - parameter visibility: How determine if the view is visible. + /// + /// - returns: If the view is visible + public func viewIsVisible(_ view: UIView, visibility: Visibility = .partial) -> Bool { + // Recursive + func viewIsVisible(_ view: UIView, currentView: UIView, visibility: Visibility) -> Bool { + guard !currentView.isHidden && currentView.alpha >= 0.01 else { + return false + } + + guard let superview = currentView.superview else { + return currentView == self.window + } + + let adjustedRect = view.convert(view.bounds, to: superview) + guard superview.bounds.isVisible(adjustedRect, visibility: visibility) else { + return false + } + + return viewIsVisible(view, currentView: superview, visibility: visibility) + } + + return viewIsVisible(view, currentView: view, visibility: visibility) + } + + /// Returns if the specified rect is visible. + /// + /// - parameter rect: The rect in screen coordinates + /// - parameter visibility: How determine if the view is visible. + /// + /// - returns: If the rect is visible + public func rectIsVisible(_ rect: CGRect, visibility: Visibility = .partial) -> Bool { + return self.window.bounds.isVisible(rect, visibility: visibility) + } + + /// Returns if the specified point is visible. + /// + /// - parameter point: The point in screen coordinates + /// + /// - returns: If the point is visible + public func pointIsVisible(_ point: CGPoint) -> Bool { + return self.window.bounds.contains(point) + } + + /// Returns if the specified view is hittable. + /// + /// NOTE: This will also return false if the view for the accessibility identifier is not found. + /// + /// - parameter accessibilityIdentifier: The identifier to check. + /// + /// - returns: If the view is hittable + public func viewIsHittable(_ accessibilityIdentifier: String) -> Bool { + guard let view = try? self.viewWithIdentifier(accessibilityIdentifier) else { + return false + } + + return self.viewIsHittable(view) + } + + /// Returns if the specified view is hittable. + /// + /// - parameter view: The view to check. + /// + /// - returns: If the view is hittable + public func viewIsHittable(_ view: UIView) -> Bool { + guard let hitPoint = try? self.hitPoint(forView: view) else { + return false + } + + return self.window.hitTest(hitPoint, with: nil) == view + } + + /// Returns if the specified point has a hittable view at that location. + /// + /// - parameter point: The point in screen coordinates + /// + /// - returns: If the point is hittable + public func pointIsHittable(_ point: CGPoint) -> Bool { + return self.window.hitTest(point, with: nil) != nil + } + + /// Checks if the specified points have a hittable view at that location. + /// + /// - parameter points: The points in screen coordinates + /// + /// - throws: If one of the points is not hittable + func checkPointsAreHittable(_ points: [CGPoint]) throws { + for point in points { + if !self.pointIsHittable(point) { + throw HammerError.pointIsNotHittable(point: point) + } + } + } + + /// Returns a valid hittable point in the specified view. + /// + /// - parameter view: The view to hit + /// + /// - throws: And error if the view is not in the same hierarchy, not visible or not hittable. + /// + /// - returns: If the view is hittable + public func hitPoint(forView view: UIView) throws -> CGPoint { + guard view.isDescendant(of: self.window) else { + throw HammerError.viewIsNotInHierarchy + } + + let viewFrame = view.convert(view.bounds, to: self.window) + guard self.rectIsVisible(viewFrame) else { + throw HammerError.viewIsNotVisible + } + + let hitPoint = self.window.bounds.intersection(viewFrame).center + guard self.window.hitTest(hitPoint, with: nil) == view else { + throw HammerError.viewIsNotHittable + } + + return hitPoint + } +} diff --git a/Sources/Hammer/Utilties/TouchStorage.swift b/Sources/Hammer/Utilties/TouchStorage.swift new file mode 100644 index 0000000..6b707a5 --- /dev/null +++ b/Sources/Hammer/Utilties/TouchStorage.swift @@ -0,0 +1,57 @@ +import Foundation +import UIKit + +struct TouchStorage { + private typealias FingerStoreType = [(finger: FingerInfo, identifier: UInt32)] + private typealias StylusStoreType = (stylus: StylusInfo, identifier: UInt32) + + private var fingerStore = FingerStoreType() + private var stylusStore: StylusStoreType? + + var fingers: [FingerInfo] { + return self.fingerStore.map(\.finger) + } + + var stylus: StylusInfo? { + return self.stylusStore?.stylus + } + + var stylusIdentifier: UInt32? { + return self.stylusStore?.identifier + } + + func identifier(forFingerIndex fingerIndex: FingerIndex) -> UInt32? { + return self.fingerStore.first { $0.finger.fingerIndex == fingerIndex }?.identifier + } + + func fingers(forIndices indices: [FingerIndex]) -> [FingerInfo] { + return self.fingers.filter { indices.contains($0.fingerIndex) } + } + + mutating func append(finger: FingerInfo, forIdentifier identifier: UInt32) throws { + guard UIDevice.current.maxNumberOfFingers > 0 else { + throw HammerError.deviceDoesNotSupportTouches + } + + guard self.fingerStore.count < UIDevice.current.maxNumberOfFingers else { + throw HammerError.fingerLimitReached(limit: self.fingerStore.count) + } + + self.fingerStore.append((finger: finger, identifier: identifier)) + } + + mutating func set(stylus: StylusInfo, forIdentifier identifier: UInt32) throws { + guard UIDevice.current.supportsStylus else { + throw HammerError.deviceDoesNotSupportStylus + } + + self.stylusStore = (stylus: stylus, identifier: identifier) + } + + mutating func remove(forIdentifier identifier: UInt32) { + self.fingerStore.removeAll { $0.identifier == identifier } + if self.stylusStore?.identifier == identifier { + self.stylusStore = nil + } + } +} diff --git a/Sources/Hammer/Utilties/UIKit+Extensions.swift b/Sources/Hammer/Utilties/UIKit+Extensions.swift new file mode 100644 index 0000000..736db24 --- /dev/null +++ b/Sources/Hammer/Utilties/UIKit+Extensions.swift @@ -0,0 +1,74 @@ +import UIKit + +extension UITouch.Phase { + // Returns 1 for all events where the fingers are on the glass. + var isTouching: Bool { + return self == .began || self == .moved || self == .stationary + } + + var eventMask: IOHID.DigitizerEventMask { + var mask: IOHID.DigitizerEventMask = [] + + if self == .began || self == .ended || self == .cancelled { + mask.insert(.touch) + mask.insert(.range) + } + + if self == .moved { + mask.insert(.position) + } + + if self == .cancelled { + mask.insert(.cancel) + } + + return mask + } +} + +extension UIDevice { + public var maxNumberOfFingers: Int { + switch self.userInterfaceIdiom { + case .phone, .carPlay: + return 5 + case .pad: + return 10 + default: + return 0 + } + } + + public var supportsStylus: Bool { + return self.userInterfaceIdiom == .pad + } +} + +extension UIWindow { + convenience init(wrapping viewController: UIViewController) { + self.init(frame: UIScreen.main.bounds) + self.rootViewController = viewController + } +} + +extension UIViewController { + convenience init(wrapping view: UIView) { + self.init(nibName: nil, bundle: nil) + view.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(view) + NSLayoutConstraint.activate([ + view.topAnchor.constraint(equalTo: self.view.topAnchor).priority(.defaultHigh), + view.bottomAnchor.constraint(equalTo: self.view.bottomAnchor).priority(.defaultHigh), + view.leadingAnchor.constraint(equalTo: self.view.leadingAnchor).priority(.defaultHigh), + view.trailingAnchor.constraint(equalTo: self.view.trailingAnchor).priority(.defaultHigh), + view.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + view.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + ]) + } +} + +extension NSLayoutConstraint { + fileprivate func priority(_ priority: UILayoutPriority) -> NSLayoutConstraint { + self.priority = priority + return self + } +} diff --git a/Sources/Hammer/Utilties/Waiting.swift b/Sources/Hammer/Utilties/Waiting.swift new file mode 100644 index 0000000..bb5f947 --- /dev/null +++ b/Sources/Hammer/Utilties/Waiting.swift @@ -0,0 +1,183 @@ +import Foundation +import UIKit + +extension EventGenerator { + /// Waits for a specified time. + /// + /// - parameter interval: The maximum time to wait. + /// + /// - throws: An error if there was an issue during waiting. + public func wait(_ interval: TimeInterval) throws { + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, interval, false) + } + + /// Waits for a condition to become true within the specified time. + /// + /// - parameter condition: The condition to check. + /// - parameter timeout: The maximum time to wait for the condition to become true. + /// - parameter checkInterval: How often should the condition be checked. + /// + /// - throws: An error if the condition did not return true within the specified time. + public func wait(until condition: @autoclosure @escaping () throws -> Bool, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + let startTime = Date().timeIntervalSinceReferenceDate + while try !condition() { + if Date().timeIntervalSinceReferenceDate - startTime > timeout { + throw HammerError.waitConditionTimeout + } + + try self.wait(checkInterval) + } + } + + /// Waits for a closure to return non-nil within the specified time. + /// + /// - parameter exists: The closure to check. + /// - parameter timeout: The maximum time to wait for the closure to return an object. + /// - parameter checkInterval: How often should the closure be checked. + /// + /// - throws: An error if the closure did not return an object within the specified time. + /// + /// - returns: The non-nil object. + @discardableResult + public func wait(until exists: @autoclosure @escaping () throws -> T?, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws -> T + { + let startTime = Date().timeIntervalSinceReferenceDate + while true { + if let element = try exists() { + return element + } + + if Date().timeIntervalSinceReferenceDate - startTime > timeout { + throw HammerError.waitConditionTimeout + } + + try self.wait(checkInterval) + } + } + + /// Waits for a view with the specified identifier to exist within the specified time. + /// + /// - parameter accessibilityIdentifier: The identifier of the view to wait for. + /// - parameter timeout: The maximum time to wait for the view to be visible. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the view does not exist after the specified time. + public func wait(untilExists accessibilityIdentifier: String, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.viewWithIdentifier(accessibilityIdentifier), + timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for a view with the specified identifier to be visible within the specified time. + /// + /// - parameter accessibilityIdentifier: The identifier of the view to wait for. + /// - parameter visibility: How determine if the view is visible. + /// - parameter timeout: The maximum time to wait for the view to be visible. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the view does not exist after the specified time. + public func wait(untilVisible accessibilityIdentifier: String, visibility: Visibility = .partial, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.viewIsVisible(accessibilityIdentifier, visibility: visibility), + timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for a view with the specified identifier to be visible within the specified time. + /// + /// - parameter view: The view to wait for. + /// - parameter visibility: How determine if the view is visible. + /// - parameter timeout: The maximum time to wait for the view to be visible. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the view does not exist after the specified time. + public func wait(untilVisible view: UIView, visibility: Visibility = .partial, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.viewIsVisible(view, visibility: visibility), + timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for a rect to be visible on screen within the specified time. + /// + /// - parameter rect: The rect to wait for. + /// - parameter visibility: How determine if the view is visible. + /// - parameter timeout: The maximum time to wait for the rect to be visible. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the rect is not visible within the specified time. + public func wait(untilVisible rect: CGRect, visibility: Visibility = .partial, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.rectIsVisible(rect, visibility: visibility), + timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for a point to be visible on screen within the specified time. + /// + /// - parameter point: The point to wait for. + /// - parameter timeout: The maximum time to wait for the point to be visible. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the point is not visible within the specified time. + public func wait(untilVisible point: CGPoint, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.pointIsVisible(point), + timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for a view with the specified identifier to be hittable within the specified time. + /// + /// - parameter accessibilityIdentifier: The identifier of the view to wait for. + /// - parameter timeout: The maximum time to wait for the view to be hittable. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the view does not exist after the specified time. + public func wait(untilHittable accessibilityIdentifier: String, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.viewIsHittable(accessibilityIdentifier), + timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for a view with the specified identifier to be hittable within the specified time. + /// + /// - parameter view: The view to wait for. + /// - parameter timeout: The maximum time to wait for the view to be hittable. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the view does not exist after the specified time. + public func wait(untilHittable view: UIView, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.viewIsHittable(view), timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for a point to be visible and hittable on screen within the specified time. + /// + /// - parameter point: The point to wait for. + /// - parameter timeout: The maximum time to wait for the point to be hittable. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the point is not hittable within the specified time. + public func wait(untilHittable point: CGPoint, + timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws + { + try self.wait(until: self.pointIsHittable(point), timeout: timeout, checkInterval: checkInterval) + } + + /// Waits for the default touch location to be visible and hittable on screen within the specified time. + /// + /// - parameter timeout: The maximum time to wait for the point to be hittable. + /// - parameter checkInterval: How often should the view be checked. + /// + /// - throws: An error if the point is not hittable within the specified time. + public func waitUntilHittable(timeout: TimeInterval, checkInterval: TimeInterval = 0.1) throws { + try self.wait(until: self.defaultTouchLocation, timeout: timeout, checkInterval: checkInterval) + } +} diff --git a/TestHost/AppDelegate.swift b/TestHost/AppDelegate.swift new file mode 100644 index 0000000..dd8dbbe --- /dev/null +++ b/TestHost/AppDelegate.swift @@ -0,0 +1,11 @@ +import UIKit + +@main +class AppDelegate: UIResponder, UIApplicationDelegate { + func application( + _ application: UIApplication, + didFinishLaunchingWithOptions launchOptions: [UIApplication.LaunchOptionsKey: Any]?) -> Bool + { + return true + } +} diff --git a/TestHost/Info.plist b/TestHost/Info.plist new file mode 100644 index 0000000..b6b4206 --- /dev/null +++ b/TestHost/Info.plist @@ -0,0 +1,46 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + $(PRODUCT_BUNDLE_PACKAGE_TYPE) + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + LSRequiresIPhoneOS + + UIApplicationSupportsIndirectInputEvents + + UILaunchStoryboardName + LaunchScreen + UIRequiredDeviceCapabilities + + armv7 + + UISupportedInterfaceOrientations + + UIInterfaceOrientationPortrait + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + UIInterfaceOrientationPortraitUpsideDown + + UISupportedInterfaceOrientations~ipad + + UIInterfaceOrientationPortrait + UIInterfaceOrientationPortraitUpsideDown + UIInterfaceOrientationLandscapeLeft + UIInterfaceOrientationLandscapeRight + + + diff --git a/TestHost/LaunchScreen.storyboard b/TestHost/LaunchScreen.storyboard new file mode 100644 index 0000000..5d32a80 --- /dev/null +++ b/TestHost/LaunchScreen.storyboard @@ -0,0 +1,47 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Tests/HammerTests/DemoTests.swift b/Tests/HammerTests/DemoTests.swift new file mode 100644 index 0000000..30d7ccd --- /dev/null +++ b/Tests/HammerTests/DemoTests.swift @@ -0,0 +1,97 @@ +import Hammer +import UIKit +import XCTest +import MapKit + +/// These are skipped by default because they're too slow +private let kSkipDemoTests = true + +private let kMapDefaultCoordinate = CLLocationCoordinate2D(latitude: 37.773972, longitude: -122.431297) +private let kMapDefaultCoordinateDistance = CLLocationDistance(100000) +private let kMapDefaultCamera = MKMapCamera(lookingAtCenter: kMapDefaultCoordinate, + fromDistance: kMapDefaultCoordinateDistance, + pitch: 0, heading: 0) + +/// These tests are used to generate the recording for the readme, too slow for normal testing +final class DemoTests: XCTestCase { + func testASwitchToggleOnOff() throws { + try XCTSkipIf(kSkipDemoTests, "Demo tests are disabled") + + let view = UISwitch() + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.wait(0.5) + + XCTAssertEqual(view.isOn, false) + try eventGenerator.fingerDown(at: view.frame.center.offset(x: -20, y: 0)) + try eventGenerator.fingerMove(to: view.frame.center.offset(x: 20, y: 0), duration: 1) + try eventGenerator.fingerUp() + XCTAssertEqual(view.isOn, true) + try eventGenerator.fingerDown(at: view.frame.center.offset(x: 20, y: 0)) + try eventGenerator.fingerMove(to: view.frame.center.offset(x: -40, y: 0), duration: 1) + try eventGenerator.fingerUp() + XCTAssertEqual(view.isOn, false) + try eventGenerator.wait(1) + } + + func testBTypeOnTextField() throws { + try XCTSkipIf(kSkipDemoTests, "Demo tests are disabled") + + let view = UITextField() + view.textAlignment = .center + view.autocapitalizationType = .none + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.wait(0.5) + + view.becomeFirstResponder() + XCTAssertEqual(view.isFirstResponder, true) + + let text1 = "I can type in a text field!" + let text2 = "Symbols too! @#$%" + try eventGenerator.keyType(text1) + try eventGenerator.wait(0.5) + try eventGenerator.keyPress(.deleteOrBackspace, numberOfTimes: text1.count) + try eventGenerator.keyType(text2) + try eventGenerator.wait(0.5) + } + + func testCMapDrag() throws { + try XCTSkipIf(kSkipDemoTests, "Demo tests are disabled") + + let view = MapView() + view.widthAnchor.constraint(equalToConstant: 600).isActive = true + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.wait(0.5) + try eventGenerator.fingerDrag(from: view.frame.center.offset(x: -20, y: -100), + to: view.frame.center.offset(x: 20, y: 100), + duration: 1) + try eventGenerator.wait(0.5) + try eventGenerator.fingerPinchOpen(at: view.frame.center, duration: 2) + try eventGenerator.wait(0.5) + try eventGenerator.fingerPinchClose(at: view.frame.center, duration: 2) + try eventGenerator.wait(0.5) + try eventGenerator.fingerRotate(at: view.frame.center, angle: .pi/2, duration: 2) + try eventGenerator.wait(0.5) + try eventGenerator.fingerRotate(at: view.frame.center, angle: -.pi, duration: 2) + try eventGenerator.wait(0.5) + } +} + +private final class MapView: MKMapView { + init() { + super.init(frame: .zero) + self.showsCompass = false + self.showsUserLocation = false + self.setCamera(kMapDefaultCamera, animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} diff --git a/Tests/HammerTests/HandTests.swift b/Tests/HammerTests/HandTests.swift new file mode 100644 index 0000000..f376b85 --- /dev/null +++ b/Tests/HammerTests/HandTests.swift @@ -0,0 +1,202 @@ +import Hammer +import UIKit +import XCTest + +final class HandTests: XCTestCase { + func testButtonTap() throws { + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let expectation = XCTestExpectation(description: "Button Tapped") + view.addHandler(forEvent: .primaryActionTriggered, action: expectation.fulfill) + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.fingerTap() + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 1), .completed) + } + + func testButtonHighlight() throws { + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let touchDownExpectation = XCTestExpectation(description: "Button Touch Down") + view.addHandler(forEvent: .touchDown, action: touchDownExpectation.fulfill) + + let touchUpExpectation = XCTestExpectation(description: "Button Touch Up") + view.addHandler(forEvent: .touchUpInside, action: touchUpExpectation.fulfill) + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + XCTAssertFalse(view.isHighlighted) + try eventGenerator.fingerDown() + XCTAssertTrue(view.isHighlighted) + try eventGenerator.fingerUp() + XCTAssertFalse(view.isHighlighted) + + XCTAssertEqual(XCTWaiter.wait(for: [touchDownExpectation, touchUpExpectation], timeout: 1), + .completed) + } + + func testViewTapGesture() throws { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let expectation = XCTestExpectation(description: "Button Tapped") + let recognizer = UITapGestureRecognizer() + recognizer.addHandler(forState: .recognized, action: expectation.fulfill) + view.addGestureRecognizer(recognizer) + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.fingerTap() + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 1), .completed) + } + + func testViewDoubleTapGesture() throws { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let expectation = XCTestExpectation(description: "Button Double Tapped") + let recognizer = UITapGestureRecognizer() + recognizer.numberOfTapsRequired = 2 + recognizer.addHandler(forState: .recognized, action: expectation.fulfill) + view.addGestureRecognizer(recognizer) + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.fingerDoubleTap() + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed) + } + + func testViewTwoFingerTapGesture() throws { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let expectation = XCTestExpectation(description: "Button Two Finger Tapped") + let recognizer = UITapGestureRecognizer() + recognizer.numberOfTouchesRequired = 2 + recognizer.addHandler(forState: .recognized, action: expectation.fulfill) + view.addGestureRecognizer(recognizer) + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.twoFingerTap() + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed) + } + + func testViewLongPressGesture() throws { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let expectation = XCTestExpectation(description: "Button Long Pressed") + let recognizer = UILongPressGestureRecognizer() + recognizer.addHandler(forState: .recognized, action: expectation.fulfill) + view.addGestureRecognizer(recognizer) + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.fingerLongPress() + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed) + } + + func testViewRotationGesture() throws { + let view = UIView(frame: CGRect(x: 0, y: 0, width: 100, height: 100)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let expectation = XCTestExpectation(description: "View Rotated") + let recognizer = UIRotationGestureRecognizer() + recognizer.addHandler(forState: .recognized, action: expectation.fulfill) + view.addGestureRecognizer(recognizer) + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.fingerRotate(angle: .pi, duration: 0.2) + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 3), .completed) + } + + func testSwitchToggle() throws { + let view = UISwitch() + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + let expectation = XCTestExpectation(description: "Button Value Changed") + view.addHandler(forEvent: .valueChanged, action: expectation.fulfill) + + XCTAssertFalse(view.isOn) + try eventGenerator.fingerTap() + XCTAssertTrue(view.isOn) + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 1), .completed) + } + + func testSwitchToggleOnOff() throws { + let view = UISwitch() + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + XCTAssertFalse(view.isOn) + try eventGenerator.fingerDown(at: view.frame.center.offset(x: -20, y: 0)) + try eventGenerator.fingerMove(to: view.frame.center.offset(x: 40, y: 0), duration: 0.5) + try eventGenerator.fingerUp() + XCTAssertTrue(view.isOn) + try eventGenerator.fingerDown(at: view.frame.center.offset(x: 20, y: 0)) + try eventGenerator.fingerMove(to: view.frame.center.offset(x: -40, y: 0), duration: 0.5) + try eventGenerator.fingerUp() + XCTAssertFalse(view.isOn) + } + + func testScrollViewDrag() throws { + let view = PatternScrollView() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + XCTAssertEqual(view.contentOffset, CGPoint(x: 0, y: 0)) + try eventGenerator.fingerDrag(from: view.frame.center.offset(x: 40, y: 100), + to: view.frame.center.offset(x: -40, y: -100), + duration: 1) + XCTAssertEqual(view.contentOffset, CGPoint(x: 75, y: 190), accuracy: 2) + } + + func testScrollViewPinch() throws { + let view = PatternScrollView() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.wait(0.3) + XCTAssertEqual(view.zoomScale, 1) + try eventGenerator.fingerPinchOpen(duration: 1) + try eventGenerator.wait(0.3) + XCTAssertEqual(view.zoomScale, 6.9, accuracy: 1) + try eventGenerator.wait(0.3) + try eventGenerator.fingerPinchClose(duration: 1) + XCTAssertEqual(view.zoomScale, 1, accuracy: 0.1) + } +} diff --git a/Tests/HammerTests/KeyboardTests.swift b/Tests/HammerTests/KeyboardTests.swift new file mode 100644 index 0000000..6bb4ccc --- /dev/null +++ b/Tests/HammerTests/KeyboardTests.swift @@ -0,0 +1,166 @@ +import Hammer +import UIKit +import XCTest + +final class KeyboardTests: XCTestCase { + func testTypeOnTextField() throws { + let view = UITextField() + view.disablePredictiveBar() + view.autocapitalizationType = .none + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + view.becomeFirstResponder() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType("abc") + XCTAssertEqual(view.text, "abc") + } + + func testKeyOnTextField() throws { + let view = UITextField() + view.disablePredictiveBar() + view.autocapitalizationType = .none + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyDown("a") + try eventGenerator.keyUp("a") + XCTAssertEqual(view.text, "a") + } + + func testUppercaseCharacters() throws { + let keys = "ABCDEFGHIJKLMNOPQRSTUVWXYZ" + + let view = UITextField() + view.disablePredictiveBar() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.smartQuotesType = .no + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType(keys) + XCTAssertEqual(view.text, keys) + } + + func testLowercaseCharacters() throws { + let keys = "abcdefghijklmnopqrstuvwxyz" + + let view = UITextField() + view.disablePredictiveBar() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.smartQuotesType = .no + view.autocapitalizationType = .none + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType(keys) + XCTAssertEqual(view.text, keys) + } + + func testNumberCharacters() throws { + let keys = "0123456789" + + let view = UITextField() + view.disablePredictiveBar() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.smartQuotesType = .no + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType(keys) + XCTAssertEqual(view.text, keys) + } + + func testSymbolCharacters() throws { + let keys = "-=,./;'[]\\" + + let view = UITextField() + view.disablePredictiveBar() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.smartQuotesType = .no + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType(keys) + XCTAssertEqual(view.text, keys) + } + + func testShiftCharacters() throws { + let keys = "!@#$%^&*()_+{}|:\"<>?~" + + let view = UITextField() + view.disablePredictiveBar() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.smartQuotesType = .no + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType(keys) + XCTAssertEqual(view.text, keys) + } + + func testSpaceCharacter() throws { + let keys = "a a" + + let view = UITextField() + view.disablePredictiveBar() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.autocapitalizationType = .none + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType(keys) + XCTAssertEqual(view.text, keys) + } + + func testNewlineCharacters() throws { + let keys = "a\na\ra" + let result = "a\na\na" + + let view = UITextView() + view.disablePredictiveBar() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.autocapitalizationType = .none + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + try eventGenerator.fingerTap() + XCTAssertTrue(view.isFirstResponder) + + XCTAssertEqual(view.text, "") + try eventGenerator.keyType(keys) + XCTAssertEqual(view.text, result) + } +} diff --git a/Tests/HammerTests/MapTests.swift b/Tests/HammerTests/MapTests.swift new file mode 100644 index 0000000..2ebd7ac --- /dev/null +++ b/Tests/HammerTests/MapTests.swift @@ -0,0 +1,105 @@ +import Hammer +import MapKit +import UIKit +import XCTest + +/// These are skipped by default because they are flaky +private let kSkipMapTests = true + +private let kMapDefaultCoordinate = CLLocationCoordinate2D(latitude: 37.773972, longitude: -122.431297) +private let kMapDefaultCoordinateDistance = CLLocationDistance(100000) +private let kMapDefaultCamera = MKMapCamera(lookingAtCenter: kMapDefaultCoordinate, + fromDistance: kMapDefaultCoordinateDistance, + pitch: 0, heading: 0) + +final class MapTests: XCTestCase { + func testMapDrag() throws { + try XCTSkipIf(kSkipMapTests, "Map tests are disabled because of flakiness") + + let view = MapView() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + XCTAssertEqual(view.camera.centerCoordinate, kMapDefaultCoordinate, accuracy: 0.001) + try eventGenerator.fingerDrag(from: view.frame.center.offset(x: -20, y: -100), + to: view.frame.center.offset(x: 20, y: 100), + duration: 1) + sleep(0.5) + let newCoordinate = CLLocationCoordinate2D(latitude: 38.07913, longitude: -122.50843) + XCTAssertEqual(view.camera.centerCoordinate, newCoordinate, accuracy: 0.01) + } + + func testMapDoubleTap() throws { + try XCTSkipIf(kSkipMapTests, "Map tests are disabled because of flakiness") + + let view = MapView() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + XCTAssertEqual(view.camera.altitude, kMapDefaultCoordinateDistance, accuracy: 1) + try eventGenerator.fingerDoubleTap(at: view.frame.center, interval: 0.1) + sleep(0.5) + XCTAssertEqual(view.camera.altitude, 67445, accuracy: 50) + try eventGenerator.twoFingerTap(at: view.frame.center) + sleep(0.5) + XCTAssertEqual(view.camera.altitude, 134889, accuracy: 50) + } + + func testMapPinch() throws { + try XCTSkipIf(kSkipMapTests, "Map tests are disabled because of flakiness") + + let view = MapView() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + XCTAssertEqual(view.camera.altitude, kMapDefaultCoordinateDistance, accuracy: 1) + try eventGenerator.fingerPinchOpen(at: view.frame.center, duration: 1) + sleep(0.5) + XCTAssertEqual(view.camera.altitude, 14000, accuracy: 50) + try eventGenerator.fingerPinchClose(at: view.frame.center, duration: 1) + sleep(0.5) + XCTAssertEqual(view.camera.altitude, 134400, accuracy: 50) + } + + func testMapRotate() throws { + try XCTSkipIf(kSkipMapTests, "Map tests are disabled because of flakiness") + + let view = MapView() + view.widthAnchor.constraint(equalToConstant: 300).isActive = true + view.heightAnchor.constraint(equalToConstant: 300).isActive = true + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + XCTAssertEqual(view.camera.heading, 0, accuracy: 1) + try eventGenerator.fingerRotate(at: view.frame.center, angle: .pi/2, duration: 1) + sleep(0.5) + XCTAssertEqual(view.camera.heading, 275, accuracy: 3) + try eventGenerator.fingerRotate(at: view.frame.center, angle: -.pi, duration: 1) + sleep(0.5) + XCTAssertEqual(view.camera.heading, 90, accuracy: 3) + } +} + +private final class MapView: MKMapView { + init() { + super.init(frame: .zero) + self.showsCompass = false + self.showsUserLocation = false + self.setCamera(kMapDefaultCamera, animated: false) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } +} + +private func sleep(_ duration: CFTimeInterval) { + CFRunLoopRunInMode(CFRunLoopMode.defaultMode, duration, false) +} diff --git a/Tests/HammerTests/StylusTests.swift b/Tests/HammerTests/StylusTests.swift new file mode 100644 index 0000000..1756747 --- /dev/null +++ b/Tests/HammerTests/StylusTests.swift @@ -0,0 +1,72 @@ +import Hammer +import UIKit +import XCTest + +final class StylusTests: XCTestCase { + func testButtonTap() throws { + try XCTSkipUnless(UIDevice.current.supportsStylus, "Stylus tests only run on iPad") + + let expectation = XCTestExpectation(description: "Button Tapped") + + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.addHandler(forEvent: .primaryActionTriggered, action: expectation.fulfill) + + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + try eventGenerator.stylusTap() + + XCTAssertEqual(XCTWaiter.wait(for: [expectation], timeout: 1), .completed) + } + + func testButtonHighlight() throws { + try XCTSkipUnless(UIDevice.current.supportsStylus, "Stylus tests only run on iPad") + + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + XCTAssertFalse(view.isHighlighted) + try eventGenerator.stylusDown() + XCTAssertTrue(view.isHighlighted) + try eventGenerator.stylusUp() + XCTAssertFalse(view.isHighlighted) + } + + func testSwitchToggle() throws { + try XCTSkipUnless(UIDevice.current.supportsStylus, "Stylus tests only run on iPad") + + let view = UISwitch() + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + XCTAssertFalse(view.isOn) + try eventGenerator.stylusTap() + XCTAssertTrue(view.isOn) + } + + func testSwitchToggleOnOff() throws { + try XCTSkipUnless(UIDevice.current.supportsStylus, "Stylus tests only run on iPad") + + let view = UISwitch() + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + XCTAssertFalse(view.isOn) + try eventGenerator.stylusDown(at: view.frame.center.offset(x: -20, y: 0)) + try eventGenerator.stylusMove(to: view.frame.center.offset(x: 40, y: 0), duration: 0.5) + try eventGenerator.stylusUp() + XCTAssertTrue(view.isOn) + try eventGenerator.stylusDown(at: view.frame.center.offset(x: 20, y: 0)) + try eventGenerator.stylusMove(to: view.frame.center.offset(x: -40, y: 0), duration: 0.5) + try eventGenerator.stylusUp() + XCTAssertFalse(view.isOn) + } +} diff --git a/Tests/HammerTests/Utilities/PatternScrollView.swift b/Tests/HammerTests/Utilities/PatternScrollView.swift new file mode 100644 index 0000000..095d331 --- /dev/null +++ b/Tests/HammerTests/Utilities/PatternScrollView.swift @@ -0,0 +1,78 @@ +import Foundation +import UIKit + +private let kCircleDiameter: CGFloat = 20 + +final class PatternScrollView: UIScrollView, UIScrollViewDelegate { + init(contentSize: CGSize = .init(width: 1000, height: 1000)) { + super.init(frame: .zero) + self.contentInsetAdjustmentBehavior = .never + self.maximumZoomScale = 10 + self.delegate = self + + let contentView = PatternView() + contentView.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(contentView) + + NSLayoutConstraint.activate([ + contentView.widthAnchor.constraint(equalToConstant: contentSize.width), + contentView.heightAnchor.constraint(equalToConstant: contentSize.height), + + contentView.topAnchor.constraint(equalTo: self.contentLayoutGuide.topAnchor), + contentView.bottomAnchor.constraint(equalTo: self.contentLayoutGuide.bottomAnchor), + contentView.leadingAnchor.constraint(equalTo: self.contentLayoutGuide.leadingAnchor), + contentView.trailingAnchor.constraint(equalTo: self.contentLayoutGuide.trailingAnchor), + ]) + } + + func addSubview(_ view: UIView, at rect: CGRect) { + view.translatesAutoresizingMaskIntoConstraints = false + self.addSubview(view) + + NSLayoutConstraint.activate([ + view.widthAnchor.constraint(equalToConstant: rect.width), + view.heightAnchor.constraint(equalToConstant: rect.height), + + view.leadingAnchor.constraint(equalTo: self.contentLayoutGuide.leadingAnchor, + constant: rect.minX), + view.topAnchor.constraint(equalTo: self.contentLayoutGuide.topAnchor, + constant: rect.minY), + ]) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + func viewForZooming(in scrollView: UIScrollView) -> UIView? { + return self.subviews.first + } +} + +final class PatternView: UIView { + override func draw(_ rect: CGRect) { + guard let ctx = UIGraphicsGetCurrentContext() else { + return + } + + ctx.setFillColor(red: 0.8, green: 0.8, blue: 0.8, alpha: 1) + ctx.setStrokeColor(red: 0, green: 0, blue: 0, alpha: 1) + ctx.fill(rect) + + let size = CGSize(width: kCircleDiameter, height: kCircleDiameter) + + var currentY = kCircleDiameter / 2 + while currentY < rect.height { + var currentX = kCircleDiameter / 2 + while currentX < rect.width { + let point = CGPoint(x: currentX, y: currentY) + ctx.strokeEllipse(in: CGRect(origin: point, size: size)) + + currentX += kCircleDiameter * 2 + } + + currentY += kCircleDiameter * 2 + } + } +} diff --git a/Tests/HammerTests/Utilities/UIKit+Actions.swift b/Tests/HammerTests/Utilities/UIKit+Actions.swift new file mode 100644 index 0000000..bccc721 --- /dev/null +++ b/Tests/HammerTests/Utilities/UIKit+Actions.swift @@ -0,0 +1,47 @@ +import UIKit + +private var kActionKey: UInt8 = 0 + +private final class ActionWrapper { + private let action: () -> Void + + init(action: @escaping () -> Void) { + self.action = action + } + + @objc + func invoke() { + self.action() + } +} + +extension UIControl { + private var actionWrappers: [ActionWrapper] { + get { objc_getAssociatedObject(self, &kActionKey) as? [ActionWrapper] ?? [] } + set { objc_setAssociatedObject(self, &kActionKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + func addHandler(forEvent event: Event, action: @escaping () -> Void) { + let target = ActionWrapper(action: action) + self.addTarget(target, action: #selector(ActionWrapper.invoke), for: event) + self.actionWrappers.append(target) + } +} + +extension UIGestureRecognizer { + private var actionWrappers: [ActionWrapper] { + get { objc_getAssociatedObject(self, &kActionKey) as? [ActionWrapper] ?? [] } + set { objc_setAssociatedObject(self, &kActionKey, newValue, .OBJC_ASSOCIATION_RETAIN_NONATOMIC) } + } + + func addHandler(forState state: State, action: @escaping () -> Void) { + let target = ActionWrapper { [weak self] in + if let this = self, this.state == state { + action() + } + } + + self.addTarget(target, action: #selector(ActionWrapper.invoke)) + self.actionWrappers.append(target) + } +} diff --git a/Tests/HammerTests/Utilities/UIKit+Extensions.swift b/Tests/HammerTests/Utilities/UIKit+Extensions.swift new file mode 100644 index 0000000..3d02522 --- /dev/null +++ b/Tests/HammerTests/Utilities/UIKit+Extensions.swift @@ -0,0 +1,37 @@ +import CoreGraphics +import UIKit + +extension CGRect { + /// Convenience getter for the center of a rect. + var center: CGPoint { + return CGPoint(x: self.midX, y: self.midY) + } +} + +extension CGPoint { + /// Calculates the offset point by translating using the specified x and y values. + /// + /// - parameter x: The offset in the horizontal direction, positive means to the right + /// - parameter y: The offset in the vertical direction, positive means down + /// + /// - returns: The offset point + func offset(x: CGFloat, y: CGFloat) -> CGPoint { + return CGPoint(x: self.x + x, y: self.y + y) + } +} + +extension UITextField { + /// Disables the predictive bar which causes autolayout issues + func disablePredictiveBar() { + self.inputAssistantItem.leadingBarButtonGroups = [] + self.inputAssistantItem.trailingBarButtonGroups = [] + } +} + +extension UITextView { + /// Disables the predictive bar which causes autolayout issues + func disablePredictiveBar() { + self.inputAssistantItem.leadingBarButtonGroups = [] + self.inputAssistantItem.trailingBarButtonGroups = [] + } +} diff --git a/Tests/HammerTests/Utilities/XCTest+Extensions.swift b/Tests/HammerTests/Utilities/XCTest+Extensions.swift new file mode 100644 index 0000000..c82b6bf --- /dev/null +++ b/Tests/HammerTests/Utilities/XCTest+Extensions.swift @@ -0,0 +1,27 @@ +import CoreLocation +import CoreGraphics +import XCTest + +func XCTAssertEqual(_ expression1: @autoclosure () -> CLLocationCoordinate2D, + _ expression2: @autoclosure () -> CLLocationCoordinate2D, + accuracy: Double, _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) +{ + let coordinate1 = expression1() + let coordinate2 = expression2() + XCTAssertEqual(coordinate1.latitude, coordinate2.latitude, accuracy: accuracy, + message(), file: file, line: line) + XCTAssertEqual(coordinate1.longitude, coordinate2.longitude, accuracy: accuracy, + message(), file: file, line: line) +} + +func XCTAssertEqual(_ expression1: @autoclosure () -> CGPoint, + _ expression2: @autoclosure () -> CGPoint, + accuracy: CGFloat, _ message: @autoclosure () -> String = "", + file: StaticString = #filePath, line: UInt = #line) +{ + let point1 = expression1() + let point2 = expression2() + XCTAssertEqual(point1.x, point2.x, accuracy: accuracy, message(), file: file, line: line) + XCTAssertEqual(point1.y, point2.y, accuracy: accuracy, message(), file: file, line: line) +} diff --git a/Tests/HammerTests/ViewControllerTests.swift b/Tests/HammerTests/ViewControllerTests.swift new file mode 100644 index 0000000..678725a --- /dev/null +++ b/Tests/HammerTests/ViewControllerTests.swift @@ -0,0 +1,176 @@ +import Foundation +import Hammer +import UIKit +import XCTest + +final class ViewControllerTests: XCTestCase { + func testSignIn() throws { + let viewController = TestSignInViewController() + let navigationController = UINavigationController(rootViewController: viewController) + let eventGenerator = try EventGenerator(viewController: navigationController) + try eventGenerator.wait(untilHittable: "username_field", timeout: 1) + + let usernameTextField = try eventGenerator.viewWithIdentifier("username_field", + ofType: UITextField.self) + let passwordTextField = try eventGenerator.viewWithIdentifier("password_field", + ofType: UITextField.self) + let signInButton = try eventGenerator.viewWithIdentifier("signin_button", + ofType: UIButton.self) + + try eventGenerator.fingerTap(at: "username_field") + XCTAssertTrue(usernameTextField.isFirstResponder) + try eventGenerator.keyType("GabrielUsername123") + XCTAssertEqual(usernameTextField.text, "GabrielUsername123") + try eventGenerator.keyPress(.returnOrEnter) + XCTAssertTrue(passwordTextField.isFirstResponder) + XCTAssertFalse(signInButton.isEnabled) + try eventGenerator.keyType("$eCr3tP@ss!") + XCTAssertEqual(passwordTextField.text, "$eCr3tP@ss!") + XCTAssertTrue(signInButton.isEnabled) + try eventGenerator.keyPress(.returnOrEnter) + + try eventGenerator.wait(untilExists: "username_label", timeout: 1) + let usernameLabel = try eventGenerator.viewWithIdentifier("username_label", ofType: UILabel.self) + + XCTAssertEqual(usernameLabel.text, "Hello GabrielUsername123") + } +} + +private final class TestSignInViewController: UIViewController, UITextFieldDelegate { + let usernameTextField: UITextField = { + let view = UITextField() + view.disablePredictiveBar() + view.accessibilityIdentifier = "username_field" + view.borderStyle = .roundedRect + view.placeholder = "Username" + return view + }() + + let passwordTextField: UITextField = { + let view = UITextField() + view.disablePredictiveBar() + view.accessibilityIdentifier = "password_field" + view.borderStyle = .roundedRect + view.placeholder = "Password" + view.isSecureTextEntry = true + return view + }() + + let signInButton: UIButton = { + let view = UIButton() + view.accessibilityIdentifier = "signin_button" + view.setTitle("Sign In", for: .normal) + view.layer.cornerRadius = 8 + view.heightAnchor.constraint(equalToConstant: 48).isActive = true + return view + }() + + override func viewDidLoad() { + super.viewDidLoad() + if #available(iOS 13.0, *) { + self.view.backgroundColor = .systemBackground + } else { + self.view.backgroundColor = .white + } + + let stackView = UIStackView() + stackView.axis = .vertical + stackView.spacing = 8 + stackView.layoutMargins = UIEdgeInsets(top: 40, left: 16, bottom: 40, right: 16) + stackView.isLayoutMarginsRelativeArrangement = true + stackView.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(stackView) + NSLayoutConstraint.activate([ + stackView.topAnchor.constraint(equalTo: self.view.topAnchor), + stackView.bottomAnchor.constraint(equalTo: self.view.bottomAnchor), + stackView.leadingAnchor.constraint(equalTo: self.view.leadingAnchor), + stackView.trailingAnchor.constraint(equalTo: self.view.trailingAnchor), + ]) + + let titleLabel = UILabel() + titleLabel.text = "Welcome to Hammer!" + titleLabel.font = .boldSystemFont(ofSize: 24) + stackView.addArrangedSubview(titleLabel) + + let subtitleLabel = UILabel() + subtitleLabel.text = "Please sign in" + subtitleLabel.font = .systemFont(ofSize: 16) + stackView.addArrangedSubview(subtitleLabel) + stackView.setCustomSpacing(24, after: subtitleLabel) + + self.usernameTextField.delegate = self + self.usernameTextField.addTarget(self, action: #selector(self.updateButton), for: .editingChanged) + stackView.addArrangedSubview(self.usernameTextField) + + self.passwordTextField.delegate = self + self.passwordTextField.addTarget(self, action: #selector(self.updateButton), for: .editingChanged) + stackView.addArrangedSubview(self.passwordTextField) + + stackView.addArrangedSubview(UIView()) // Stretchy spacer + + self.signInButton.addTarget(self, action: #selector(self.performSignIn), for: .touchUpInside) + stackView.addArrangedSubview(self.signInButton) + + self.updateButton() + } + + func textFieldShouldReturn(_ textField: UITextField) -> Bool { + if textField == self.usernameTextField { + self.passwordTextField.becomeFirstResponder() + } else if textField == self.passwordTextField { + self.performSignIn() + } + + return false + } + + @objc + private func updateButton() { + let isUsernameEmpty = self.usernameTextField.text?.isEmpty ?? true + let isPasswordEmpty = self.passwordTextField.text?.isEmpty ?? true + let isEnabled = !isUsernameEmpty && !isPasswordEmpty + self.signInButton.isEnabled = isEnabled + self.signInButton.backgroundColor = isEnabled ? .systemGreen : .lightGray + } + + @objc + private func performSignIn() { + self.resignFirstResponder() + + let profileViewController = TestProfileViewController(username: self.usernameTextField.text ?? "Err") + self.navigationController?.pushViewController(profileViewController, animated: true) + } +} + +private final class TestProfileViewController: UIViewController { + private let username: String + + init(username: String) { + self.username = username + super.init(nibName: nil, bundle: nil) + } + + @available(*, unavailable) + required init?(coder: NSCoder) { + fatalError("init(coder:) has not been implemented") + } + + override func viewDidLoad() { + super.viewDidLoad() + if #available(iOS 13.0, *) { + self.view.backgroundColor = .systemBackground + } else { + self.view.backgroundColor = .white + } + + let titleLabel = UILabel() + titleLabel.text = "Hello \(self.username)" + titleLabel.accessibilityIdentifier = "username_label" + titleLabel.translatesAutoresizingMaskIntoConstraints = false + self.view.addSubview(titleLabel) + NSLayoutConstraint.activate([ + titleLabel.centerYAnchor.constraint(equalTo: self.view.centerYAnchor), + titleLabel.centerXAnchor.constraint(equalTo: self.view.centerXAnchor), + ]) + } +} diff --git a/Tests/HammerTests/WaitingTests.swift b/Tests/HammerTests/WaitingTests.swift new file mode 100644 index 0000000..863e1a9 --- /dev/null +++ b/Tests/HammerTests/WaitingTests.swift @@ -0,0 +1,103 @@ +import CoreGraphics +import Dispatch +import Hammer +import UIKit +import XCTest + +final class WaitingTests: XCTestCase { + func testWaitUntilVisibleWithIdentifier() throws { + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.accessibilityIdentifier = "my_button" + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + view.isHidden = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + view.isHidden = false + } + + XCTAssertFalse(eventGenerator.viewIsVisible("my_button")) + try eventGenerator.wait(untilVisible: "my_button", timeout: 1) + XCTAssertTrue(eventGenerator.viewIsVisible("my_button")) + } + + func testWaitUntilVisible() throws { + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.accessibilityIdentifier = "my_button" + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + view.isHidden = true + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + view.isHidden = false + } + + XCTAssertFalse(eventGenerator.viewIsVisible(view)) + try eventGenerator.wait(untilVisible: view, timeout: 1) + XCTAssertTrue(eventGenerator.viewIsVisible(view)) + } + + func testWaitUntilVisibleMove() throws { + let view = UIButton() + view.accessibilityIdentifier = "my_button" + view.backgroundColor = .green + + let scrollView = PatternScrollView() + scrollView.widthAnchor.constraint(equalToConstant: 300).isActive = true + scrollView.heightAnchor.constraint(equalToConstant: 300).isActive = true + scrollView.addSubview(view, at: CGRect(x: 0, y: 400, width: 50, height: 50)) + + let eventGenerator = try EventGenerator(view: scrollView) + try eventGenerator.waitUntilHittable(timeout: 1) + + DispatchQueue.main.asyncAfter(deadline: .now() + 1) { + scrollView.scrollRectToVisible(view.frame, animated: false) + } + + XCTAssertFalse(eventGenerator.viewIsVisible("my_button")) + try eventGenerator.wait(untilVisible: "my_button", timeout: 1) + XCTAssertTrue(eventGenerator.viewIsVisible("my_button")) + } + + func testWaitUntilHittableWithIdentifier() throws { + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.accessibilityIdentifier = "my_button" + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + view.isUserInteractionEnabled = false + DispatchQueue.main.asyncAfter(deadline: .now() + 0.02) { + view.isUserInteractionEnabled = true + } + + XCTAssertFalse(eventGenerator.viewIsHittable("my_button")) + try eventGenerator.wait(untilHittable: "my_button", timeout: 1) + XCTAssertTrue(eventGenerator.viewIsHittable("my_button")) + } + + func testViewForIdentifierWithTimeout() throws { + let view = UIButton(frame: CGRect(x: 0, y: 0, width: 50, height: 50)) + view.accessibilityIdentifier = "my_button" + view.setContentHuggingPriority(.required, for: .vertical) + view.setContentHuggingPriority(.required, for: .horizontal) + view.backgroundColor = .green + + let eventGenerator = try EventGenerator(view: view) + try eventGenerator.waitUntilHittable(timeout: 1) + + let button = try eventGenerator.viewWithIdentifier("my_button", ofType: UIButton.self, timeout: 0.1) + XCTAssertEqual(button, view) + } +} diff --git a/project.yml b/project.yml new file mode 100644 index 0000000..2fceeb7 --- /dev/null +++ b/project.yml @@ -0,0 +1,28 @@ +name: Hammer +options: + bundleIdPrefix: com.lyft +targets: + Hammer: + type: framework + platform: iOS + deploymentTarget: "11.0" + sources: Sources/Hammer + scheme: + testTargets: + - HammerTests + coverageTargets: + - Hammer + gatherCoverageData: true + HammerTests: + type: bundle.unit-test + platform: iOS + deploymentTarget: "11.0" + sources: Tests/HammerTests + dependencies: + - target: Hammer + - target: TestHost + TestHost: + type: application + platform: iOS + deploymentTarget: "11.0" + sources: TestHost