Skip to content

Commit

Permalink
Merge pull request #4394 from wix/feat/ios-web-views
Browse files Browse the repository at this point in the history
feat(ios): add web-view testing support.
  • Loading branch information
asafkorem authored Mar 12, 2024
2 parents 9579c96 + 1aaebce commit 715d100
Show file tree
Hide file tree
Showing 58 changed files with 2,518 additions and 679 deletions.
35 changes: 27 additions & 8 deletions detox/detox.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1137,7 +1137,21 @@ declare global {
}

interface WebViewElement {
/**
* Find a web element by a matcher.
* @param webMatcher a web matcher for the web element.
*/
element(webMatcher: WebMatcher): IndexableWebElement;

/**
* Returns the index-th web-view in the UI hierarchy that is matched by the given matcher.
* @param index the index of the web-view.
*
* @note Currently, supported only for iOS.
*
* @example await web(by.id('webview')).atIndex(1);
*/
atIndex(index: number): WebViewElement;
}

interface WebFacade extends WebViewElement {
Expand Down Expand Up @@ -1507,8 +1521,8 @@ declare global {

interface IndexableWebElement extends WebElement {
/**
* Choose from multiple elements matching the same matcher using index
* @example await web.element(by.web.hrefContains('Details')).atIndex(2).tap();
* Choose from multiple elements matching the same matcher using index.
* @example await web.element(by.web.tag('p')).atIndex(2).tap();
*/
atIndex(index: number): WebElement;
}
Expand All @@ -1520,24 +1534,27 @@ declare global {
tap(): Promise<void>;

/**
* Type text into a web element.
* @param text to type
* @param isContentEditable whether its a ContentEditable element, default is false.
* @param isContentEditable whether the element is content-editable, default is false. Ignored on iOS.
*/
typeText(text: string, isContentEditable: boolean): Promise<void>;

/**
* At the moment not working on content-editable
* Replaces the input content with the new text.
* @note On Android, not working for content-editable elements.
* @param text to replace with the old content.
*/
replaceText(text: string): Promise<void>;

/**
* At the moment not working on content-editable
* Clears the input content.
* @note On Android, not working for content-editable elements.
*/
clearText(): Promise<void>;

/**
* scrolling to the view, the element top position will be at the top of the screen.
* Scrolling to the view, the element top position will be at the top of the screen.
*/
scrollToView(): Promise<void>;

Expand All @@ -1552,12 +1569,14 @@ declare global {
focus(): Promise<void>;

/**
* Selects all the input content, works on ContentEditable at the moment.
* Selects all the input content.
* @note On Android, it works only for content-editable elements.
*/
selectAllText(): Promise<void>;

/**
* Moves the input cursor / caret to the end of the content, works on ContentEditable at the moment.
* Moves the input cursor to the end of the content.
* @note On Android, it works only for content-editable elements.
*/
moveCursorToEnd(): Promise<void>;

Expand Down
168 changes: 168 additions & 0 deletions detox/ios/Detox.xcodeproj/project.pbxproj

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion detox/ios/Detox/Invocation/Element.swift
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ class Element : NSObject {
return array
}

private var view : NSObject {
var view : NSObject {
let array = self.views

let element : NSObject
Expand Down
25 changes: 23 additions & 2 deletions detox/ios/Detox/Invocation/InvocationManager.swift
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ final class InvocationManager {
internal struct Types {
static let action = "action"
static let expectation = "expectation"

static let webAction = "webAction"
static let webExpectation = "webExpectation"
}

class func invoke(dictionaryRepresentation: [String: Any], completionHandler: @escaping ([String: Any]?, Error?) -> Void) {
Expand All @@ -33,14 +36,32 @@ final class InvocationManager {
switch kind {
case Types.action:
let action = try Action.with(dictionaryRepresentation: dictionaryRepresentation)
os_signpost(.begin, log: log.osLog, name: "Action Invocation", signpostID: signpostID, "%{public}s", action.description)
os_signpost(.begin, log: log.osLog, name: "Action Invocation",
signpostID: signpostID, "%{public}s", action.description)
action.perform(completionHandler: signpostCompletionHandler)

case Types.expectation:
let expectation = try Expectation.with(dictionaryRepresentation: dictionaryRepresentation)
os_signpost(.begin, log: log.osLog, name: "Expectation Invocation", signpostID: signpostID, "%{public}s", expectation.description)
os_signpost(.begin, log: log.osLog, name: "Expectation Invocation",
signpostID: signpostID, "%{public}s", expectation.description)
expectation.evaluate { error in
signpostCompletionHandler(nil, error)
}

case Types.webAction:
let action = try WebAction.init(json: dictionaryRepresentation)
os_signpost(.begin, log: log.osLog, name: "Web action Invocation",
signpostID: signpostID, "%{public}s", action.description)
action.perform(completionHandler: signpostCompletionHandler)

case Types.webExpectation:
let expectation = try WebExpectation.init(json: dictionaryRepresentation)
os_signpost(.begin, log: log.osLog, name: "Web expectation Invocation",
signpostID: signpostID, "%{public}s", expectation.description)
expectation.evaluate { error in
signpostCompletionHandler(nil, error)
}

default:
fatalError("Unknown invocation type “\(kind)")
}
Expand Down
40 changes: 40 additions & 0 deletions detox/ios/Detox/Invocation/WKWebView+evaluateJSAfterLoading.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
//
// WKWebView+evaluateJSAfterLoading.swift (Detox)
// Created by Asaf Korem (Wix.com) on 2024.
//

import WebKit

fileprivate let log = DetoxLog(category: "WebView")

/// Extends WKWebView with the ability to evaluate JavaScript after the web view has
/// finished loading.
extension WKWebView {
func evaluateJSAfterLoading(
_ javaScriptString: String,
completionHandler: ((Any?, Error?) -> Void)? = nil
) {
let cleanJavaScriptString = replaceConsecutiveSpacesAndTabs(in: javaScriptString)
log.debug("Evaluating JavaScript after loading: `\(cleanJavaScriptString)`")

var observation: NSKeyValueObservation?
observation = self.observe(
\.isLoading, options: [.new, .old, .initial]
) { (webView, change) in
guard change.newValue == false else { return }

observation?.invalidate()

log.debug("Evaluating JavaScript on web-view: `\(cleanJavaScriptString)`")
webView.evaluateJavaScript(cleanJavaScriptString, completionHandler: completionHandler)
}
}

private func replaceConsecutiveSpacesAndTabs(in input: String) -> String {
let pattern = "[ \\t\\r\\n]+"
let regex = try! NSRegularExpression(pattern: pattern, options: [])
let range = NSRange(location: 0, length: input.utf16.count)
let modifiedString = regex.stringByReplacingMatches(in: input, options: [], range: range, withTemplate: " ")
return modifiedString
}
}
65 changes: 65 additions & 0 deletions detox/ios/Detox/Invocation/WKWebView+findView.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
//
// WKWebView+findView.swift (Detox)
// Created by Asaf Korem (Wix.com) on 2024.
//

import WebKit

/// Extends WKWebView with the ability to find a web view element.
extension WKWebView {
/// Finds a web view element by the given `predicate` at the given `index`.
class func findView(
by predicate: Predicate?,
atIndex index: Int?
) throws -> WKWebView {
let webView: WKWebView?

if let predicate = predicate {
guard let ancestor = Element(predicate: predicate, index: index).view as? UIView else {
throw dtx_errorForFatalError(
"Failed to find web view with predicate: \(predicate.description)")
}

webView = try findWebViewDescendant(in: ancestor)
} else {
webView = try findWebViewDescendant()
}

guard let webView = webView else {
throw dtx_errorForFatalError(
"Failed to find web view with predicate: `\(predicate?.description ?? "")` " +
"at index: `\(index ?? 0)`")
}

return webView
}

fileprivate class func findWebViewDescendant(
in ancestor: UIView? = nil
) throws -> WKWebView? {
let predicate = NSPredicate.init { (view, _) -> Bool in
return view is WKWebView
}

var webViews: [WKWebView]
if let ancestor = ancestor {
webViews = UIView.dtx_findViews(inHierarchy: ancestor, passing: predicate).compactMap {
$0 as? WKWebView
}
} else {
webViews = UIView.dtx_findViewsInAllWindows(passing: predicate).compactMap {
$0 as? WKWebView
}
}

if webViews.count == 0 {
return nil
} else if webViews.count > 1 {
throw dtx_errorForFatalError(
"Found more than one matching web view in the hierarchy. " +
"Please specify a predicate to find the correct web view.")
} else {
return webViews.first
}
}
}
61 changes: 61 additions & 0 deletions detox/ios/Detox/Invocation/WebAction.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
//
// WebAction.swift (Detox)
// Created by Asaf Korem (Wix.com) on 2024.
//

import WebKit

/// Represents a web action to be performed on a web view.
class WebAction: WebInteraction {
var webAction: WebActionType
var params: [Any]?

override init(json: [String: Any]) throws {
self.webAction = WebActionType(rawValue: json["webAction"] as! String)!
self.params = json["params"] as? [Any]
try super.init(json: json)
}

override var description: String {
return "WebAction: \(webAction.rawValue)"
}

func perform(completionHandler: @escaping ([String: Any]?, Error?) -> Void) {
var jsString: String
var webView: WKWebView

do {
jsString = try WebCodeBuilder()
.with(predicate: webPredicate, atIndex: webAtIndex)
.with(action: webAction, params: params)
.build()

webView = try WKWebView.findView(by: predicate, atIndex: atIndex)
} catch {
completionHandler(nil, error)
return
}

webView.evaluateJSAfterLoading(jsString) { (result, error) in
if let error = error {
completionHandler(
["result": false, "error": error.localizedDescription],
dtx_errorForFatalError(
"Failed to evaluate JavaScript on web view: \(webView.debugDescription). " +
"Error: \(error.localizedDescription)")
)
} else if let jsError = (result as? [String: Any])?["error"] as? String {
completionHandler(
["result": false, "error": jsError],
dtx_errorForFatalError(
"Failed to evaluate JavaScript on web view: \(webView.debugDescription). " +
"JS exception: \(jsError)")
)
} else if let result = (result as? [String: Any])?["result"] as? String {
completionHandler(["result": result], nil)
} else {
completionHandler(nil, nil)
}
}
}
}
20 changes: 20 additions & 0 deletions detox/ios/Detox/Invocation/WebActionType.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
//
// WebActionType.swift (Detox)
// Created by Asaf Korem (Wix.com) on 2024.
//

enum WebActionType: String, Codable {
case tap = "tap"
case typeText = "typeText"
case replaceText = "replaceText"
case clearText = "clearText"
case selectAllText = "selectAllText"
case getText = "getText"
case scrollToView = "scrollToView"
case focus = "focus"
case moveCursorToEnd = "moveCursorToEnd"
case runScript = "runScript"
case runScriptWithArgs = "runScriptWithArgs"
case getCurrentUrl = "getCurrentUrl"
case getTitle = "getTitle"
}
Loading

0 comments on commit 715d100

Please sign in to comment.