You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

303 lines
11 KiB

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