mirror of
https://repo.dactyloidae.xyz/Dactyloidae/UXP.git
synced 2026-08-30 19:28:40 +09:00
388 lines
15 KiB
Swift
388 lines
15 KiB
Swift
/* This Source Code Form is subject to the terms of the Mozilla Public
|
||
* License, v. 2.0. If a copy of the MPL was not distributed with this
|
||
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
|
||
|
||
/**
|
||
* ScreenGraph helps you get rid of the navigation boiler plate found in a lot of whole-application UI testing.
|
||
*
|
||
* You create a shared graph of UI 'screens' or 'scenes' for your app, and use it for every test.
|
||
*
|
||
* In your tests, you use a navigator which does the job of getting your tests from place to place in your application,
|
||
* leaving you to concentrate on testing, rather than maintaining brittle and duplicated navigation code.
|
||
*
|
||
* The shared graph may also have other uses, such as generating screen shots for the App Store or L10n translators.
|
||
*
|
||
* Under the hood, the ScreenGraph is using GameplayKit's path finding to do the heavy lifting.
|
||
*/
|
||
|
||
import Foundation
|
||
import GameplayKit
|
||
import XCTest
|
||
|
||
typealias Edge = (XCTestCase, String, UInt) -> Void
|
||
typealias SceneBuilder = (ScreenGraphNode) -> Void
|
||
typealias NodeVisitor = (String) -> Void
|
||
|
||
/**
|
||
* ScreenGraph
|
||
* This is the main interface to building a graph of screens/app states and how to navigate between them.
|
||
* The ScreenGraph will be used as a map to navigate the test agent around the app.
|
||
*/
|
||
open class ScreenGraph {
|
||
var initialSceneName: String?
|
||
|
||
var namedScenes: [String: ScreenGraphNode] = [:]
|
||
var nodedScenes: [GKGraphNode: ScreenGraphNode] = [:]
|
||
|
||
var isReady: Bool = false
|
||
|
||
let gkGraph: GKGraph
|
||
|
||
init() {
|
||
self.gkGraph = GKGraph()
|
||
}
|
||
}
|
||
|
||
extension ScreenGraph {
|
||
/**
|
||
* Method for creating a ScreenGraphNode in the graph. The node should be accompanied by a closure
|
||
* used to document the exits out of this node to other nodes.
|
||
*/
|
||
func createScene(_ name: String, file: String = #file, line: UInt = #line, builder: @escaping (ScreenGraphNode) -> Void) {
|
||
let scene = ScreenGraphNode(map: self, name: name, builder: builder)
|
||
scene.file = file
|
||
scene.line = line
|
||
namedScenes[name] = scene
|
||
nodedScenes[scene.gkNode] = scene
|
||
}
|
||
}
|
||
|
||
extension ScreenGraph {
|
||
/**
|
||
* Create a new navigator object. Navigator objects are the main way of getting around the app.
|
||
* Typically, you'll do this in `TestCase.setUp()`
|
||
*/
|
||
func navigator(_ xcTest: XCTestCase, startingAt: String? = nil, file: String = #file, line: UInt = #line) -> Navigator {
|
||
buildGkGraph()
|
||
var current: ScreenGraphNode?
|
||
if let name = startingAt ?? initialSceneName {
|
||
current = namedScenes[name]
|
||
}
|
||
|
||
if current == nil {
|
||
xcTest.recordFailure(withDescription: "The app's initial state couldn't be established.",
|
||
inFile: file, atLine: line, expected: false)
|
||
}
|
||
return Navigator(self, xcTest: xcTest, initialScene: current!)
|
||
}
|
||
|
||
fileprivate func buildGkGraph() {
|
||
if isReady {
|
||
return
|
||
}
|
||
|
||
isReady = true
|
||
|
||
// Construct all the GKGraphNodes, and add them to the GKGraph.
|
||
let scenes = namedScenes.values
|
||
gkGraph.add(scenes.map { $0.gkNode })
|
||
|
||
// Now, use the scene builders to collect edge actions and destinations.
|
||
scenes.forEach { scene in
|
||
scene.builder(scene)
|
||
}
|
||
|
||
scenes.forEach { scene in
|
||
let gkNodes = scene.edges.keys.flatMap { self.namedScenes[$0]?.gkNode } as [GKGraphNode]
|
||
scene.gkNode.addConnections(to: gkNodes, bidirectional: false)
|
||
}
|
||
}
|
||
}
|
||
|
||
typealias Gesture = () -> Void
|
||
|
||
/**
|
||
* The ScreenGraph is made up of nodes. It is not possible to init these directly, only by creating
|
||
* screen nodes from the ScreenGraph object.
|
||
*
|
||
* The ScreenGraphNode has all the methods needed to define edges from this node to another node, using the usual
|
||
* XCUIElement method of moving about.
|
||
*/
|
||
class ScreenGraphNode {
|
||
let name: String
|
||
fileprivate let builder: SceneBuilder
|
||
fileprivate let gkNode: GKGraphNode
|
||
fileprivate var edges: [String: Edge] = [:]
|
||
|
||
fileprivate weak var map: ScreenGraph?
|
||
|
||
// Iff this node has a backAction, this store temporarily stores
|
||
// the node we were at before we got to this one. This becomes the node we return to when the backAction is
|
||
// invoked.
|
||
fileprivate weak var returnNode: ScreenGraphNode?
|
||
|
||
fileprivate var hasBack: Bool {
|
||
return backAction != nil
|
||
}
|
||
|
||
/**
|
||
* This is an action that will cause us to go back from where we came from.
|
||
* This is most useful when the same screen is accessible from multiple places,
|
||
* and we have a back button to return to where we came from.
|
||
*/
|
||
var backAction: Gesture?
|
||
|
||
/**
|
||
* This flag indicates that once we've moved on from this node, we can't come back to
|
||
* it via `backAction`. This is especially useful for Menus, and dialogs.
|
||
*/
|
||
var dismissOnUse: Bool = false
|
||
|
||
var existsWhen: XCUIElement?
|
||
|
||
fileprivate var line: UInt!
|
||
|
||
fileprivate var file: String!
|
||
|
||
fileprivate init(map: ScreenGraph, name: String, builder: @escaping SceneBuilder) {
|
||
self.map = map
|
||
self.name = name
|
||
self.gkNode = GKGraphNode()
|
||
self.builder = builder
|
||
}
|
||
|
||
fileprivate func addEdge(_ dest: String, by edge: @escaping Edge) {
|
||
edges[dest] = edge
|
||
// by this time, we should've added all nodes in to the gkGraph.
|
||
|
||
assert(map?.namedScenes[dest] != nil, "Destination scene '\(dest)' has not been created anywhere")
|
||
}
|
||
}
|
||
|
||
private let existsPredicate = NSPredicate(format: "exists == true")
|
||
private let enabledPredicate = NSPredicate(format: "enabled == true")
|
||
private let hittablePredicate = NSPredicate(format: "hittable == true")
|
||
private let noopNodeVisitor: NodeVisitor = { _ in }
|
||
|
||
extension ScreenGraphNode {
|
||
fileprivate func waitForElement(_ element: XCUIElement, withTest xcTest: XCTestCase, notFoundHandler: () -> Void) {
|
||
// Wait for 5 seconds for the element to exist.
|
||
let expectation = XCTNSPredicateExpectation(predicate: existsPredicate, object: element)
|
||
let result = XCTWaiter().wait(for: [expectation], timeout: 5)
|
||
if result != .completed {
|
||
notFoundHandler()
|
||
}
|
||
}
|
||
}
|
||
|
||
// Public methods for defining edges out of this node.
|
||
extension ScreenGraphNode {
|
||
/**
|
||
* Declare that by performing the given action/gesture, then we can navigate from this node to the next.
|
||
*
|
||
* @param withElement – optional, but if provided will attempt to verify it is there before performing the action.
|
||
* @param to – the destination node.
|
||
*/
|
||
func gesture(withElement element: XCUIElement? = nil, to nodeName: String, file declFile: String = #file, line declLine: UInt = #line, g: @escaping () -> Void) {
|
||
addEdge(nodeName) { xcTest, file, line in
|
||
if let el = element {
|
||
self.waitForElement(el, withTest: xcTest) { _ in
|
||
xcTest.recordFailure(withDescription: "Cannot find \(el)", inFile: declFile, atLine: declLine, expected: false)
|
||
xcTest.recordFailure(withDescription: "Cannot get from \(self.name) to \(nodeName). See \(declFile)", inFile: file, atLine: line, expected: false)
|
||
}
|
||
}
|
||
g()
|
||
}
|
||
}
|
||
|
||
func noop(to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(to: nodeName, file: file, line: line) {
|
||
// NOOP.
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Declare that by tapping a given element, we should be able to navigate from this node to another.
|
||
*
|
||
* @param element - the element to tap
|
||
* @param to – the destination node.
|
||
*/
|
||
func tap(_ element: XCUIElement, to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(withElement: element, to: nodeName, file: file, line: line) {
|
||
element.tap()
|
||
}
|
||
}
|
||
|
||
func doubleTap(_ element: XCUIElement, to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(withElement: element, to: nodeName, file: file, line: line) {
|
||
element.doubleTap()
|
||
}
|
||
}
|
||
|
||
func typeText(_ text: String, into element: XCUIElement, to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(withElement: element, to: nodeName, file: file, line: line) {
|
||
element.typeText(text)
|
||
}
|
||
}
|
||
|
||
func swipeLeft(_ element: XCUIElement, to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(withElement: element, to: nodeName, file: file, line: line) {
|
||
element.swipeLeft()
|
||
}
|
||
}
|
||
|
||
func swipeRight(_ element: XCUIElement, to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(withElement: element, to: nodeName, file: file, line: line) {
|
||
element.swipeRight()
|
||
}
|
||
}
|
||
|
||
func swipeUp(_ element: XCUIElement, to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(withElement: element, to: nodeName, file: file, line: line) {
|
||
element.swipeUp()
|
||
}
|
||
}
|
||
|
||
func swipeDown(_ element: XCUIElement, to nodeName: String, file: String = #file, line: UInt = #line) {
|
||
self.gesture(withElement: element, to: nodeName, file: file, line: line) {
|
||
element.swipeDown()
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* The Navigator provides a set of methods to navigate around the app. You can `goto` nodes, `visit` multiple nodes,
|
||
* or visit all nodes, but mostly you just goto. If you take actions that move around the app outside of the
|
||
* navigator, you can re-sync app with navigator my telling it which node it is now at, using the `nowAt` method.
|
||
*/
|
||
class Navigator {
|
||
fileprivate let map: ScreenGraph
|
||
fileprivate var currentScene: ScreenGraphNode
|
||
fileprivate var returnToRecentScene: ScreenGraphNode
|
||
fileprivate let xcTest: XCTestCase
|
||
|
||
fileprivate init(_ map: ScreenGraph, xcTest: XCTestCase, initialScene: ScreenGraphNode) {
|
||
self.map = map
|
||
self.xcTest = xcTest
|
||
self.currentScene = initialScene
|
||
self.returnToRecentScene = initialScene
|
||
}
|
||
|
||
/**
|
||
* Move the application to the named node.
|
||
*/
|
||
func goto(_ nodeName: String, file: String = #file, line: UInt = #line) {
|
||
goto(nodeName, file: file, line: line, visitWith: noopNodeVisitor)
|
||
}
|
||
|
||
/**
|
||
* Move the application to the named node, wth an optional node visitor closure, which is called each time the
|
||
* node changes.
|
||
*/
|
||
func goto(_ nodeName: String, file: String = #file, line: UInt = #line, visitWith nodeVisitor: NodeVisitor) {
|
||
let gkSrc = currentScene.gkNode
|
||
guard let gkDest = map.namedScenes[nodeName]?.gkNode else {
|
||
xcTest.recordFailure(withDescription: "Cannot route to \(nodeName), because it doesn't exist", inFile: file, atLine: line, expected: false)
|
||
return
|
||
}
|
||
|
||
var gkPath = map.gkGraph.findPath(from: gkSrc, to: gkDest)
|
||
guard gkPath.count > 0 else {
|
||
xcTest.recordFailure(withDescription: "Cannot route from \(currentScene.name) to \(nodeName)", inFile: file, atLine: line, expected: false)
|
||
return
|
||
}
|
||
|
||
gkPath.removeFirst()
|
||
gkPath.forEach { gkNext in
|
||
if !currentScene.dismissOnUse {
|
||
returnToRecentScene = currentScene
|
||
}
|
||
|
||
let nextScene = map.nodedScenes[gkNext]!
|
||
let action = currentScene.edges[nextScene.name]!
|
||
|
||
// We definitely have an action, so it's save to unbox.
|
||
action(xcTest, file, line)
|
||
|
||
if let testElement = nextScene.existsWhen {
|
||
nextScene.waitForElement(testElement, withTest: xcTest) { _ in
|
||
// TODO report error in the correct place in the graph.
|
||
self.xcTest.recordFailure(withDescription: "Cannot find \(testElement) in \(nextScene.name)",
|
||
inFile: nextScene.file,
|
||
atLine: nextScene.line,
|
||
expected: false)
|
||
}
|
||
}
|
||
|
||
if nextScene.hasBack {
|
||
if nextScene.returnNode == nil {
|
||
nextScene.returnNode = returnToRecentScene
|
||
nextScene.gkNode.addConnections(to: [ returnToRecentScene.gkNode ], bidirectional: false)
|
||
nextScene.gesture(to: returnToRecentScene.name, g: nextScene.backAction!)
|
||
}
|
||
}
|
||
|
||
if currentScene.hasBack {
|
||
if nextScene.name == currentScene.returnNode?.name {
|
||
currentScene.returnNode = nil
|
||
currentScene.gkNode.removeConnections(to: [ nextScene.gkNode ], bidirectional: false)
|
||
}
|
||
}
|
||
nodeVisitor(currentScene.name)
|
||
currentScene = nextScene
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Helper method when the navigator gets out of sync with the actual app.
|
||
* This should not be used too often, as it indicates you should probably have another node in your graph,
|
||
* or you should be using `scene.dismissOnUse = true`.
|
||
* Also useful if you're using XCUIElement taps directly to navigate from one node to another.
|
||
*/
|
||
func nowAt(_ nodeName: String, file: String = #file, line: UInt = #line) {
|
||
guard let newScene = map.namedScenes[nodeName] else {
|
||
xcTest.recordFailure(withDescription: "Cannot force to unknown \(nodeName). Currently at \(currentScene.name)", inFile: file, atLine: line, expected: false)
|
||
return
|
||
}
|
||
currentScene = newScene
|
||
}
|
||
|
||
/**
|
||
* Visit the named nodes, calling the NodeVisitor the first time it is encountered.
|
||
*/
|
||
func visitNodes(_ nodes: [String], file: String = #file, line: UInt = #line, f: NodeVisitor) {
|
||
var visitedNodes = Set<String>()
|
||
let desiredNodes = Set<String>(nodes)
|
||
nodes.forEach { node in
|
||
if visitedNodes.contains(node) {
|
||
return
|
||
}
|
||
self.goto(node, file: file, line: line) { visitedNode in
|
||
if desiredNodes.contains(visitedNode) && !visitedNodes.contains(visitedNode) {
|
||
f(visitedNode)
|
||
}
|
||
visitedNodes.insert(visitedNode)
|
||
}
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Visit all nodes, calling the NodeVisitor the first time it is encountered.
|
||
*
|
||
* Some nodes may not be immediately available, depending on the state of the app.
|
||
*/
|
||
func visitAll(_ file: String = #file, line: UInt = #line, f: NodeVisitor) {
|
||
let nodes: [String] = self.map.namedScenes.keys.map { $0 } // keys can't be coerced into a [String]
|
||
self.visitNodes(nodes, file: file, line: line, f: f)
|
||
}
|
||
|
||
/**
|
||
* Move the app back to its initial state.
|
||
* This may not be possible.
|
||
*/
|
||
func revert(_ file: String = #file, line: UInt = #line) {
|
||
if let initial = self.map.initialSceneName {
|
||
self.goto(initial, file: file, line: line)
|
||
}
|
||
}
|
||
}
|