Skip to content

Commit

Permalink
make it possible to tap on views by index on iOS
Browse files Browse the repository at this point in the history
  • Loading branch information
bartekpacia committed Apr 1, 2023
1 parent 19d7ff1 commit 9ab4598
Show file tree
Hide file tree
Showing 8 changed files with 202 additions and 60 deletions.
1 change: 0 additions & 1 deletion .github/workflows/patrol-prepare.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -124,7 +124,6 @@ jobs:
fail-fast: false
matrix:
include:
- version: '3.3.0'
- channel: stable

defaults:
Expand Down
10 changes: 8 additions & 2 deletions .github/workflows/test-ios-simulator.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -79,8 +79,14 @@ jobs:
- run: patrol test -t integration_test/service_wifi_test.dart
if: ${{ false }} # Not on Simulator

- run: patrol test -t integration_test/webview_all_test.dart
if: ${{ false }} # Temporarily broken
- run: patrol test -t webview_hackernews_test.dart
if: ${{ false }} # https://github.com/leancodepl/patrol/issues/1139
- run: patrol test -t webview_leancode_test.dart
if: ${{ false }} # https://github.com/leancodepl/patrol/issues/1139
- run: patrol test -t webview_login_test.dart
if: ${{ false }} # https://github.com/leancodepl/patrol/issues/1139
- run: patrol test -t webview_stackoverflow_test.dart
if: ${{ false }} # https://github.com/leancodepl/patrol/issues/1139

- name: Set job status
id: set_status
Expand Down
10 changes: 6 additions & 4 deletions packages/patrol/example/integration_test/common.dart
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@ export 'package:example/main.dart';
export 'package:flutter_test/flutter_test.dart';
export 'package:patrol/patrol.dart';

final _patrolTesterConfig = PatrolTesterConfig();
final _nativeAutomatorConfig = NativeAutomatorConfig();
final globalPatrolTesterConfig = PatrolTesterConfig();
final globalNativeAutomatorConfig = NativeAutomatorConfig();

Future<void> createApp(PatrolTester $) async {
await setUpTimezone();
Expand All @@ -17,12 +17,14 @@ Future<void> createApp(PatrolTester $) async {
void patrol(
String description,
Future<void> Function(PatrolTester) callback, {
PatrolTesterConfig? patrolTesterConfig,
NativeAutomatorConfig? nativeAutomatorConfig,
bool? skip,
}) {
patrolTest(
description,
config: _patrolTesterConfig,
nativeAutomatorConfig: _nativeAutomatorConfig,
config: patrolTesterConfig ?? globalPatrolTesterConfig,
nativeAutomatorConfig: nativeAutomatorConfig ?? globalNativeAutomatorConfig,
nativeAutomation: true,
skip: skip,
callback,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import '../common.dart';

final _nativeAutomatorConfig = globalNativeAutomatorConfig.copyWith(
findTimeout: Duration(seconds: 3), // shorter timeout for this test
);

void main() {
patrol(
'native tap() fails gracefully',
nativeAutomatorConfig: _nativeAutomatorConfig,
($) async {
await createApp($);

await expectLater(
() => $.native.tap(Selector(text: 'This does not exist, boom!')),
throwsA(
isA<PatrolActionException>().having(
(err) => err.message,
'message',
contains('This does not exist, boom!'),
),
),
);
},
);

patrol(
'native enterText() fails gracefully',
nativeAutomatorConfig: _nativeAutomatorConfig,
($) async {
await createApp($);

await expectLater(
() => $.native.enterText(
Selector(text: 'This does not exist, boom!'),
text: 'some text',
),
throwsA(
isA<PatrolActionException>().having(
(err) => err.message,
'message',
contains('This does not exist, boom!'),
),
),
);
},
);

patrol(
'native enterTextByIndex() fails gracefully',
nativeAutomatorConfig: _nativeAutomatorConfig,
($) async {
await createApp($);

await expectLater(
() => $.native.enterTextByIndex('some text', index: 100),
throwsA(isA<PatrolActionException>()),
);
},
);
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ void main() {
patrol('interacts with the orange website in a webview', ($) async {
await createApp($);

await $('Open webview (Hacker News').scrollTo().tap();
await $('Open webview (Hacker News)').scrollTo().tap();

await $.native.tap(Selector(text: 'login'));
await $.native.enterTextByIndex('[email protected]', index: 0);
await $.native.enterTextByIndex('[email protected]', index: 0);
await $.native.enterTextByIndex('ny4ncat', index: 1);
await $.native.tap(Selector(text: 'login'));
});
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,18 @@ import 'common.dart';
void main() async {
patrol('interacts with the LeanCode website in a webview', ($) async {
await createApp($);

await $('Open webview (LeanCode)').scrollTo().tap();

await $.native.tap(Selector(text: 'Accept cookies'));

// open dropdown
await $.native.tap(Selector(text: 'What do you do in IT?', instance: 1));

// select option in dropdown
await $.native.tap(Selector(text: 'Developer'));
await $.native.tap(Selector(text: '1 item selected'));

// close dropdown
await $.native.tap(Selector(text: 'What do you do in IT?', instance: 1));

await $.native.enterTextByIndex('[email protected]', index: 0);
});
}
138 changes: 94 additions & 44 deletions packages/patrol/ios/Classes/AutomatorServer/Automator.swift
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
import XCTest

let MICROSECONDS_IN_SECOND: UInt32 = 1_000_000

class Automator {
private lazy var device: XCUIDevice = {
return XCUIDevice.shared
Expand Down Expand Up @@ -54,57 +56,70 @@ class Automator {

// MARK: General UI interaction

func tap(on text: String, inApp bundleId: String) async throws {
try await runAction("tapping on view with text \(format: text) in app \(bundleId)") {
func tap(onText text: String, inApp bundleId: String, atIndex index: Int) async throws {
let view = "view with text \(format: text) in app \(bundleId) at index \(index)"

try await runAction("tapping on \(view)") {
let app = try self.getApp(withBundleId: bundleId)
let element = app.descendants(matching: .any)[text]
let elementQuery = app.descendants(matching: .any).matching(identifier: text)

Logger.shared.i("waiting for existence of view with text \(format: text)")
let exists = element.waitForExistence(timeout: self.timeout)
guard exists else {
throw PatrolError.viewNotExists("view with text \(format: text) in app \(format: bundleId)")
Logger.shared.i("waiting for existence of \(view)")
guard let element = self.waitForView(query: elementQuery, index: index) else {
throw PatrolError.viewNotExists(view)
}
Logger.shared.i("found view with text \(format: text), will tap on it")

element.firstMatch.forceTap()
Logger.shared.i("found \(view), will tap on it")
element.tap()
}
}

func doubleTap(on text: String, inApp bundleId: String) async throws {
try await runAction("double tapping on text \(format: text) in app \(bundleId)") {
func doubleTap(onText text: String, inApp bundleId: String, atIndex index: Int) async throws {
let view = "view with text \(format: text) in app \(bundleId) at index \(index)"

try await runAction("double tapping on \(view)") {
let app = try self.getApp(withBundleId: bundleId)
let element = app.descendants(matching: .any)[text]
let elementQuery = app.descendants(matching: .any).matching(identifier: text)

let exists = element.waitForExistence(timeout: self.timeout)
guard exists else {
throw PatrolError.viewNotExists("view with text \(format: text) in app \(format: bundleId)")
Logger.shared.i("waiting for existence of \(view)")
guard let element = self.waitForView(query: elementQuery, index: index) else {
throw PatrolError.viewNotExists(view)
}

element.firstMatch.forceTap()
Logger.shared.i("found \(view), will double tap on it")
element.doubleTap()
}
}

func enterText(_ data: String, by text: String, inApp bundleId: String) async throws {
try await runAction(
"entering text \(format: data) into text field with text \(text) in app \(bundleId)"
) {
func enterText(_ data: String, byText text: String, inApp bundleId: String, atIndex index: Int)
async throws
{
let view = "text field with ident/label \(format: text) in app \(bundleId) at index \(index)"

try await runAction("entering text \(format: data) into \(view)") {
let app = try self.getApp(withBundleId: bundleId)

guard
let element = self.waitForAnyElement(
elements: [app.textFields[text], app.secureTextFields[text]],
timeout: self.timeout
)
else {
throw PatrolError.viewNotExists(
"text field with text \(format: text) in app \(format: bundleId)")
// elementType must be specified as integer
// See:
// * https://developer.apple.com/documentation/xctest/xcuielementtype/xcuielementtypetextfield
// * https://developer.apple.com/documentation/xctest/xcuielementtype/xcuielementtypesecuretextfield
let textFieldPredicate = NSPredicate(format: "elementType == 49")
let secureTextFieldPredicate = NSPredicate(format: "elementType == 50")
let predicate = NSCompoundPredicate(
orPredicateWithSubpredicates: [textFieldPredicate, secureTextFieldPredicate]
)

let elementQuery = app.descendants(matching: .any).matching(predicate).matching(
identifier: text)
guard let element = self.waitForView(query: elementQuery, index: index) else {
throw PatrolError.viewNotExists(view)
}

element.firstMatch.typeText(data)
element.tap()
element.typeText(data)
}
}

func enterText(_ data: String, by index: Int, inApp bundleId: String) async throws {
func enterText(_ data: String, byIndex index: Int, inApp bundleId: String) async throws {
try await runAction("entering text \(format: data) by index \(index) in app \(bundleId)") {
let app = try self.getApp(withBundleId: bundleId)

Expand All @@ -118,18 +133,12 @@ class Automator {
orPredicateWithSubpredicates: [textFieldPredicate, secureTextFieldPredicate]
)

let textFieldsQuery = app.descendants(matching: .any).matching(predicate)
guard
let element = self.waitFor(
query: textFieldsQuery,
byIndex: index,
timeout: self.timeout
)
else {
let elementQuery = app.descendants(matching: .any).matching(predicate)
guard let element = self.waitForView(query: elementQuery, index: index) else {
throw PatrolError.viewNotExists("text field at index \(index) in app \(bundleId)")
}

element.forceTap()
element.tap()
element.typeText(data)
}
}
Expand Down Expand Up @@ -506,7 +515,8 @@ class Automator {
}
}

// MARK: Private stuff
// MARK: Private common methods

private func isSimulator() -> Bool {
#if targetEnvironment(simulator)
return true
Expand Down Expand Up @@ -570,9 +580,10 @@ class Automator {
start.press(forDuration: 0.1, thenDragTo: end)
}

private func runControlCenterAction(_ log: String, block: @escaping () throws -> Void)
async throws
{
private func runControlCenterAction(
_ log: String,
block: @escaping () throws -> Void
) async throws {
#if targetEnvironment(simulator)
throw PatrolError.internal("Control Center is not available on Simulator")
#endif
Expand Down Expand Up @@ -622,10 +633,49 @@ class Automator {
Logger.shared.i("\(log)...")
let result = try block()
Logger.shared.i("done \(log)")
Logger.shared.i("result: \(result)")
Logger.shared.i("result: \(result), type: \(type(of: result))")
return result
}
}

// MARK: Custom view utilities

/// Adapted from https://stackoverflow.com/q/47880395/7009800
@discardableResult
func waitForAnyElement(elements: [XCUIElement], timeout: TimeInterval? = nil) -> XCUIElement? {
var foundElement: XCUIElement?
let startTime = Date()

while Date().timeIntervalSince(startTime) < (timeout ?? self.timeout) {
if let elementFound = elements.first(where: { $0.exists }) {
foundElement = elementFound
break
}
usleep(MICROSECONDS_IN_SECOND * 1)
}

return foundElement
}

/// Adapted from https://stackoverflow.com/q/47880395/7009800
@discardableResult
func waitForView(query: XCUIElementQuery, index: Int, timeout: TimeInterval? = nil)
-> XCUIElement?
{
var foundElement: XCUIElement?
let startTime = Date()

while Date().timeIntervalSince(startTime) < (timeout ?? self.timeout) {
let elements = query.allElementsBoundByIndex
if index < elements.count && elements[index].exists {
foundElement = elements[index]
break
}
usleep(MICROSECONDS_IN_SECOND * 1)
}

return foundElement
}
}

// MARK: Utilities
Expand Down
Loading

0 comments on commit 9ab4598

Please sign in to comment.