Dactyloidae iOS initial commit

This commit is contained in:
wuggy 2026-06-26 21:04:09 -07:00
commit 7154a0497e
2123 changed files with 197052 additions and 0 deletions

View file

@ -0,0 +1,388 @@
/* 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)
}
}
}