SnapshotHelper.swift 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292
  1. // _____ _
  2. // |_ _| |_ _ _ ___ ___ _ __ __ _
  3. // | | | ' \| '_/ -_) -_) ' \/ _` |_
  4. // |_| |_||_|_| \___\___|_|_|_\__,_(_)
  5. //
  6. // Threema iOS Client
  7. // Copyright (c) 2017-2020 Threema GmbH
  8. //
  9. // This program is free software: you can redistribute it and/or modify
  10. // it under the terms of the GNU Affero General Public License, version 3,
  11. // as published by the Free Software Foundation.
  12. //
  13. // This program is distributed in the hope that it will be useful,
  14. // but WITHOUT ANY WARRANTY; without even the implied warranty of
  15. // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  16. // GNU Affero General Public License for more details.
  17. //
  18. // You should have received a copy of the GNU Affero General Public License
  19. // along with this program. If not, see <https://www.gnu.org/licenses/>.
  20. import Foundation
  21. import XCTest
  22. var deviceLanguage = ""
  23. var locale = ""
  24. func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
  25. Snapshot.setupSnapshot(app, waitForAnimations: waitForAnimations)
  26. }
  27. func snapshot(_ name: String, waitForLoadingIndicator: Bool) {
  28. if waitForLoadingIndicator {
  29. Snapshot.snapshot(name)
  30. } else {
  31. Snapshot.snapshot(name, timeWaitingForIdle: 0)
  32. }
  33. }
  34. /// - Parameters:
  35. /// - name: The name of the snapshot
  36. /// - timeout: Amount of seconds to wait until the network loading indicator disappears. Pass `0` if you don't want to wait.
  37. func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
  38. Snapshot.snapshot(name, timeWaitingForIdle: timeout)
  39. }
  40. enum SnapshotError: Error, CustomDebugStringConvertible {
  41. case cannotFindSimulatorHomeDirectory
  42. case cannotRunOnPhysicalDevice
  43. var debugDescription: String {
  44. switch self {
  45. case .cannotFindSimulatorHomeDirectory:
  46. return "Couldn't find simulator home location. Please, check SIMULATOR_HOST_HOME env variable."
  47. case .cannotRunOnPhysicalDevice:
  48. return "Can't use Snapshot on a physical device."
  49. }
  50. }
  51. }
  52. @objcMembers
  53. open class Snapshot: NSObject {
  54. static var app: XCUIApplication?
  55. static var waitForAnimations = true
  56. static var cacheDirectory: URL?
  57. static var screenshotsDirectory: URL? {
  58. return cacheDirectory?.appendingPathComponent("screenshots", isDirectory: true)
  59. }
  60. open class func setupSnapshot(_ app: XCUIApplication, waitForAnimations: Bool = true) {
  61. Snapshot.app = app
  62. Snapshot.waitForAnimations = waitForAnimations
  63. do {
  64. let cacheDir = try getCacheDirectory()
  65. Snapshot.cacheDirectory = cacheDir
  66. setLanguage(app)
  67. setLocale(app)
  68. setLaunchArguments(app)
  69. } catch let error {
  70. NSLog(error.localizedDescription)
  71. }
  72. }
  73. class func setLanguage(_ app: XCUIApplication) {
  74. guard let cacheDirectory = self.cacheDirectory else {
  75. NSLog("CacheDirectory is not set - probably running on a physical device?")
  76. return
  77. }
  78. let path = cacheDirectory.appendingPathComponent("language.txt")
  79. do {
  80. let trimCharacterSet = CharacterSet.whitespacesAndNewlines
  81. deviceLanguage = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
  82. app.launchArguments += ["-AppleLanguages", "(\(deviceLanguage))"]
  83. } catch {
  84. NSLog("Couldn't detect/set language...")
  85. }
  86. }
  87. class func setLocale(_ app: XCUIApplication) {
  88. guard let cacheDirectory = self.cacheDirectory else {
  89. NSLog("CacheDirectory is not set - probably running on a physical device?")
  90. return
  91. }
  92. let path = cacheDirectory.appendingPathComponent("locale.txt")
  93. do {
  94. let trimCharacterSet = CharacterSet.whitespacesAndNewlines
  95. locale = try String(contentsOf: path, encoding: .utf8).trimmingCharacters(in: trimCharacterSet)
  96. } catch {
  97. NSLog("Couldn't detect/set locale...")
  98. }
  99. if locale.isEmpty && !deviceLanguage.isEmpty {
  100. locale = Locale(identifier: deviceLanguage).identifier
  101. }
  102. if !locale.isEmpty {
  103. app.launchArguments += ["-AppleLocale", "\"\(locale)\""]
  104. }
  105. }
  106. class func setLaunchArguments(_ app: XCUIApplication) {
  107. guard let cacheDirectory = self.cacheDirectory else {
  108. NSLog("CacheDirectory is not set - probably running on a physical device?")
  109. return
  110. }
  111. let path = cacheDirectory.appendingPathComponent("snapshot-launch_arguments.txt")
  112. app.launchArguments += ["-FASTLANE_SNAPSHOT", "YES", "-ui_testing"]
  113. do {
  114. let launchArguments = try String(contentsOf: path, encoding: String.Encoding.utf8)
  115. let regex = try NSRegularExpression(pattern: "(\\\".+?\\\"|\\S+)", options: [])
  116. let matches = regex.matches(in: launchArguments, options: [], range: NSRange(location: 0, length: launchArguments.count))
  117. let results = matches.map { result -> String in
  118. (launchArguments as NSString).substring(with: result.range)
  119. }
  120. app.launchArguments += results
  121. } catch {
  122. NSLog("Couldn't detect/set launch_arguments...")
  123. }
  124. }
  125. open class func snapshot(_ name: String, timeWaitingForIdle timeout: TimeInterval = 20) {
  126. if timeout > 0 {
  127. waitForLoadingIndicatorToDisappear(within: timeout)
  128. }
  129. NSLog("snapshot: \(name)") // more information about this, check out https://docs.fastlane.tools/actions/snapshot/#how-does-it-work
  130. if Snapshot.waitForAnimations {
  131. sleep(1) // Waiting for the animation to be finished (kind of)
  132. }
  133. #if os(OSX)
  134. guard let app = self.app else {
  135. NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
  136. return
  137. }
  138. app.typeKey(XCUIKeyboardKeySecondaryFn, modifierFlags: [])
  139. #else
  140. guard self.app != nil else {
  141. NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
  142. return
  143. }
  144. let screenshot = XCUIScreen.main.screenshot()
  145. guard var simulator = ProcessInfo().environment["SIMULATOR_DEVICE_NAME"], let screenshotsDir = screenshotsDirectory else { return }
  146. do {
  147. // The simulator name contains "Clone X of " inside the screenshot file when running parallelized UI Tests on concurrent devices
  148. let regex = try NSRegularExpression(pattern: "Clone [0-9]+ of ")
  149. let range = NSRange(location: 0, length: simulator.count)
  150. simulator = regex.stringByReplacingMatches(in: simulator, range: range, withTemplate: "")
  151. let path = screenshotsDir.appendingPathComponent("\(name).png")
  152. try screenshot.pngRepresentation.write(to: path)
  153. } catch let error {
  154. NSLog("Problem writing screenshot: \(name) to \(screenshotsDir)/\(simulator)-\(name).png")
  155. NSLog(error.localizedDescription)
  156. }
  157. #endif
  158. }
  159. class func waitForLoadingIndicatorToDisappear(within timeout: TimeInterval) {
  160. #if os(tvOS)
  161. return
  162. #endif
  163. guard let app = self.app else {
  164. NSLog("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
  165. return
  166. }
  167. let networkLoadingIndicator = app.otherElements.deviceStatusBars.networkLoadingIndicators.element
  168. let networkLoadingIndicatorDisappeared = XCTNSPredicateExpectation(predicate: NSPredicate(format: "exists == false"), object: networkLoadingIndicator)
  169. _ = XCTWaiter.wait(for: [networkLoadingIndicatorDisappeared], timeout: timeout)
  170. }
  171. class func getCacheDirectory() throws -> URL {
  172. let cachePath = "Library/Caches/tools.fastlane"
  173. // on OSX config is stored in /Users/<username>/Library
  174. // and on iOS/tvOS/WatchOS it's in simulator's home dir
  175. #if os(OSX)
  176. let homeDir = URL(fileURLWithPath: NSHomeDirectory())
  177. return homeDir.appendingPathComponent(cachePath)
  178. #elseif arch(i386) || arch(x86_64)
  179. guard let simulatorHostHome = ProcessInfo().environment["SIMULATOR_HOST_HOME"] else {
  180. throw SnapshotError.cannotFindSimulatorHomeDirectory
  181. }
  182. let homeDir = URL(fileURLWithPath: simulatorHostHome)
  183. return homeDir.appendingPathComponent(cachePath)
  184. #else
  185. throw SnapshotError.cannotRunOnPhysicalDevice
  186. #endif
  187. }
  188. open class func getLanguage() -> String {
  189. return deviceLanguage
  190. }
  191. }
  192. private extension XCUIElementAttributes {
  193. var isNetworkLoadingIndicator: Bool {
  194. if hasAllowListedIdentifier { return false }
  195. let hasOldLoadingIndicatorSize = frame.size == CGSize(width: 10, height: 20)
  196. let hasNewLoadingIndicatorSize = frame.size.width.isBetween(46, and: 47) && frame.size.height.isBetween(2, and: 3)
  197. return hasOldLoadingIndicatorSize || hasNewLoadingIndicatorSize
  198. }
  199. var hasAllowListedIdentifier: Bool {
  200. let allowListedIdentifiers = ["GeofenceLocationTrackingOn", "StandardLocationTrackingOn"]
  201. return allowListedIdentifiers.contains(identifier)
  202. }
  203. func isStatusBar(_ deviceWidth: CGFloat) -> Bool {
  204. if elementType == .statusBar { return true }
  205. guard frame.origin == .zero else { return false }
  206. let oldStatusBarSize = CGSize(width: deviceWidth, height: 20)
  207. let newStatusBarSize = CGSize(width: deviceWidth, height: 44)
  208. return [oldStatusBarSize, newStatusBarSize].contains(frame.size)
  209. }
  210. }
  211. private extension XCUIElementQuery {
  212. var networkLoadingIndicators: XCUIElementQuery {
  213. let isNetworkLoadingIndicator = NSPredicate { (evaluatedObject, _) in
  214. guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
  215. return element.isNetworkLoadingIndicator
  216. }
  217. return self.containing(isNetworkLoadingIndicator)
  218. }
  219. var deviceStatusBars: XCUIElementQuery {
  220. guard let app = Snapshot.app else {
  221. fatalError("XCUIApplication is not set. Please call setupSnapshot(app) before snapshot().")
  222. }
  223. let deviceWidth = app.windows.firstMatch.frame.width
  224. let isStatusBar = NSPredicate { (evaluatedObject, _) in
  225. guard let element = evaluatedObject as? XCUIElementAttributes else { return false }
  226. return element.isStatusBar(deviceWidth)
  227. }
  228. return self.containing(isStatusBar)
  229. }
  230. }
  231. private extension CGFloat {
  232. func isBetween(_ numberA: CGFloat, and numberB: CGFloat) -> Bool {
  233. return numberA...numberB ~= self
  234. }
  235. }
  236. // Please don't remove the lines below
  237. // They are used to detect outdated configuration files
  238. // SnapshotHelperVersion [1.23]