Repository: wix/Detox
Pull request #2538 (2020-12-16 11:46:29)

Detox Web - Espresso-Web API [Android]
# Detox Web - Espresso-Web API [Android]

The following is a basic implementation to enable WebView testing with Detox using [EspressoWeb](https://developer.android.com/training/testing/espresso/web).

## Main Classes

### `WebViewElement` - class

Representation of a webview element on the testing environment.<br>

#### Methods:

#### - `element(webMatcher)`

finds an element within the webview according to `webMatcher` at `index` and
returns `WebElement` class.  

<br>

### `WebElement` - class

Representation of a DOM element on the testing environment.  

#### Methods:

#### - `atIndex(index)` - select element at index (useful when multiple elements are matched); returns this.
#### - `tap()` - tapping on an element.
#### - `typeText(text, isContentEditable = false)` - typeText on input and contenteditable element.
#### - `replaceText(text)` - clear the current text value, and replace it with the new `text` value (works on input tag at the moment).
#### - `clearText()` - clears the current text value (works on input tag at the moment).
#### - `scrollToView()` - scroll to the view, element.top will be at screen [x, 0] offset.
#### - `getText()` - gets the current text value
#### - `focus()` - set foucus on the element
#### - `selectAllText()` - select all the current text value (works on contenteditable at the moment).
#### - `moveCursorToEnd()` - moves the cursor to the end position (works on contenteditable at the moment).
#### - `runScript(script)` - running a script on the element, the script should be a function which accept the element as the only arg. The element value will be injected to the function on runtime. [see examples](detox/src/android/espressoapi/web/simpleAtoms.js).
#### - `runScriptWithArgs(script, args)` - runnsing a standalone script which can accept few args. The element ref will NOT be injected to the function args. 
#### - `getCurrentUrl()` - gets the current page URL which the element lives in.
#### - `getTitle()`- gets the current page Title which the element lives in.  

<br>

### `WebExpect` - class

Representation of an assertion on webElement.  

#### Methods:
#### - `toHaveText(text)` - expect the element text value to contain the `text` value.
#### - `not.toHaveText(text)` or `.toNotHaveText(text)` - expect the element text value to NOT contain the `text` value.
#### - `toExist()` - expect the element to exist in the current document DOM tree.
#### - `not.toExist()` or `.toNotExist()` - expect the element to NOT exist in the current document DOM tree.

<br>
<br>

## `web` - global object:

#### The new web API exposes a global object called `web`, this object represent the bridge into testing WebView as part of Detox.

#### 1. `web(matcher)`

Allow to attach to a specified WebView using the regular [View Matchers](https://github.com/wix/Detox/blob/oren/espresso-web-android/docs/APIRef.Matchers.md).
Returns a `WebViewElement` class as described above.

```js
web(by.id('webView2')).element(by.html.id('form'));
```

#### 2. `web.element(webMatcher)`

Given a single web view, allows to locate a web element inside it using Web Matchers.
Returns a `WebElement` class as described above.

#### 3. `by.html` - Web Matchers

- `by.html.id(id)` - Match elements with the specified id attribute.
- `by.html.className(className)` - Match elements with the specified className attribute.
- `by.html.selector(cssSelector)` - Match elements with the specified cssSelector attribute.
- `by.html.name(name)` - Match elements with the specified name attribute.
- `by.html.xpath(id)` - Match elements with the specified xpath.
- `by.html.href(id)` - Match `<a href />` elements with the exact link text.
- `by.html.hrefContains(partialLinkText)` - Match `<a href />` elements with partial of the link text.
- `by.html.tag(tag)` - Match elements with the specified tag (`<a>` / `<input>` etc.).



Patch:
diff --git a/detox/android/detox/.gitignore b/detox/android/detox/.gitignore
index 796b96d1c..39fb081a4 100644
--- a/detox/android/detox/.gitignore
+++ b/detox/android/detox/.gitignore
@@ -1,1 +1,9 @@
+*.iml
+.gradle
+/local.properties
+/.idea/workspace.xml
+/.idea/libraries
+.DS_Store
 /build
+/captures
+.externalNativeBuild
diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle
index 41afb0a45..46841c8f4 100644
--- a/detox/android/detox/build.gradle
+++ b/detox/android/detox/build.gradle
@@ -87,6 +87,9 @@ dependencies {
     // Version is the latest; Cannot sync with the Github repo (e.g. android/android-test) because the androidx
     // packaging version of associated classes is simply not there...
     api 'androidx.test.uiautomator:uiautomator:2.2.0' // Needed by Detox but also makes UIAutomator seamlessly provided to Detox users with hybrid apps/E2E-tests.
+
+    // WebView Testing
+    api 'androidx.test.espresso:espresso-web:3.2.0'
 }
 
 // Third-party deps.
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/web/DetoxWebAtomMatcher.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/DetoxWebAtomMatcher.java
new file mode 100644
index 000000000..8ccd21138
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/DetoxWebAtomMatcher.java
@@ -0,0 +1,49 @@
+package com.wix.detox.espresso.web;
+
+
+import androidx.test.espresso.web.model.Atom;
+import androidx.test.espresso.web.model.ElementReference;
+import androidx.test.espresso.web.webdriver.Locator;
+
+import java.util.List;
+
+import static androidx.test.espresso.web.webdriver.DriverAtoms.findMultipleElements;
+
+public class DetoxWebAtomMatcher {
+
+    private DetoxWebAtomMatcher() {
+        // static class
+    }
+
+    public static Atom<List<ElementReference>> matcherForId(String id) {
+        return findMultipleElements(Locator.ID, id);
+    }
+
+    public static Atom<List<ElementReference>> matcherForClassName(String className) {
+        return findMultipleElements(Locator.CLASS_NAME, className);
+    }
+
+    public static Atom<List<ElementReference>> matcherForCssSelector(String cssSelector) {
+        return findMultipleElements(Locator.CSS_SELECTOR, cssSelector);
+    }
+
+    public static Atom<List<ElementReference>> matcherForName(String name) {
+        return findMultipleElements(Locator.NAME, name);
+    }
+
+    public static Atom<List<ElementReference>> matcherForXPath(String xpath) {
+        return findMultipleElements(Locator.XPATH, xpath);
+    }
+
+    public static Atom<List<ElementReference>> matcherForLinkText(String linkText) {
+        return findMultipleElements(Locator.LINK_TEXT, linkText);
+    }
+
+    public static Atom<List<ElementReference>> matcherForPartialLinkText(String partialLinkText) {
+        return findMultipleElements(Locator.PARTIAL_LINK_TEXT, partialLinkText);
+    }
+
+    public static Atom<List<ElementReference>> matcherForTagName(String tag) {
+        return findMultipleElements(Locator.TAG_NAME, tag);
+    }
+}
\ No newline at end of file
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/web/EspressoWebDetox.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/EspressoWebDetox.java
new file mode 100644
index 000000000..b0f47e3dc
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/EspressoWebDetox.java
@@ -0,0 +1,42 @@
+package com.wix.detox.espresso.web;
+
+import android.view.View;
+
+import org.hamcrest.Matcher;
+
+import javax.annotation.Nullable;
+
+public class EspressoWebDetox {
+
+    private static final String TAG = "EspressoWebDetox";
+
+    private EspressoWebDetox() {
+        // static class
+    }
+
+    /**
+     * Gets the one and only visible webview on the screen.
+     * If there are multiple webview, you should use {@code getWebView(@Nullable Matcher<View> matcher)}.
+     * @return {@link WebViewElement}
+     */
+    public static WebViewElement getWebView() {
+        return getWebView(null);
+    }
+
+    /**
+     * Get the webview matches the provided {@param matcher}
+     * @param matcher a webview mathcer representation
+     * @return {@link WebViewElement}
+     */
+    public static WebViewElement getWebView(@Nullable Matcher<View> matcher) {
+        return new WebViewElement(matcher);
+    }
+
+    /**
+     * @param webElement
+     * @return {@link WebExpect}
+     */
+    public static WebExpect expect(WebElement webElement) {
+        return new WebExpect(webElement);
+    }
+}
\ No newline at end of file
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebElement.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebElement.java
new file mode 100644
index 000000000..bb7610e5a
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebElement.java
@@ -0,0 +1,80 @@
+package com.wix.detox.espresso.web;
+
+import androidx.test.espresso.web.model.Atom;
+import androidx.test.espresso.web.model.Atoms;
+import androidx.test.espresso.web.model.ElementReference;
+import androidx.test.espresso.web.model.Evaluation;
+import androidx.test.espresso.web.model.SimpleAtom;
+import androidx.test.espresso.web.sugar.Web;
+import androidx.test.espresso.web.webdriver.DriverAtoms;
+
+import java.util.ArrayList;
+import java.util.List;
+
+public class WebElement {
+
+    final WebViewElement webViewElement;
+    final Atom<List<ElementReference>> matcherAtom;
+    final int index;
+
+    WebElement(WebViewElement webViewElement, Atom<List<ElementReference>> matcherAtom, int index) {
+        this.webViewElement = webViewElement;
+        this.matcherAtom = matcherAtom;
+        this.index = index;
+    }
+
+    Web.WebInteraction<Void> getWebViewInteraction() {
+        return webViewElement.webViewInteraction;
+    }
+
+    ElementReference get() {
+        List<ElementReference> elements = getWebViewInteraction().perform(matcherAtom).get();
+
+        if (elements == null || elements.size() == 0 || index >= elements.size()) {
+            throw new RuntimeException(String.format("element was not found at index: %d", index));
+        }
+
+        return elements.get(index);
+    }
+
+    public void tap() {
+        getWebViewInteraction().withElement(get()).perform(DriverAtoms.webClick());
+    }
+
+    public void typeText(String text) {
+        getWebViewInteraction().withElement(get()).perform(DriverAtoms.webKeys(text));
+    }
+
+    public void replaceText(String text) {
+        clearText();
+        getWebViewInteraction().withElement(get()).perform(DriverAtoms.webKeys(text));
+    }
+
+    public void clearText() {
+        getWebViewInteraction().withElement(get()).perform(DriverAtoms.clearElement());
+    }
+
+    public boolean scrollToView() {
+        return getWebViewInteraction().withElement(get()).perform(DriverAtoms.webScrollIntoView()).get();
+    }
+
+    public String getText() {
+        return getWebViewInteraction().withElement(get()).perform(DriverAtoms.getText()).get();
+    }
+
+    public Evaluation runScript(String script) {
+        return getWebViewInteraction().withElement(get()).perform(new SimpleAtom(script)).get();
+    }
+
+    public Evaluation runScriptWithArgs(String script, final ArrayList<Object> args) {
+        return getWebViewInteraction().withElement(get()).perform(Atoms.scriptWithArgs(script, args)).get();
+    }
+
+    public String getCurrentUrl() {
+        return getWebViewInteraction().withElement(get()).perform(Atoms.getCurrentUrl()).get();
+    }
+
+    public String getTitle() {
+        return getWebViewInteraction().withElement(get()).perform(Atoms.getTitle()).get();
+    }
+}
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebExpect.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebExpect.java
new file mode 100644
index 000000000..f38113ced
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebExpect.java
@@ -0,0 +1,40 @@
+package com.wix.detox.espresso.web;
+
+import androidx.test.espresso.web.model.ElementReference;
+import androidx.test.espresso.web.sugar.Web;
+
+import java.util.Collections;
+
+import static androidx.test.espresso.web.assertion.WebViewAssertions.webMatches;
+import static androidx.test.espresso.web.webdriver.DriverAtoms.getText;
+import static org.hamcrest.CoreMatchers.containsString;
+import static org.hamcrest.CoreMatchers.equalTo;
+import static org.hamcrest.CoreMatchers.not;
+
+public class WebExpect {
+    final WebElement webElement;
+
+    WebExpect(WebElement webElement) {
+        this.webElement = webElement;
+    }
+
+    private Web.WebInteraction<Void> webViewInteraction() {
+        return webElement.getWebViewInteraction();
+    }
+
+    public void toNotExist() {
+        webViewInteraction().check(webMatches(webElement.matcherAtom, equalTo(Collections.<ElementReference>emptyList())));
+    }
+
+    public void toExist() {
+        webViewInteraction().check(webMatches(webElement.matcherAtom, not(equalTo(Collections.<ElementReference>emptyList()))));
+    }
+
+    public void toHaveText(String text) {
+        webViewInteraction().withElement(webElement.get()).check(webMatches(getText(), containsString(text)));
+    }
+
+    public void toNotHaveText(String text) {
+        webViewInteraction().withElement(webElement.get()).check(webMatches(getText(), not(containsString(text))));
+    }
+}
\ No newline at end of file
diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebViewElement.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebViewElement.java
new file mode 100644
index 000000000..778037a30
--- /dev/null
+++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebViewElement.java
@@ -0,0 +1,38 @@
+package com.wix.detox.espresso.web;
+
+import android.view.View;
+import android.webkit.WebView;
+
+import androidx.test.espresso.web.model.Atom;
+import androidx.test.espresso.web.model.ElementReference;
+import androidx.test.espresso.web.sugar.Web;
+
+import org.hamcrest.CoreMatchers;
+import org.hamcrest.Matcher;
+
+import java.util.List;
+
+import javax.annotation.Nullable;
+
+import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed;
+import static androidx.test.espresso.web.sugar.Web.onWebView;
+import static org.hamcrest.CoreMatchers.allOf;
+
+public class WebViewElement {
+
+    final Matcher<View> matcher;
+    final Web.WebInteraction<Void> webViewInteraction;
+
+    WebViewElement(@Nullable Matcher<View> matcher) {
+        this.matcher = matcher != null ? matcher : allOf(CoreMatchers.<View>instanceOf(WebView.class), isDisplayed());
+        this.webViewInteraction = matcher != null ? onWebView(matcher) : onWebView();
+    }
+
+    public WebElement element(Atom<List<ElementReference>> webMatcher) {
+        return element(webMatcher, 0);
+    }
+
+    public WebElement element(Atom<List<ElementReference>> webMatcher, int index) {
+        return new WebElement(this, webMatcher, index);
+    }
+}
diff --git a/detox/android/detox/src/main/resources/targetInvocationEspressoWebDetox.json b/detox/android/detox/src/main/resources/targetInvocationEspressoWebDetox.json
new file mode 100644
index 000000000..e628c2ab5
--- /dev/null
+++ b/detox/android/detox/src/main/resources/targetInvocationEspressoWebDetox.json
@@ -0,0 +1,44 @@
+{
+  "target":{
+    "type":"Invocation",
+    "value":{
+      "target":{
+        "type":"Invocation",
+        "value":{
+          "target":{
+            "type":"Class",
+            "value":"com.wix.detox.espresso.web.EspressoWebDetox"
+          },
+          "method":"getWebView",
+          "args":[
+
+          ]
+        }
+      },
+      "method":"element",
+      "args":[
+        {
+          "type":"Invocation",
+          "value":{
+            "target":{
+              "type":"Class",
+              "value":"com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+            },
+            "method":"matcherForId",
+            "args":[
+              "textInput"
+            ]
+          }
+        },
+        {
+          "type":"Integer",
+          "value":0
+        }
+      ]
+    }
+  },
+  "method":"tap",
+  "args":[
+
+  ]
+}
diff --git a/detox/index.d.ts b/detox/index.d.ts
index 3014b503d..9d3c51cef 100644
--- a/detox/index.d.ts
+++ b/detox/index.d.ts
@@ -1,5 +1,4 @@
 // TypeScript definitions for Detox
-//
 // Original authors:
 // * Jane Smith <jsmith@example.com>
 // * Tareq El-Masri <https://github.com/TareqElMasri>
@@ -8,6 +7,9 @@
 // * pera <https://github.com/santiagofm>
 // * Max Komarychev <https://github.com/maxkomarychev>
 // * Dor Ben Baruch <https://github.com/Dor256>
+// Current contributors:
+// * Yaroslav Serhieiev <https://github.com/noomorph>
+// * Oren Zakay <https://github.com/OrenZak>
 
 declare global {
     const device: Detox.DetoxExportWrapper['device'];
@@ -15,6 +17,7 @@ declare global {
     const waitFor: Detox.DetoxExportWrapper['waitFor'];
     const expect: Detox.DetoxExportWrapper['expect'];
     const by: Detox.DetoxExportWrapper['by'];
+    const web: Detox.DetoxExportWrapper['web'];
     const detoxCircus: Detox.DetoxCircus;
 
     namespace NodeJS {
@@ -24,6 +27,7 @@ declare global {
             waitFor: Detox.DetoxExportWrapper['waitFor'];
             expect: Detox.DetoxExportWrapper['expect'];
             by: Detox.DetoxExportWrapper['by'];
+            web: Detox.DetoxExportWrapper['web'];
             detoxCircus: Detox.DetoxCircus;
         }
     }
@@ -272,10 +276,11 @@ declare global {
         // Detox exports all methods from detox global and all of the global constants.
         interface DetoxInstance {
             device: Device;
-            element: Element;
-            waitFor: WaitFor;
-            expect: Expect<Expect<Promise<void>>>;
-            by: Matchers;
+            element: ElementFacade;
+            waitFor: WaitForFacade;
+            expect: ExpectFacade;
+            by: ByFacade;
+            web: WebFacade;
         }
 
         interface DetoxExportWrapper extends DetoxInstance {
@@ -630,21 +635,27 @@ declare global {
             getUiDevice(): Promise<void>;
         }
 
-        type DetoxAny = Element & Actions<any> & WaitFor;
+        /**
+         * @deprecated
+         */
+        type DetoxAny = NativeElement & WaitFor;
 
-        interface Element {
-            (by: Matchers): DetoxAny;
+        interface ElementFacade {
+            (by: NativeMatcher): IndexableNativeElement;
+        }
 
+        interface IndexableNativeElement extends NativeElement {
             /**
              * Choose from multiple elements matching the same matcher using index
-             * @example await element(by.text('Product')).atIndex(2);
+             * @example await element(by.text('Product')).atIndex(2).tap();
              */
-            atIndex(index: number): DetoxAny;
+            atIndex(index: number): NativeElement;
         }
 
-        interface Matchers {
-            (by: Matchers): Matchers;
+        interface NativeElement extends NativeElementActions {
+        }
 
+        interface ByFacade {
             /**
              * by.id will match an id that is given to the view via testID prop.
              * @example
@@ -653,53 +664,146 @@ declare global {
              * // Then match with by.id:
              * await element(by.id('tap_me'));
              */
-            id(id: string): Matchers;
+            id(id: string): NativeMatcher;
 
             /**
              * Find an element by text, useful for text fields, buttons.
              * @example await element(by.text('Tap Me'));
              */
-            text(text: string): Matchers;
+            text(text: string): NativeMatcher;
 
             /**
              * Find an element by accessibilityLabel on iOS, or by contentDescription on Android.
              * @example await element(by.label('Welcome'));
              */
-            label(label: string): Matchers;
+            label(label: string): NativeMatcher;
 
             /**
              * Find an element by native view type.
              * @example await element(by.type('RCTImageView'));
              */
-            type(nativeViewType: string): Matchers;
+            type(nativeViewType: string): NativeMatcher;
 
             /**
              * Find an element with an accessibility trait. (iOS only)
              * @example await element(by.traits(['button']));
              */
-            traits(traits: string[]): Matchers;
+            traits(traits: string[]): NativeMatcher;
+
+            /**
+             * Collection of web matchers
+             */
+            readonly html: ByHtmlFacade;
+        }
+
+        interface ByHtmlFacade {
+            /**
+             * Find an element on the DOM tree by its id
+             * @param id
+             * @example
+             * web.element(by.html.id('testingh1'))
+             */
+            id(id: string): WebMatcher;
+
+            /**
+             * Find an element on the DOM tree by its CSS class
+             * @param className
+             * @example
+             * web.element(by.html.className('a'))
+             */
+            className(className: string): WebMatcher;
+
+            /**
+             * Find an element on the DOM tree matching the given CSS selector
+             * @param cssSelector
+             * @example
+             * web.element(by.html.selector('#cssSelector'))
+             */
+            selector(cssSelector: string): WebMatcher;
+
+            /**
+             * Find an element on the DOM tree by its "name" attribute
+             * @param name
+             * @example
+             * web.element(by.html.name('sec_input'))
+             */
+            name(name: string): WebMatcher;
+
+            /**
+             * Find an element on the DOM tree by its XPath
+             * @param xpath
+             * @example
+             * web.element(by.html.xpath('//*[@id="testingh1-1"]'))
+             */
+            xpath(xpath: string): WebMatcher;
+
+            /**
+             * Find an <a> element on the DOM tree by its link text (href content)
+             * @param linkText
+             * @example
+             * web.element(by.html.href('disney.com'))
+             */
+            href(linkText: string): WebMatcher;
+
+            /**
+             * Find an <a> element on the DOM tree by its partial link text (href content)
+             * @param linkTextFragment
+             * @example
+             * web.element(by.html.hrefContains('disney'))
+             */
+            hrefContains(linkTextFragment: string): WebMatcher;
 
+            /**
+             * Find an element on the DOM tree by its tag name
+             * @param tag
+             * @example
+             * web.element(by.html.tag('mark'))
+             */
+            tag(tagName: string): WebMatcher;
+        }
+
+        interface NativeMatcher {
+            /**
+             * Find an element satisfying all the matchers
+             * @example await element(by.text('Product').and(by.id('product_name'));
+             */
+            and(by: NativeMatcher): NativeMatcher;
             /**
              * Find an element by a matcher with a parent matcher
              * @example await element(by.id('Grandson883').withAncestor(by.id('Son883')));
              */
-            withAncestor(parentBy: Matchers): Matchers;
-
+            withAncestor(parentBy: NativeMatcher): NativeMatcher;
             /**
              * Find an element by a matcher with a child matcher
              * @example await element(by.id('Son883').withDescendant(by.id('Grandson883')));
              */
-            withDescendant(childBy: Matchers): Matchers;
+            withDescendant(childBy: NativeMatcher): NativeMatcher;
+        }
+
+        interface WebMatcher {
+            __web__: any; // prevent type coersion
+        }
+
+        interface ExpectFacade {
+            (element: NativeElement): Expect;
+            (webElement: WebElement): WebExpect;
+        }
+
+        interface WebViewElement {
+            element(webMatcher: WebMatcher): IndexableWebElement;
+        }
 
+        interface WebFacade extends WebViewElement {
             /**
-             * Find an element by multiple matchers
-             * @example await element(by.text('Product').and(by.id('product_name'));
+             * Gets the webview element as a testing element.
+             * @param matcher a simple view matcher for the webview element in th UI hierarchy.
+             * If there is only ONE webview element in the UI hierarchy, its NOT a must to supply it.
+             * If there are MORE then one webview element in the UI hierarchy you MUST supply are view matcher.
              */
-            and(by: Matchers): Matchers;
+            (matcher?: NativeMatcher): WebViewElement;
         }
 
-        interface Expect<R> {
-            (element: Element): Expect<Promise<void>>;
+        interface Expect<R = Promise<void>> {
 
             /**
              * Expect the view to be at least 75% visible.
@@ -711,7 +815,7 @@ declare global {
              * Negate the expectation.
              * @example await expect(element(by.id('UniqueId205'))).not.toBeVisible();
              */
-            not: Expect<Promise<void>>;
+            not: this;
 
             /**
              * Expect the view to not be visible.
@@ -765,14 +869,16 @@ declare global {
             toHaveValue(value: any): R;
         }
 
-        interface WaitFor {
+        interface WaitForFacade {
             /**
              * This API polls using the given expectation continuously until the expectation is met. Use manual synchronization with waitFor only as a last resort.
              * NOTE: Every waitFor call must set a timeout using withTimeout(). Calling waitFor without setting a timeout will do nothing.
              * @example await waitFor(element(by.id('UniqueId336'))).toExist().withTimeout(2000);
              */
-            (element: Element): Expect<WaitFor>;
+            (element: NativeElement): Expect<WaitFor>;
+        }
 
+        interface WaitFor {
             /**
              * Waits for the condition to be met until the specified time (millis) have elapsed.
              * @example await waitFor(element(by.id('UniqueId336'))).toExist().withTimeout(2000);
@@ -783,71 +889,72 @@ declare global {
              * Performs the action repeatedly on the element until an expectation is met
              * @example await waitFor(element(by.text('Text5'))).toBeVisible().whileElement(by.id('ScrollView630')).scroll(50, 'down');
              */
-            whileElement(by: Matchers): DetoxAny;
+            whileElement(by: NativeMatcher): NativeElement & WaitFor;
+            // TODO: not sure about & WaitFor - check if we can chain whileElement multiple times
         }
 
-        interface Actions<R> {
+        interface NativeElementActions {
             /**
              * Simulate tap on an element
              * @example await element(by.id('tappable')).tap();
              */
-            tap(): Promise<Actions<R>>;
+            tap(): Promise<void>;
 
             /**
              * Simulate long press on an element
              * @example await element(by.id('tappable')).longPress();
              */
-            longPress(): Promise<Actions<R>>;
+            longPress(): Promise<void>;
 
             /**
              * Simulate long press on an element and then drag it to the position of the target element. (iOS Only)
              * @example await element(by.id('draggable')).longPressAndDrag(2000, NaN, NaN, element(by.id('target')), NaN, NaN, 'fast', 0);
              */
-            longPressAndDrag(duration: number, normalizedPositionX: number, normalizedPositionY: number, targetElement: Element,
-                             normalizedTargetPositionX: number, normalizedTargetPositionY: number, speed: Speed, holdDuration: number): Promise<Actions<R>>;
+            longPressAndDrag(duration: number, normalizedPositionX: number, normalizedPositionY: number, targetElement: NativeElement,
+                             normalizedTargetPositionX: number, normalizedTargetPositionY: number, speed: Speed, holdDuration: number): Promise<void>;
             /**
              * Simulate multiple taps on an element.
              * @param times number of times to tap
              * @example await element(by.id('tappable')).multiTap(3);
              */
-            multiTap(times: number): Promise<Actions<R>>;
+            multiTap(times: number): Promise<void>;
 
             /**
              * Simulate tap at a specific point on an element.
              * Note: The point coordinates are relative to the matched element and the element size could changes on different devices or even when changing the device font size.
              * @example await element(by.id('tappable')).tapAtPoint({ x:5, y:10 });
              */
-            tapAtPoint(point: { x: number; y: number }): Promise<Actions<R>>;
+            tapAtPoint(point: { x: number; y: number }): Promise<void>;
 
             /**
              * Use the builtin keyboard to type text into a text field.
              * @example await element(by.id('textField')).typeText('passcode');
              */
-            typeText(text: string): Promise<Actions<R>>;
+            typeText(text: string): Promise<void>;
 
             /**
              * Paste text into a text field.
              * @example await element(by.id('textField')).replaceText('passcode again');
              */
-            replaceText(text: string): Promise<Actions<R>>;
+            replaceText(text: string): Promise<void>;
 
             /**
              * Clear text from a text field.
              * @example await element(by.id('textField')).clearText();
              */
-            clearText(): Promise<Actions<R>>;
+            clearText(): Promise<void>;
 
             /**
              * Taps the backspace key on the built-in keyboard.
              * @example await element(by.id('textField')).tapBackspaceKey();
              */
-            tapBackspaceKey(): Promise<Actions<R>>;
+            tapBackspaceKey(): Promise<void>;
 
             /**
              * Taps the return key on the built-in keyboard.
              * @example await element(by.id('textField')).tapReturnKey();
              */
-            tapReturnKey(): Promise<Actions<R>>;
+            tapReturnKey(): Promise<void>;
 
             /**
              * Scrolls a given amount of pixels in the provided direction, starting from the provided start positions.
@@ -863,14 +970,14 @@ declare global {
               direction: Direction,
               startPositionX?: number,
               startPositionY?: number,
-            ): Promise<Actions<R>>;
+            ): Promise<void>;
 
             /**
              * Scroll to edge.
              * @example await element(by.id('scrollView')).scrollTo('bottom');
              * @example await element(by.id('scrollView')).scrollTo('top');
              */
-            scrollTo(edge: Direction): Promise<Actions<R>>;
+            scrollTo(edge: Direction): Promise<void>;
 
             /**
              * Swipes in the provided direction at the provided speed, started from percentage.
@@ -884,7 +991,7 @@ declare global {
              * @example await element(by.id('scrollView')).swipe('down', 'fast', 0.5, 0.2);
              * @example await element(by.id('scrollView')).swipe('down', 'fast', 0.5, 0.2, 0.5);
              */
-            swipe(direction: Direction, speed?: Speed, percentage?: number, normalizedStartingPointX?: number, normalizedStartingPointY?: number): Promise<Actions<R>>;
+            swipe(direction: Direction, speed?: Speed, percentage?: number, normalizedStartingPointX?: number, normalizedStartingPointY?: number): Promise<void>;
 
             /**
              * Sets a picker view’s column to the given value. This function supports both date pickers and general picker views. (iOS Only)
@@ -898,7 +1005,7 @@ declare global {
              * await element(by.type('UIPickerView')).setColumnToValue(1,"6");
              * await element(by.type('UIPickerView')).setColumnToValue(2,"34");
              */
-            setColumnToValue(column: number, value: string): Promise<Actions<R>>;
+            setColumnToValue(column: number, value: string): Promise<void>;
 
             /**
              * Sets the date of a date picker to a date generated from the provided string and date format. (iOS only)
@@ -908,7 +1015,7 @@ declare global {
              * await expect(element(by.id('datePicker'))).toBeVisible();
              * await element(by.id('datePicker')).setDatePickerDate('2019-02-06T05:10:00-08:00', "yyyy-MM-dd'T'HH:mm:ssZZZZZ");
              */
-            setDatePickerDate(dateString: string, dateFormat: string): Promise<Actions<R>>;
+            setDatePickerDate(dateString: string, dateFormat: string): Promise<void>;
 
             /**
              * Pinches in the given direction with speed and angle. (iOS only)
@@ -917,7 +1024,111 @@ declare global {
              * await expect(element(by.id('PinchableScrollView'))).toBeVisible();
              * await element(by.id('PinchableScrollView')).pinchWithAngle('outward', 'slow', 0);
              */
-            pinchWithAngle(direction: Direction, speed: Speed, angle: number): Promise<Actions<R>>;
+            pinchWithAngle(direction: Direction, speed: Speed, angle: number): Promise<void>;
+        }
+
+        interface WebExpect<R = Promise<void>> {
+            /**
+             * Negate the expectation.
+             * @example await expect(webview.element(by.htmlId('UniqueId205'))).not.toExist();
+             */
+            not: this;
+
+            /**
+             * Expect the element content to have the `text` supplied
+             * @param text expected to be on the element content
+             * @example
+             * await expect(webview.element(by.htmlId('UniqueId205'))).toHaveText('ExactText');
+             */
+            toHaveText(text: string): R
+
+            /**
+             * Expect the view to exist in the webview DOM tree.
+             * @example await expect(webview.element(by.htmlId('UniqueId205'))).toExist();
+             */
+            toExist(): R;
+        }
+
+        interface IndexableWebElement extends WebElement {
+            /**
+             * Choose from multiple elements matching the same matcher using index
+             * @example await web.element(by.text('Product')).atIndex(2).tap();
+             */
+            atIndex(index: number): WebElement;
+        }
+
+        interface WebElement extends WebElementActions {
+        }
+
+        interface WebElementActions {
+            tap(): Promise<void>
+
+            /**
+             * @param text to type
+             * @param isContentEditable whether its a ContentEditable element, default is false.
+             */
+            typeText(text: string, isContentEditable: boolean): Promise<void>
+
+            /**
+             * At the moment not working on content-editable
+             * @param text to replace with the old content.
+             */
+            replaceText(text: string): Promise<void>
+
+            /**
+             * At the moment not working on content-editable
+             */
+            clearText(): Promise<void>
+
+            /**
+             * scrolling to the view, the element top position will be at the top of the screen.
+             */
+            scrollToView(): Promise<void>
+
+            /**
+             * Gets the input content
+             */
+            getText(): Promise<string>
+
+            /**
+             * Calls the focus function on the element
+             */
+            focus(): Promise<void>
+
+            /**
+             * Selects all the input content, works on ContentEditable at the moment.
+             */
+            selectAllText(): Promise<void>
+
+            /**
+             * Moves the input cursor / caret to the end of the content, works on ContentEditable at the moment.
+             */
+            moveCursorToEnd(): Promise<void>
+
+            /**
+             * Running a script on the element
+             * @param script a method that accept the element as its first arg
+             * @example function foo(element) { console.log(element); }
+             */
+            runScript(script: string): Promise<any>
+
+            /**
+             * Running a script on the element that accept args
+             * @param script a method that accept few args, and the element as the last arg.
+             * @param args a list of args to pass to the script
+             * @example function foo(a, b, c, element) { console.log(`${a}, ${b}, ${c}, ${element}`)}
+             */
+            runScriptWithArgs(script: string, args: any[]): Promise<any>;
+
+            /**
+             * Gets the current page url
+             */
+            getCurrentUrl(): Promise<string>;
+
+            /**
+             * Gets the current page title
+             */
+            getTitle(): Promise<string>;
         }
 
         type Direction = 'left' | 'right' | 'top' | 'bottom' | 'up' | 'down';
@@ -1027,6 +1238,7 @@ declare global {
                 addEventsListener(listener: CircusTestEventListenerBase): void
             };
         }
+
     }
 }
 
diff --git a/detox/src/Detox.js b/detox/src/Detox.js
index 5ed1c0312..cec32bbc7 100644
--- a/detox/src/Detox.js
+++ b/detox/src/Detox.js
@@ -172,6 +172,7 @@ class Detox {
 
     const matchers = matchersRegistry.resolve(this.device, {
       invocationManager,
+      device: this.device,
       emitter: this._eventEmitter,
     });
     Object.assign(this, matchers);
diff --git a/detox/src/DetoxExportWrapper.js b/detox/src/DetoxExportWrapper.js
index 65a037fb9..7d73fac0a 100644
--- a/detox/src/DetoxExportWrapper.js
+++ b/detox/src/DetoxExportWrapper.js
@@ -30,6 +30,7 @@ class DetoxExportWrapper {
 
     this._defineProxy('by');
     this._defineProxy('device');
+    this._defineProxy('web');
 
     this.trace = trace;
     this.traceCall = traceCall;
diff --git a/detox/src/android/AndroidExpect.js b/detox/src/android/AndroidExpect.js
new file mode 100644
index 000000000..708b80838
--- /dev/null
+++ b/detox/src/android/AndroidExpect.js
@@ -0,0 +1,57 @@
+const { NativeElement } = require('./core/NativeElement');
+const { NativeMatcher } = require('./core/NativeMatcher');
+const { NativeExpectElement } = require('./core/NativeExpect');
+const { NativeWaitForElement } = require('./core/NativeWaitFor');
+const { WebElement, WebViewElement } = require('./core/WebElement');
+const { WebExpectElement } = require('./core/WebExpect');
+const matchers = require('./matchers');
+
+class AndroidExpect {
+  constructor({ invocationManager, device, emitter }) {
+    this._device = device;
+    this._emitter = emitter;
+    this._invocationManager = invocationManager;
+
+    this.by = matchers;
+    this.element = this.element.bind(this);
+    this.expect = this.expect.bind(this);
+    this.waitFor = this.waitFor.bind(this);
+    this.web = this.web.bind(this);
+    this.web.element = (...args) => this.web().element(...args);
+  }
+
+  element(matcher) {
+    if (matcher instanceof NativeMatcher) {
+      return new NativeElement(this._invocationManager, this._emitter, matcher);
+    }
+
+    throw new Error(`element() argument is invalid, expected a native matcher, but got ${typeof element}`);
+  }
+
+  // Matcher can be null only if there is only one webview on the hierarchy tree.
+  web(matcher) {
+    if (matcher == null || matcher instanceof NativeMatcher) {
+      return new WebViewElement({
+        device: this._device,
+        emitter: this._emitter,
+        invocationManager: this._invocationManager,
+        matcher,
+      });
+    }
+
+    throw new Error(`web() argument is invalid, expected a native matcher, but got ${typeof element}`);
+  }
+
+  expect(element) {
+    if (element instanceof WebElement) return new WebExpectElement(this._invocationManager, element);
+    if (element instanceof NativeElement) return new NativeExpectElement(this._invocationManager, element);
+    throw new Error(`expect() argument is invalid, expected a native or web matcher, but got ${typeof element}`);
+  }
+
+  waitFor(element) {
+    if (element instanceof NativeElement) return new NativeWaitForElement(this._invocationManager, element);
+    throw new Error(`waitFor() argument is invalid, got ${typeof element}`);
+  }
+}
+
+module.exports = AndroidExpect;
diff --git a/detox/src/android/actions/native.js b/detox/src/android/actions/native.js
new file mode 100644
index 000000000..b24213c9c
--- /dev/null
+++ b/detox/src/android/actions/native.js
@@ -0,0 +1,135 @@
+const invoke = require('../../invoke');
+const DetoxActionApi = require('../espressoapi/DetoxAction');
+const ViewActionsApi = require('../espressoapi/ViewActions');
+const DetoxViewActionsApi = require('../espressoapi/DetoxViewActions');
+
+const { assertEnum, assertNormalized } = require('../../utils/assertArgument');
+const assertDirection = assertEnum(['left', 'right', 'up', 'down']);
+const assertSpeed = assertEnum(['fast', 'slow']);
+
+class Action {
+}
+
+class TapAction extends Action {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(value ? DetoxActionApi.tapAtLocation(value.x, value.y) : DetoxViewActionsApi.click());
+  }
+}
+
+class TapAtPointAction extends Action {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxActionApi.tapAtLocation(value.x, value.y));
+  }
+}
+
+class LongPressAction extends Action {
+  constructor() {
+    super();
+    this._call = invoke.callDirectly(ViewActionsApi.longClick());
+  }
+}
+
+class MultiClickAction extends Action {
+  constructor(times) {
+    super();
+    this._call = invoke.callDirectly(DetoxActionApi.multiClick(times));
+  }
+}
+
+class PressKeyAction extends Action {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(ViewActionsApi.pressKey(value));
+  }
+}
+
+class TypeTextAction extends Action {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxViewActionsApi.typeText(value));
+  }
+}
+
+class ReplaceTextAction extends Action {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(ViewActionsApi.replaceText(value));
+  }
+}
+
+class ClearTextAction extends Action {
+  constructor() {
+    super();
+    this._call = invoke.callDirectly(ViewActionsApi.clearText());
+  }
+}
+
+class ScrollAmountAction extends Action {
+  constructor(direction, amount, startPositionX = -1.0, startPositionY = -1.0) {
+    super();
+    this._call = invoke.callDirectly(DetoxActionApi.scrollInDirection(direction, amount, startPositionX, startPositionY));
+  }
+}
+
+class ScrollAmountStopAtEdgeAction extends Action {
+  constructor(direction, amount, startPositionX = -1.0, startPositionY = -1.0) {
+    super();
+    this._call = invoke.callDirectly(DetoxActionApi.scrollInDirectionStaleAtEdge(direction, amount, startPositionX, startPositionY));
+  }
+}
+
+class ScrollEdgeAction extends Action {
+  constructor(edge) {
+    super();
+
+    this._call = invoke.callDirectly(DetoxActionApi.scrollToEdge(edge));
+  }
+}
+
+class SwipeAction extends Action {
+  constructor(direction, speed, normalizedSwipeOffset, normalizedStartingPointX, normalizedStartingPointY) {
+    super();
+
+    assertDirection({ direction });
+    assertSpeed({ speed });
+    assertNormalized({ normalizedSwipeOffset });
+    assertNormalized({ normalizedStartingPointX });
+    assertNormalized({ normalizedStartingPointY });
+
+    this._call = invoke.callDirectly(
+      DetoxActionApi.swipeInDirection(
+        direction,
+        speed === 'fast',
+        normalizedSwipeOffset,
+        normalizedStartingPointX,
+        normalizedStartingPointY
+      )
+    );
+  }
+}
+
+class TakeElementScreenshot extends Action {
+  constructor() {
+    super();
+    this._call = invoke.callDirectly(DetoxActionApi.takeViewScreenshot());
+  }
+}
+
+module.exports = {
+  Action,
+  TapAction,
+  TapAtPointAction,
+  LongPressAction,
+  MultiClickAction,
+  PressKeyAction,
+  TypeTextAction,
+  ReplaceTextAction,
+  ClearTextAction,
+  ScrollAmountAction,
+  ScrollAmountStopAtEdgeAction,
+  ScrollEdgeAction,
+  SwipeAction,
+  TakeElementScreenshot,
+};
diff --git a/detox/src/android/actions/web.js b/detox/src/android/actions/web.js
new file mode 100644
index 000000000..e6e2e544c
--- /dev/null
+++ b/detox/src/android/actions/web.js
@@ -0,0 +1,138 @@
+const simpleAtoms = require('../espressoapi/web/simpleAtoms');
+const WebElementApi = require('../espressoapi/web/WebElement');
+const WebExpectApi = require('../espressoapi/web/WebExpect');
+
+class WebAction {
+}
+
+class WebTapAction extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.tap(element._call);
+  }
+}
+
+class WebTypeTextAction extends WebAction {
+  constructor(element, text) {
+    super();
+    this._call = WebElementApi.typeText(element._call, text);
+  }
+}
+
+class WebReplaceTextAction extends WebAction {
+  constructor(element, text) {
+    super();
+    this._call = WebElementApi.replaceText(element._call, text);
+  }
+}
+
+class WebClearTextAction extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.clearText(element._call);
+  }
+}
+
+class WebScrollToViewAction extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.scrollToView(element._call);
+  }
+}
+
+class WebGetTextAction extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.getText(element._call);
+  }
+}
+
+class WebRunScriptAction extends WebAction {
+  constructor(element, script) {
+    super();
+    this._call = WebElementApi.runScript(element._call, script);
+  }
+}
+
+class WebRunScriptWithArgsAction extends WebAction {
+  constructor(element, script, args) {
+    super();
+    this._call = WebElementApi.runScriptWithArgs(element._call, script, args);
+  }
+}
+
+class WebGetCurrentUrlAction extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.getCurrentUrl(element._call);
+  }
+}
+
+class WebGetTitleAction extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.getTitle(element._call);
+  }
+}
+
+class WebFocusAction extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.runScript(element._call, simpleAtoms.focus);
+  }
+}
+
+class WebSelectAllText extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.runScript(element._call, simpleAtoms.selectElementContents);
+  }
+}
+
+class WebMoveCursorEnd extends WebAction {
+  constructor(element) {
+    super();
+    this._call = WebElementApi.runScript(element._call, simpleAtoms.moveCursorToEnd);
+  }
+}
+
+class WebExistsAssertion extends WebAction {
+  constructor(webExpect) {
+    super();
+    if (webExpect._notCondition) {
+      this._call = WebExpectApi.toNotExist(webExpect._call);
+    } else {
+      this._call = WebExpectApi.toExist(webExpect._call);
+    }
+  }
+}
+
+class WebHasTextAssertion extends WebAction {
+  constructor(webExpect, text) {
+    super();
+    if (webExpect._notCondition) {
+      this._call = WebExpectApi.toNotHaveText(webExpect._call, text);
+    } else {
+      this._call = WebExpectApi.toHaveText(webExpect._call, text);
+    }
+  }
+}
+
+module.exports = {
+  WebAction,
+  WebTapAction,
+  WebTypeTextAction,
+  WebReplaceTextAction,
+  WebClearTextAction,
+  WebScrollToViewAction,
+  WebGetTextAction,
+  WebRunScriptAction,
+  WebRunScriptWithArgsAction,
+  WebGetCurrentUrlAction,
+  WebGetTitleAction,
+  WebFocusAction,
+  WebSelectAllText,
+  WebMoveCursorEnd,
+  WebExistsAssertion,
+  WebHasTextAssertion,
+};
diff --git a/detox/src/android/core/NativeElement.js b/detox/src/android/core/NativeElement.js
new file mode 100644
index 000000000..61e5cb9ad
--- /dev/null
+++ b/detox/src/android/core/NativeElement.js
@@ -0,0 +1,112 @@
+const fs = require('fs-extra');
+const path = require('path');
+const tempfile = require('tempfile');
+const invoke = require('../../invoke');
+const DetoxMatcherApi = require('../espressoapi/DetoxMatcher');
+const { ActionInteraction } = require('../interactions/native')
+const actions = require('../actions/native')
+
+class NativeElement {
+  constructor(invocationManager, emitter, matcher) {
+    this._invocationManager = invocationManager;
+    this._emitter = emitter;
+    this._originalMatcher = matcher;
+    this._selectElementWithMatcher(this._originalMatcher);
+  }
+
+  _selectElementWithMatcher(matcher) {
+    // if (!(matcher instanceof NativeMatcher)) throw new Error(`Element _selectElementWithMatcher argument must be a valid NativeMatcher, got ${typeof matcher}`);
+    this._call = invoke.call(invoke.Espresso, 'onView', matcher._call);
+  }
+
+  atIndex(index) {
+    if (typeof index !== 'number') throw new Error(`Element atIndex argument must be a number, got ${typeof index}`);
+    const matcher = this._originalMatcher;
+    this._originalMatcher._call = invoke.callDirectly(DetoxMatcherApi.matcherForAtIndex(index, matcher._call.value));
+
+    this._selectElementWithMatcher(this._originalMatcher);
+    return this;
+  }
+
+  async tap(value) {
+    return await new ActionInteraction(this._invocationManager, this, new actions.TapAction(value)).execute();
+  }
+
+  async tapAtPoint(value) {
+    return await new ActionInteraction(this._invocationManager, this, new actions.TapAtPointAction(value)).execute();
+  }
+
+  async longPress() {
+    return await new ActionInteraction(this._invocationManager, this, new actions.LongPressAction()).execute();
+  }
+
+  async multiTap(times) {
+    return await new ActionInteraction(this._invocationManager, this, new actions.MultiClickAction(times)).execute();
+  }
+
+  async tapBackspaceKey() {
+    return await new ActionInteraction(this._invocationManager, this, new actions.PressKeyAction(67)).execute();
+  }
+
+  async tapReturnKey() {
+    return await new ActionInteraction(this._invocationManager, this, new actions.TypeTextAction('\n')).execute();
+  }
+
+  async typeText(value) {
+    return await new ActionInteraction(this._invocationManager, this, new actions.TypeTextAction(value)).execute();
+  }
+
+  async replaceText(value) {
+    return await new ActionInteraction(this._invocationManager, this, new actions.ReplaceTextAction(value)).execute();
+  }
+
+  async clearText() {
+    return await new ActionInteraction(this._invocationManager, this, new actions.ClearTextAction()).execute();
+  }
+
+  async scroll(amount, direction = 'down', startPositionX, startPositionY) {
+    // override the user's element selection with an extended matcher that looks for UIScrollView children
+    // this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
+    return await new ActionInteraction(this._invocationManager, this, new actions.ScrollAmountAction(direction, amount, startPositionX, startPositionY)).execute();
+  }
+
+  async scrollTo(edge) {
+    // override the user's element selection with an extended matcher that looks for UIScrollView children
+    this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
+    return await new ActionInteraction(this._invocationManager, this, new actions.ScrollEdgeAction(edge)).execute();
+  }
+
+  /**
+   * @param {'up' | 'right' | 'down' | 'left'} direction
+   * @param {'slow' | 'fast'} [speed]
+   * @param {number} [normalizedSwipeOffset] - swipe amount relative to the screen width/height
+   * @param {number} [normalizedStartingPointX] - X coordinate of swipe starting point, relative to the view width
+   * @param {number} [normalizedStartingPointY] - Y coordinate of swipe starting point, relative to the view height
+   */
+  async swipe(direction, speed = 'fast', normalizedSwipeOffset = NaN, normalizedStartingPointX = NaN, normalizedStartingPointY = NaN) {
+    normalizedSwipeOffset = Number.isNaN(normalizedSwipeOffset) ? 0.75 : normalizedSwipeOffset;
+
+    // override the user's element selection with an extended matcher that avoids RN issues with RCTScrollView
+    this._selectElementWithMatcher(this._originalMatcher._avoidProblematicReactNativeElements());
+    const action = new actions.SwipeAction(direction, speed, normalizedSwipeOffset, normalizedStartingPointX, normalizedStartingPointY);
+    return await new ActionInteraction(this._invocationManager, this, action).execute();
+  }
+
+  async takeScreenshot(screenshotName) {
+    // TODO this should be moved to a lower-layer handler of this use-case
+    const resultBase64 = await new ActionInteraction(this._invocationManager, this, new actions.TakeElementScreenshot()).execute();
+    const filePath = tempfile('detox.element-screenshot.png');
+    await fs.writeFile(filePath, resultBase64, 'base64');
+
+    await this._emitter.emit('createExternalArtifact', {
+      pluginId: 'screenshot',
+      artifactName: screenshotName || path.basename(filePath, '.png'),
+      artifactPath: filePath,
+    });
+    return filePath;
+  }
+}
+
+module.exports = {
+  NativeElement,
+};
diff --git a/detox/src/android/core/NativeExpect.js b/detox/src/android/core/NativeExpect.js
new file mode 100644
index 000000000..083600681
--- /dev/null
+++ b/detox/src/android/core/NativeExpect.js
@@ -0,0 +1,77 @@
+const { MatcherAssertionInteraction } = require('../interactions/native')
+const matchers = require('../matchers/native')
+
+class NativeExpect {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+  }
+
+  get not() {
+    this._notCondition = true;
+    return this;
+  }
+}
+
+class NativeExpectElement extends NativeExpect {
+  constructor(invocationManager, element) {
+    super(invocationManager);
+    this._element = element;
+  }
+
+  async toBeVisible() {
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.VisibleMatcher().not : new matchers.VisibleMatcher()).execute();
+  }
+
+  async toBeNotVisible() {
+    return await this.not.toBeVisible();
+  }
+
+  async toExist() {
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.ExistsMatcher().not : new matchers.ExistsMatcher()).execute();
+  }
+
+  async toNotExist() {
+    return await this.not.toExist();
+  }
+
+  async toHaveText(text) {
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.TextMatcher(text).not : new matchers.TextMatcher(text)).execute();
+  }
+
+  async toNotHaveText(text) {
+    return await this.not.toHaveText(text);
+  }
+
+  async toHaveLabel(value) {
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.LabelMatcher(value).not : new matchers.LabelMatcher(value)).execute();
+  }
+
+  async toNotHaveLabel(value) {
+    return await this.not.toHaveLabel(value);
+  }
+
+  async toHaveId(value) {
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.IdMatcher(value).not : new matchers.IdMatcher(value)).execute();
+  }
+
+  async toNotHaveId(value) {
+    return await this.not.toHaveId(value);
+  }
+
+  async toHaveValue(value) {
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.ValueMatcher(value).not : new matchers.ValueMatcher(value)).execute();
+  }
+
+  async toNotHaveValue(value) {
+    return await this.not.toHaveValue(value);
+  }
+
+  async toHaveToggleValue(value) {
+    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.ToggleMatcher(value).not : new matchers.ToggleMatcher(value)).execute();
+  }
+}
+
+module.exports = {
+  NativeExpect,
+  NativeExpectElement,
+};
diff --git a/detox/src/android/core/NativeMatcher.js b/detox/src/android/core/NativeMatcher.js
new file mode 100644
index 000000000..5c25f4504
--- /dev/null
+++ b/detox/src/android/core/NativeMatcher.js
@@ -0,0 +1,48 @@
+const invoke = require('../../invoke');
+const DetoxMatcherApi = require('../espressoapi/DetoxMatcher');
+
+class NativeMatcher {
+  withAncestor(matcher) {
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherWithAncestor(this, matcher));
+    return this;
+  }
+
+  withDescendant(matcher) {
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherWithDescendant(this, matcher));
+    return this;
+  }
+
+  and(matcher) {
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForAnd(this, matcher));
+    return this;
+  }
+
+  or(matcher) {
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForOr(this, matcher));
+    return this;
+  }
+
+  get not() {
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForNot(this));
+    return this;
+  }
+
+  _avoidProblematicReactNativeElements() {
+    /*
+    const _originalMatcherCall = this._call;
+    this._call = invoke.call(invoke.IOS.Class('GREYMatchers'), 'detoxMatcherAvoidingProblematicReactNativeElements:', _originalMatcherCall);
+    */
+    return this;
+  }
+  _extendToDescendantScrollViews() {
+    /*
+    const _originalMatcherCall = this._call;
+    this._call = invoke.call(invoke.IOS.Class('GREYMatchers'), 'detoxMatcherForScrollChildOfMatcher:', _originalMatcherCall);
+    */
+    return this;
+  }
+}
+
+module.exports = {
+  NativeMatcher,
+};
diff --git a/detox/src/android/core/NativeWaitFor.js b/detox/src/android/core/NativeWaitFor.js
new file mode 100644
index 000000000..7008ad7a4
--- /dev/null
+++ b/detox/src/android/core/NativeWaitFor.js
@@ -0,0 +1,73 @@
+const { WaitForInteraction } = require('../interactions/native')
+const matchers = require('../matchers/native')
+
+class NativeWaitFor {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager;
+  }
+}
+
+class NativeWaitForElement extends NativeWaitFor {
+  constructor(invocationManager, element) {
+    super(invocationManager);
+    this._element = element;
+  }
+
+  get not() {
+    this._notCondition = true;
+    return this;
+  }
+
+  toBeVisible() {
+    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.VisibleMatcher().not : new matchers.VisibleMatcher());
+  }
+
+  toBeNotVisible() {
+    return this.not.toBeVisible();
+  }
+
+  toExist() {
+    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.ExistsMatcher().not : new matchers.ExistsMatcher());
+  }
+
+  toNotExist() {
+    return this.not.toExist();
+  }
+
+  toHaveText(text) {
+    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.TextMatcher(text).not : new matchers.TextMatcher(text));
+  }
+
+  toNotHaveText(text) {
+    return this.not.toHaveText(text);
+  }
+
+  toHaveLabel(value) {
+    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.LabelMatcher(value).not : new matchers.LabelMatcher(value));
+  }
+
+  toNotHaveLabel(value) {
+    return this.not.toHaveLabel(value);
+  }
+
+  toHaveId(value) {
+    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.IdMatcher(value).not : new matchers.IdMatcher(value));
+  }
+
+  toNotHaveId(value) {
+    return this.not.toHaveId(value);
+  }
+
+  toHaveValue(value) {
+    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new matchers.ValueMatcher(value).not : new matchers.ValueMatcher(value));
+  }
+
+  toNotHaveValue(value) {
+    return this.not.toHaveValue(value);
+  }
+}
+
+module.exports = {
+  NativeWaitFor,
+  NativeWaitForElement,
+};
diff --git a/detox/src/android/core/WebElement.js b/detox/src/android/core/WebElement.js
new file mode 100644
index 000000000..6a1c5c67f
--- /dev/null
+++ b/detox/src/android/core/WebElement.js
@@ -0,0 +1,124 @@
+const invoke = require('../../invoke');
+const EspressoWebDetoxApi = require('../espressoapi/web/EspressoWebDetox');
+const WebViewElementApi = require('../espressoapi/web/WebViewElement');
+const actions = require('../actions/web');
+const { ActionInteraction } = require('../interactions/web');
+const { WebMatcher } = require('./WebMatcher');
+
+const _device = Symbol('device');
+const _emitter = Symbol('emitter');
+const _matcher = Symbol('matcher');
+const _invocationManager = Symbol('invocationManager');
+const _webMatcher = Symbol('webMatcher');
+const _webViewElement = Symbol('webViewElement');
+
+class WebElement {
+  constructor({ device, invocationManager, webMatcher, webViewElement }) {
+    this[_device] = device;
+    this[_invocationManager] = invocationManager;
+    this[_webMatcher] = webMatcher;
+    this[_webViewElement] = webViewElement;
+    this.atIndex(0);
+  }
+
+  atIndex(index) {
+    const webViewElementCall = this[_webViewElement]._call;
+    const webMatcherCall = this[_webMatcher]._call;
+
+    this._call = invoke.callDirectly(WebViewElementApi.element(webViewElementCall, webMatcherCall.value, index));
+    return this;
+  }
+
+  // At the moment not working on content-editable
+  async tap() {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebTapAction(this)).execute();
+  }
+
+  async typeText(text, isContentEditable = false) {
+    if (isContentEditable) {
+      return await this[_device]._typeText(text);
+    }
+    return await new ActionInteraction(this[_invocationManager],  new actions.WebTypeTextAction(this, text)).execute();
+  }
+
+  // At the moment not working on content-editable
+  async replaceText(text) {
+    return await new ActionInteraction(this[_invocationManager],  new actions.WebReplaceTextAction(this, text)).execute();
+  }
+
+  // At the moment not working on content-editable
+  async clearText() {
+    return await new ActionInteraction(this[_invocationManager],  new actions.WebClearTextAction(this)).execute();
+  }
+
+  async scrollToView() {
+    return await new ActionInteraction(this[_invocationManager],  new actions.WebScrollToViewAction(this)).execute();
+  }
+
+  async getText() {
+    return await new ActionInteraction(this[_invocationManager],  new actions.WebGetTextAction(this)).execute();
+  }
+
+  async focus() {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebFocusAction(this)).execute();
+  }
+
+  async selectAllText() {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebSelectAllText(this)).execute();
+  }
+
+  async moveCursorToEnd() {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebMoveCursorEnd(this)).execute();
+  }
+
+  async runScript(script) {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebRunScriptAction(this, script)).execute();
+  }
+
+  async runScriptWithArgs(script, args) {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebRunScriptWithArgsAction(this, script, args)).execute();
+  }
+
+  async getCurrentUrl() {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebGetCurrentUrlAction(this)).execute();
+  }
+
+  async getTitle() {
+    return await new ActionInteraction(this[_invocationManager], new actions.WebGetTitleAction(this)).execute();
+  }
+}
+
+class WebViewElement {
+  constructor({ device, emitter, invocationManager, matcher }) {
+    this[_device] = device;
+    this[_emitter] = emitter;
+    this[_invocationManager] = invocationManager;
+    this[_matcher] = matcher;
+
+    if (matcher !== undefined) {
+      this._call = invoke.callDirectly(EspressoWebDetoxApi.getWebView(matcher._call.value));
+    } else {
+      this._call = invoke.callDirectly(EspressoWebDetoxApi.getWebView());
+    }
+
+    this.element = this.element.bind(this);
+  }
+
+  element(webMatcher) {
+    if (webMatcher instanceof WebMatcher) {
+      return new WebElement({
+        device: this[_device],
+        invocationManager: this[_invocationManager],
+        webViewElement: this,
+        webMatcher,
+      });
+    }
+
+    throw new Error(`element() argument is invalid, expected a web matcher, but got ${typeof element}`);
+  }
+}
+
+module.exports = {
+  WebElement,
+  WebViewElement,
+};
diff --git a/detox/src/android/core/WebExpect.js b/detox/src/android/core/WebExpect.js
new file mode 100644
index 000000000..89d5deeb8
--- /dev/null
+++ b/detox/src/android/core/WebExpect.js
@@ -0,0 +1,36 @@
+const invoke = require('../../invoke');
+const EspressoWebDetoxApi = require('../espressoapi/web/EspressoWebDetox');
+const { WebExistsAssertion, WebHasTextAssertion } = require('../actions/web');
+const { WebAssertionInteraction } = require('../interactions/web');
+
+class WebExpect {
+  constructor(invocationManager) {
+    this._invocationManager = invocationManager
+    this._notCondition = false;
+  }
+
+  get not() {
+    this._notCondition = true;
+    return this;
+  }
+}
+
+class WebExpectElement extends WebExpect {
+  constructor(invocationManager, webElement) {
+    super(invocationManager)
+    this._call = invoke.callDirectly(EspressoWebDetoxApi.expect(webElement._call.value));
+  }
+
+  async toHaveText(text) {
+    return await new WebAssertionInteraction(this._invocationManager, new WebHasTextAssertion(this, text)).execute();
+  }
+
+  async toExist() {
+    return await new WebAssertionInteraction(this._invocationManager, new WebExistsAssertion(this)).execute();
+  }
+}
+
+module.exports = {
+  WebExpect,
+  WebExpectElement,
+};
diff --git a/detox/src/android/core/WebMatcher.js b/detox/src/android/core/WebMatcher.js
new file mode 100644
index 000000000..03dca2013
--- /dev/null
+++ b/detox/src/android/core/WebMatcher.js
@@ -0,0 +1,7 @@
+class WebMatcher {
+
+}
+
+module.exports = {
+  WebMatcher,
+};
diff --git a/detox/src/android/espressoapi/web/DetoxWebAtomMatcher.js b/detox/src/android/espressoapi/web/DetoxWebAtomMatcher.js
new file mode 100644
index 000000000..bc79ff190
--- /dev/null
+++ b/detox/src/android/espressoapi/web/DetoxWebAtomMatcher.js
@@ -0,0 +1,108 @@
+/**
+
+	This code is generated.
+	For more information see generation/README.md.
+*/
+
+
+
+class DetoxWebAtomMatcher {
+  static matcherForId(id) {
+    if (typeof id !== "string") throw new Error("id should be a string, but got " + (id + (" (" + (typeof id + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForId",
+      args: [id]
+    };
+  }
+
+  static matcherForClassName(className) {
+    if (typeof className !== "string") throw new Error("className should be a string, but got " + (className + (" (" + (typeof className + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForClassName",
+      args: [className]
+    };
+  }
+
+  static matcherForCssSelector(cssSelector) {
+    if (typeof cssSelector !== "string") throw new Error("cssSelector should be a string, but got " + (cssSelector + (" (" + (typeof cssSelector + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForCssSelector",
+      args: [cssSelector]
+    };
+  }
+
+  static matcherForName(name) {
+    if (typeof name !== "string") throw new Error("name should be a string, but got " + (name + (" (" + (typeof name + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForName",
+      args: [name]
+    };
+  }
+
+  static matcherForXPath(xpath) {
+    if (typeof xpath !== "string") throw new Error("xpath should be a string, but got " + (xpath + (" (" + (typeof xpath + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForXPath",
+      args: [xpath]
+    };
+  }
+
+  static matcherForLinkText(linkText) {
+    if (typeof linkText !== "string") throw new Error("linkText should be a string, but got " + (linkText + (" (" + (typeof linkText + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForLinkText",
+      args: [linkText]
+    };
+  }
+
+  static matcherForPartialLinkText(partialLinkText) {
+    if (typeof partialLinkText !== "string") throw new Error("partialLinkText should be a string, but got " + (partialLinkText + (" (" + (typeof partialLinkText + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForPartialLinkText",
+      args: [partialLinkText]
+    };
+  }
+
+  static matcherForTagName(tag) {
+    if (typeof tag !== "string") throw new Error("tag should be a string, but got " + (tag + (" (" + (typeof tag + ")"))));
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.DetoxWebAtomMatcher"
+      },
+      method: "matcherForTagName",
+      args: [tag]
+    };
+  }
+
+}
+
+module.exports = DetoxWebAtomMatcher;
\ No newline at end of file
diff --git a/detox/src/android/espressoapi/web/EspressoWebDetox.js b/detox/src/android/espressoapi/web/EspressoWebDetox.js
new file mode 100644
index 000000000..7a1fb63a4
--- /dev/null
+++ b/detox/src/android/espressoapi/web/EspressoWebDetox.js
@@ -0,0 +1,68 @@
+/**
+
+	This code is generated.
+	For more information see generation/README.md.
+*/
+
+
+function sanitize_matcher(matcher) {
+  if (!matcher._call) {
+    return matcher;
+  }
+
+  const originalMatcher = typeof matcher._call === 'function' ? matcher._call() : matcher._call;
+  return originalMatcher.type ? originalMatcher.value : originalMatcher;
+} 
+class EspressoWebDetox {
+  static getWebView() {
+    function getWebView0() {
+      return {
+        target: {
+          type: "Class",
+          value: "com.wix.detox.espresso.web.EspressoWebDetox"
+        },
+        method: "getWebView",
+        args: []
+      };
+    }
+
+    function getWebView1(matcher) {
+      return {
+        target: {
+          type: "Class",
+          value: "com.wix.detox.espresso.web.EspressoWebDetox"
+        },
+        method: "getWebView",
+        args: [{
+          type: "Invocation",
+          value: sanitize_matcher(matcher)
+        }]
+      };
+    }
+
+    if (arguments.length === 0) {
+      return getWebView0.apply(null, arguments);
+    }
+
+    if (arguments.length === 1) {
+      return getWebView1.apply(null, arguments);
+    }
+  }
+
+  static expect(webElement) {
+    return {
+      target: {
+        type: "Class",
+        value: "com.wix.detox.espresso.web.EspressoWebDetox"
+      },
+      method: "expect",
+      args: [{
+        type: "Invocation",
+        value: webElement
+      }]
+    };
+  }
+
+}
+
+module.exports = EspressoWebDetox;
\ No newline at end of file
diff --git a/detox/src/android/espressoapi/web/WebElement.js b/detox/src/android/espressoapi/web/WebElement.js
new file mode 100644
index 000000000..8af504915
--- /dev/null
+++ b/detox/src/android/espressoapi/web/WebElement.js
@@ -0,0 +1,104 @@
+/**
+
+	This code is generated.
+	For more information see generation/README.md.
+*/
+
+
+
+class WebElement {
+  static tap(element) {
+    return {
+      target: element,
+      method: "tap",
+      args: []
+    };
+  }
+
+  static typeText(element, text) {
+    if (typeof text !== "string") throw new Error("text should be a string, but got " + (text + (" (" + (typeof text + ")"))));
+    return {
+      target: element,
+      method: "typeText",
+      args: [text]
+    };
+  }
+
+  static replaceText(element, text) {
+    if (typeof text !== "string") throw new Error("text should be a string, but got " + (text + (" (" + (typeof text + ")"))));
+    return {
+      target: element,
+      method: "replaceText",
+      args: [text]
+    };
+  }
+
+  static clearText(element) {
+    return {
+      target: element,
+      method: "clearText",
+      args: []
+    };
+  }
+
+  static scrollToView(element) {
+    return {
+      target: element,
+      method: "scrollToView",
+      args: []
+    };
+  }
+
+  static getText(element) {
+    return {
+      target: element,
+      method: "getText",
+      args: []
+    };
+  }
+
+  static runScript(element, script) {
+    if (typeof script !== "string") throw new Error("script should be a string, but got " + (script + (" (" + (typeof script + ")"))));
+    return {
+      target: element,
+      method: "runScript",
+      args: [script]
+    };
+  }
+
+  static runScriptWithArgs(element, script, args) {
+    if (typeof script !== "string") throw new Error("script should be a string, but got " + (script + (" (" + (typeof script + ")"))));
+
+    if (typeof args !== 'object' || !args instanceof Array) {
+      throw new Error('args must be an array, got ' + typeof args);
+    }
+
+    return {
+      target: element,
+      method: "runScriptWithArgs",
+      args: [script, {
+        type: "ArrayList<Object>",
+        value: args
+      }]
+    };
+  }
+
+  static getCurrentUrl(element) {
+    return {
+      target: element,
+      method: "getCurrentUrl",
+      args: []
+    };
+  }
+
+  static getTitle(element) {
+    return {
+      target: element,
+      method: "getTitle",
+      args: []
+    };
+  }
+
+}
+
+module.exports = WebElement;
\ No newline at end of file
diff --git a/detox/src/android/espressoapi/web/WebExpect.js b/detox/src/android/espressoapi/web/WebExpect.js
new file mode 100644
index 000000000..0d1a6e20d
--- /dev/null
+++ b/detox/src/android/espressoapi/web/WebExpect.js
@@ -0,0 +1,46 @@
+/**
+
+	This code is generated.
+	For more information see generation/README.md.
+*/
+
+
+
+class WebExpect {
+  static toNotExist(element) {
+    return {
+      target: element,
+      method: "toNotExist",
+      args: []
+    };
+  }
+
+  static toExist(element) {
+    return {
+      target: element,
+      method: "toExist",
+      args: []
+    };
+  }
+
+  static toHaveText(element, text) {
+    if (typeof text !== "string") throw new Error("text should be a string, but got " + (text + (" (" + (typeof text + ")"))));
+    return {
+      target: element,
+      method: "toHaveText",
+      args: [text]
+    };
+  }
+
+  static toNotHaveText(element, text) {
+    if (typeof text !== "string") throw new Error("text should be a string, but got " + (text + (" (" + (typeof text + ")"))));
+    return {
+      target: element,
+      method: "toNotHaveText",
+      args: [text]
+    };
+  }
+
+}
+
+module.exports = WebExpect;
\ No newline at end of file
diff --git a/detox/src/android/espressoapi/web/WebViewElement.js b/detox/src/android/espressoapi/web/WebViewElement.js
new file mode 100644
index 000000000..5e65790a5
--- /dev/null
+++ b/detox/src/android/espressoapi/web/WebViewElement.js
@@ -0,0 +1,48 @@
+/**
+
+	This code is generated.
+	For more information see generation/README.md.
+*/
+
+
+
+class WebViewElement {
+  static element(element, webMatcher) {
+    function element2(element, webMatcher) {
+      return {
+        target: element,
+        method: "element",
+        args: [{
+          type: "Invocation",
+          value: webMatcher
+        }]
+      };
+    }
+
+    function element3(element, webMatcher, index) {
+      if (typeof index !== "number") throw new Error("index should be a number, but got " + (index + (" (" + (typeof index + ")"))));
+      return {
+        target: element,
+        method: "element",
+        args: [{
+          type: "Invocation",
+          value: webMatcher
+        }, {
+          type: "Integer",
+          value: index
+        }]
+      };
+    }
+
+    if (arguments.length === 2) {
+      return element2.apply(null, arguments);
+    }
+
+    if (arguments.length === 3) {
+      return element3.apply(null, arguments);
+    }
+  }
+
+}
+
+module.exports = WebViewElement;
\ No newline at end of file
diff --git a/detox/src/android/espressoapi/web/simpleAtoms.js b/detox/src/android/espressoapi/web/simpleAtoms.js
new file mode 100644
index 000000000..6f7c45caa
--- /dev/null
+++ b/detox/src/android/espressoapi/web/simpleAtoms.js
@@ -0,0 +1,62 @@
+const selectElementContents = `
+function selectElementContents(el) {
+  el.focus();
+  if (el.contentEditable) {
+    var range = document.createRange();
+    range.selectNodeContents(el);
+    var sel = window.getSelection();
+    sel.removeAllRanges();
+    sel.addRange(range);
+  } else {
+    el.setSelectionRange(0, el.value.length);
+  }
+}`;
+
+const moveCursorToEnd = `
+function moveCursorToEnd(el) {
+  el.focus();
+  if (typeof window.getSelection != "undefined" && typeof document.createRange != "undefined") {
+      var range = document.createRange();
+      range.selectNodeContents(el);
+      range.collapse(false);
+      var sel = window.getSelection();
+      sel.removeAllRanges();
+      sel.addRange(range);
+  } else if (typeof document.body.createTextRange != "undefined") {
+      var textRange = document.body.createTextRange();
+      textRange.moveToElementText(el);
+      textRange.collapse(false);
+      textRange.select();
+  }
+}`;
+
+const setCursorPosition = `
+  function setPos(pos, el) {
+    const isContentEditable = el && el.contentEditable;
+    // for ContentEditable field
+    if (isContentEditable) {
+        el.focus();
+        document.getSelection().collapse(el, pos);
+        return;
+    }
+    el.setSelectionRange(pos, pos);
+}`;
+
+const focus = `
+function focusElement(el) {
+  el.focus();
+}
+`;
+
+const getBoundingClientRect = `
+function getBoundingClientRect(el) {
+  return el.getBoundingClientRect();
+}`;
+
+module.exports = {
+  selectElementContents,
+  moveCursorToEnd,
+  setCursorPosition,
+  focus,
+  getBoundingClientRect
+}
\ No newline at end of file
diff --git a/detox/src/android/expect.js b/detox/src/android/expect.js
deleted file mode 100644
index b0bb1cea6..000000000
--- a/detox/src/android/expect.js
+++ /dev/null
@@ -1,493 +0,0 @@
-const fs = require('fs-extra');
-const path = require('path');
-const tempfile = require('tempfile');
-const invoke = require('../invoke');
-const DetoxActionApi = require('./espressoapi/DetoxAction');
-const ViewActionsApi = require('./espressoapi/ViewActions');
-const DetoxViewActionsApi = require('./espressoapi/DetoxViewActions');
-const DetoxAssertionApi = require('./espressoapi/DetoxAssertion');
-const EspressoDetoxApi = require('./espressoapi/EspressoDetox');
-const DetoxMatcherApi = require('./espressoapi/DetoxMatcher');
-const {
-  Matcher,
-  LabelMatcher,
-  IdMatcher,
-  TypeMatcher,
-  TraitsMatcher,
-  VisibleMatcher,
-  ExistsMatcher,
-  TextMatcher,
-  ValueMatcher,
-  ToggleMatcher,
-} = require('./matcher');
-const { assertEnum, assertNormalized, assertNumber } = require('../utils/assertArgument');
-const assertDirection = assertEnum(['left', 'right', 'up', 'down']);
-const assertSpeed = assertEnum(['fast', 'slow']);
-
-function call(maybeAFunction) {
-  return maybeAFunction instanceof Function ? maybeAFunction() : maybeAFunction;
-}
-
-class Action {
-}
-
-class TapAction extends Action {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(value ? DetoxActionApi.tapAtLocation(value.x, value.y) : DetoxViewActionsApi.click());
-  }
-}
-
-class TapAtPointAction extends Action {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(DetoxActionApi.tapAtLocation(value.x, value.y));
-  }
-}
-
-class LongPressAction extends Action {
-  constructor() {
-    super();
-    this._call = invoke.callDirectly(ViewActionsApi.longClick());
-  }
-}
-
-class MultiClickAction extends Action {
-  constructor(times) {
-    super();
-    this._call = invoke.callDirectly(DetoxActionApi.multiClick(times));
-  }
-}
-
-class PressKeyAction extends Action {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(ViewActionsApi.pressKey(value));
-  }
-}
-
-class TypeTextAction extends Action {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(DetoxViewActionsApi.typeText(value));
-  }
-}
-
-class ReplaceTextAction extends Action {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(ViewActionsApi.replaceText(value));
-  }
-}
-
-class ClearTextAction extends Action {
-  constructor() {
-    super();
-    this._call = invoke.callDirectly(ViewActionsApi.clearText());
-  }
-}
-
-class ScrollAmountAction extends Action {
-  constructor(direction, amount, startPositionX = -1.0, startPositionY = -1.0) {
-    super();
-    this._call = invoke.callDirectly(DetoxActionApi.scrollInDirection(direction, amount, startPositionX, startPositionY));
-  }
-}
-
-class ScrollAmountStopAtEdgeAction extends Action {
-  constructor(direction, amount, startPositionX = -1.0, startPositionY = -1.0) {
-    super();
-    this._call = invoke.callDirectly(DetoxActionApi.scrollInDirectionStaleAtEdge(direction, amount, startPositionX, startPositionY));
-  }
-}
-
-class ScrollEdgeAction extends Action {
-  constructor(edge) {
-    super();
-
-    this._call = invoke.callDirectly(DetoxActionApi.scrollToEdge(edge));
-  }
-}
-
-class SwipeAction extends Action {
-  constructor(direction, speed, normalizedSwipeOffset, normalizedStartingPointX, normalizedStartingPointY) {
-    super();
-
-    assertDirection({ direction });
-    assertSpeed({ speed });
-    assertNormalized({ normalizedSwipeOffset });
-    assertNormalized({ normalizedStartingPointX });
-    assertNormalized({ normalizedStartingPointY });
-
-    this._call = invoke.callDirectly(
-      DetoxActionApi.swipeInDirection(
-        direction,
-        speed === 'fast',
-        normalizedSwipeOffset,
-        normalizedStartingPointX,
-        normalizedStartingPointY
-      )
-    );
-  }
-}
-
-class TakeElementScreenshot extends Action {
-  constructor() {
-    super();
-    this._call = invoke.callDirectly(DetoxActionApi.takeViewScreenshot());
-  }
-}
-
-class Interaction {
-  constructor(invocationManager) {
-    this._call = undefined;
-    this._invocationManager = invocationManager;
-  }
-
-  async execute() {
-    const resultObj = await this._invocationManager.execute(this._call);
-    return resultObj ? resultObj.result : undefined;
-  }
-}
-
-class ActionInteraction extends Interaction {
-  constructor(invocationManager, element, action) {
-    super(invocationManager);
-    this._call = EspressoDetoxApi.perform(call(element._call), action._call);
-    // TODO: move this.execute() here from the caller
-  }
-}
-
-class MatcherAssertionInteraction extends Interaction {
-  constructor(invocationManager, element, matcher) {
-    super(invocationManager);
-    this._call = DetoxAssertionApi.assertMatcher(call(element._call), matcher._call.value);
-    // TODO: move this.execute() here from the caller
-  }
-}
-
-class WaitForInteraction extends Interaction {
-  constructor(invocationManager, element, assertionMatcher) {
-    super(invocationManager);
-    this._element = element;
-    this._assertionMatcher = assertionMatcher;
-    this._element._selectElementWithMatcher(this._element._originalMatcher);
-  }
-
-  async withTimeout(timeout) {
-    if (typeof timeout !== 'number') throw new Error(`WaitForInteraction withTimeout argument must be a number, got ${typeof timeout}`);
-    if (timeout < 0) throw new Error('timeout must be larger than 0');
-
-    this._call = DetoxAssertionApi.waitForAssertMatcher(call(this._element._call), this._assertionMatcher._call.value, timeout / 1000);
-    await this.execute();
-  }
-
-  whileElement(searchMatcher) {
-    return new WaitForActionInteraction(this._invocationManager, this._element, this._assertionMatcher, searchMatcher);
-  }
-}
-
-class WaitForActionInteractionBase extends Interaction {
-  constructor(invocationManager, element, matcher, searchMatcher) {
-    super(invocationManager);
-    //if (!(element instanceof Element)) throw new Error(`WaitForActionInteraction ctor 1st argument must be a valid Element, got ${typeof element}`);
-    //if (!(matcher instanceof Matcher)) throw new Error(`WaitForActionInteraction ctor 2nd argument must be a valid Matcher, got ${typeof matcher}`);
-    if (!(searchMatcher instanceof Matcher))
-      throw new Error(`WaitForActionInteraction ctor 3rd argument must be a valid Matcher, got ${typeof searchMatcher}`);
-    this._element = element;
-    this._originalMatcher = matcher;
-    this._searchMatcher = searchMatcher;
-  }
-
-  _prepare(searchAction) {
-    //if (!searchAction instanceof Action) throw new Error(`WaitForActionInteraction _execute argument must be a valid Action, got ${typeof searchAction}`);
-
-    this._call = DetoxAssertionApi.waitForAssertMatcherWithSearchAction(
-      call(this._element._call),
-      call(this._originalMatcher._call).value,
-      call(searchAction._call),
-      call(this._searchMatcher._call).value
-    );
-  }
-}
-
-class WaitForActionInteraction extends WaitForActionInteractionBase {
-  async scroll(amount, direction = 'down', scrollPositionX, scrollPositionY) {
-    this._prepare(new ScrollAmountStopAtEdgeAction(direction, amount, scrollPositionX, scrollPositionY));
-    await this.execute();
-  }
-}
-
-class Element {
-  constructor(invocationManager, emitter, matcher) {
-    this._invocationManager = invocationManager;
-    this._emitter = emitter;
-    this._originalMatcher = matcher;
-    this._selectElementWithMatcher(this._originalMatcher);
-  }
-
-  _selectElementWithMatcher(matcher) {
-    // if (!(matcher instanceof Matcher)) throw new Error(`Element _selectElementWithMatcher argument must be a valid Matcher, got ${typeof matcher}`);
-    this._call = invoke.call(invoke.Espresso, 'onView', matcher._call);
-  }
-
-  atIndex(index) {
-    if (typeof index !== 'number') throw new Error(`Element atIndex argument must be a number, got ${typeof index}`);
-    const matcher = this._originalMatcher;
-    this._originalMatcher._call = invoke.callDirectly(DetoxMatcherApi.matcherForAtIndex(index, matcher._call.value));
-
-    this._selectElementWithMatcher(this._originalMatcher);
-    return this;
-  }
-
-  async tap(value) {
-    return await new ActionInteraction(this._invocationManager, this, new TapAction(value)).execute();
-  }
-
-  async tapAtPoint(value) {
-    return await new ActionInteraction(this._invocationManager, this, new TapAtPointAction(value)).execute();
-  }
-
-  async longPress() {
-    return await new ActionInteraction(this._invocationManager, this, new LongPressAction()).execute();
-  }
-
-  async multiTap(times) {
-    return await new ActionInteraction(this._invocationManager, this, new MultiClickAction(times)).execute();
-  }
-
-  async tapBackspaceKey() {
-    return await new ActionInteraction(this._invocationManager, this, new PressKeyAction(67)).execute();
-  }
-
-  async tapReturnKey() {
-    return await new ActionInteraction(this._invocationManager, this, new TypeTextAction('\n')).execute();
-  }
-
-  async typeText(value) {
-    return await new ActionInteraction(this._invocationManager, this, new TypeTextAction(value)).execute();
-  }
-
-  async replaceText(value) {
-    return await new ActionInteraction(this._invocationManager, this, new ReplaceTextAction(value)).execute();
-  }
-
-  async clearText() {
-    return await new ActionInteraction(this._invocationManager, this, new ClearTextAction()).execute();
-  }
-
-  async scroll(amount, direction = 'down', startPositionX, startPositionY) {
-    // override the user's element selection with an extended matcher that looks for UIScrollView children
-    // this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
-    return await new ActionInteraction(this._invocationManager, this, new ScrollAmountAction(direction, amount, startPositionX, startPositionY)).execute();
-  }
-
-  async scrollTo(edge) {
-    // override the user's element selection with an extended matcher that looks for UIScrollView children
-    this._selectElementWithMatcher(this._originalMatcher._extendToDescendantScrollViews());
-    return await new ActionInteraction(this._invocationManager, this, new ScrollEdgeAction(edge)).execute();
-  }
-
-  /**
-   * @param {'up' | 'right' | 'down' | 'left'} direction
-   * @param {'slow' | 'fast'} [speed]
-   * @param {number} [normalizedSwipeOffset] - swipe amount relative to the screen width/height
-   * @param {number} [normalizedStartingPointX] - X coordinate of swipe starting point, relative to the view width
-   * @param {number} [normalizedStartingPointY] - Y coordinate of swipe starting point, relative to the view height
-   */
-  async swipe(direction, speed = 'fast', normalizedSwipeOffset = NaN, normalizedStartingPointX = NaN, normalizedStartingPointY = NaN) {
-    normalizedSwipeOffset = Number.isNaN(normalizedSwipeOffset) ? 0.75 : normalizedSwipeOffset;
-
-    // override the user's element selection with an extended matcher that avoids RN issues with RCTScrollView
-    this._selectElementWithMatcher(this._originalMatcher._avoidProblematicReactNativeElements());
-    const action = new SwipeAction(direction, speed, normalizedSwipeOffset, normalizedStartingPointX, normalizedStartingPointY);
-    return await new ActionInteraction(this._invocationManager, this, action).execute();
-  }
-
-  async takeScreenshot(screenshotName) {
-    // TODO this should be moved to a lower-layer handler of this use-case
-    const resultBase64 = await new ActionInteraction(this._invocationManager, this, new TakeElementScreenshot()).execute();
-    const filePath = tempfile('detox.element-screenshot.png');
-    await fs.writeFile(filePath, resultBase64, 'base64');
-
-    await this._emitter.emit('createExternalArtifact', {
-      pluginId: 'screenshot',
-      artifactName: screenshotName || path.basename(filePath, '.png'),
-      artifactPath: filePath,
-    });
-    return filePath;
-  }
-}
-
-class Expect {
-  constructor(invocationManager) {
-    this._invocationManager = invocationManager;
-  }
-
-  get not() {
-    this._notCondition = true;
-    return this;
-  }
-}
-
-class ExpectElement extends Expect {
-  constructor(invocationManager, element) {
-    super(invocationManager);
-    this._element = element;
-  }
-
-  async toBeVisible() {
-    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new VisibleMatcher().not : new VisibleMatcher()).execute();
-  }
-
-  async toBeNotVisible() {
-    return await this.not.toBeVisible();
-  }
-
-  async toExist() {
-    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new ExistsMatcher().not : new ExistsMatcher()).execute();
-  }
-
-  async toNotExist() {
-    return await this.not.toExist();
-  }
-
-  async toHaveText(text) {
-    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new TextMatcher(text).not : new TextMatcher(text)).execute();
-  }
-
-  async toNotHaveText(text) {
-    return await this.not.toHaveText(text);
-  }
-
-  async toHaveLabel(value) {
-    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new LabelMatcher(value).not : new LabelMatcher(value)).execute();
-  }
-
-  async toNotHaveLabel(value) {
-    return await this.not.toHaveLabel(value);
-  }
-
-  async toHaveId(value) {
-    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new IdMatcher(value).not : new IdMatcher(value)).execute();
-  }
-
-  async toNotHaveId(value) {
-    return await this.not.toHaveId(value);
-  }
-
-  async toHaveValue(value) {
-    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new ValueMatcher(value).not : new ValueMatcher(value)).execute();
-  }
-
-  async toNotHaveValue(value) {
-    return await this.not.toHaveValue(value);
-  }
-
-  async toHaveToggleValue(value) {
-    return await new MatcherAssertionInteraction(this._invocationManager, this._element, this._notCondition ? new ToggleMatcher(value).not : new ToggleMatcher(value)).execute()
-  }
-}
-
-class WaitFor {
-  constructor(invocationManager) {
-    this._invocationManager = invocationManager;
-  }
-}
-
-class WaitForElement extends WaitFor {
-  constructor(invocationManager, element) {
-    super(invocationManager);
-    this._element = element;
-  }
-
-  get not() {
-    this._notCondition = true;
-    return this;
-  }
-
-  toBeVisible() {
-    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new VisibleMatcher().not : new VisibleMatcher());
-  }
-
-  toBeNotVisible() {
-    return this.not.toBeVisible();
-  }
-
-  toExist() {
-    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new ExistsMatcher().not : new ExistsMatcher());
-  }
-
-  toNotExist() {
-    return this.not.toExist();
-  }
-
-  toHaveText(text) {
-    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new TextMatcher(text).not : new TextMatcher(text));
-  }
-
-  toNotHaveText(text) {
-    return this.not.toHaveText(text);
-  }
-
-  toHaveLabel(value) {
-    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new LabelMatcher(value).not : new LabelMatcher(value));
-  }
-
-  toNotHaveLabel(value) {
-    return this.not.toHaveLabel(value);
-  }
-
-  toHaveId(value) {
-    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new IdMatcher(value).not : new IdMatcher(value));
-  }
-
-  toNotHaveId(value) {
-    return this.not.toHaveId(value);
-  }
-
-  toHaveValue(value) {
-    return new WaitForInteraction(this._invocationManager, this._element, this._notCondition ? new ValueMatcher(value).not : new ValueMatcher(value));
-  }
-
-  toNotHaveValue(value) {
-    return this.not.toHaveValue(value);
-  }
-}
-
-class AndroidExpect {
-  constructor({ invocationManager, emitter }) {
-    this._invocationManager = invocationManager;
-    this._emitter = emitter;
-
-    this.by = {
-      accessibilityLabel: value => new LabelMatcher(value),
-      label: value => new LabelMatcher(value),
-      id: value => new IdMatcher(value),
-      type: value => new TypeMatcher(value),
-      traits: value => new TraitsMatcher(value),
-      value: value => new ValueMatcher(value),
-      text: value => new TextMatcher(value)
-    };
-
-    this.element = this.element.bind(this);
-    this.expect = this.expect.bind(this);
-    this.waitFor = this.waitFor.bind(this);
-  }
-
-  element(matcher) {
-    return new Element(this._invocationManager, this._emitter, matcher);
-  }
-
-  expect(element) {
-    if (element instanceof Element) return new ExpectElement(this._invocationManager, element);
-    throw new Error(`expect() argument is invalid, got ${typeof element}`);
-  }
-
-  waitFor(element) {
-    if (element instanceof Element) return new WaitForElement(this._invocationManager, element);
-    throw new Error(`waitFor() argument is invalid, got ${typeof element}`);
-  }
-}
-
-module.exports = AndroidExpect;
diff --git a/detox/src/android/interactions/native.js b/detox/src/android/interactions/native.js
new file mode 100644
index 000000000..edd8e3c44
--- /dev/null
+++ b/detox/src/android/interactions/native.js
@@ -0,0 +1,97 @@
+const DetoxAssertionApi = require('../espressoapi/DetoxAssertion');
+const EspressoDetoxApi = require('../espressoapi/EspressoDetox');
+const { NativeMatcher } = require('../core/NativeMatcher');
+const { ScrollAmountStopAtEdgeAction } = require('../actions/native');
+
+function call(maybeAFunction) {
+  return maybeAFunction instanceof Function ? maybeAFunction() : maybeAFunction;
+}
+
+class Interaction {
+  constructor(invocationManager) {
+    this._call = undefined;
+    this._invocationManager = invocationManager;
+  }
+
+  async execute() {
+    const resultObj = await this._invocationManager.execute(this._call);
+    return resultObj ? resultObj.result : undefined;
+  }
+}
+
+class ActionInteraction extends Interaction {
+  constructor(invocationManager, element, action) {
+    super(invocationManager);
+    this._call = EspressoDetoxApi.perform(call(element._call), action._call);
+    // TODO: move this.execute() here from the caller
+  }
+}
+
+class MatcherAssertionInteraction extends Interaction {
+  constructor(invocationManager, element, matcher) {
+    super(invocationManager);
+    this._call = DetoxAssertionApi.assertMatcher(call(element._call), matcher._call.value);
+    // TODO: move this.execute() here from the caller
+  }
+}
+
+class WaitForInteraction extends Interaction {
+  constructor(invocationManager, element, assertionMatcher) {
+    super(invocationManager);
+    this._element = element;
+    this._assertionMatcher = assertionMatcher;
+    this._element._selectElementWithMatcher(this._element._originalMatcher);
+  }
+
+  async withTimeout(timeout) {
+    if (typeof timeout !== 'number') throw new Error(`WaitForInteraction withTimeout argument must be a number, got ${typeof timeout}`);
+    if (timeout < 0) throw new Error('timeout must be larger than 0');
+
+    this._call = DetoxAssertionApi.waitForAssertMatcher(call(this._element._call), this._assertionMatcher._call.value, timeout / 1000);
+    await this.execute();
+  }
+
+  whileElement(searchMatcher) {
+    return new WaitForActionInteraction(this._invocationManager, this._element, this._assertionMatcher, searchMatcher);
+  }
+}
+
+class WaitForActionInteractionBase extends Interaction {
+  constructor(invocationManager, element, matcher, searchMatcher) {
+    super(invocationManager);
+    //if (!(element instanceof NativeElement)) throw new Error(`WaitForActionInteraction ctor 1st argument must be a valid NativeElement, got ${typeof element}`);
+    //if (!(matcher instanceof NativeMatcher)) throw new Error(`WaitForActionInteraction ctor 2nd argument must be a valid NativeMatcher, got ${typeof matcher}`);
+    if (!(searchMatcher instanceof NativeMatcher))
+      throw new Error(`WaitForActionInteraction ctor 3rd argument must be a valid NativeMatcher, got ${typeof searchMatcher}`);
+    this._element = element;
+    this._originalMatcher = matcher;
+    this._searchMatcher = searchMatcher;
+  }
+
+  _prepare(searchAction) {
+    //if (!searchAction instanceof Action) throw new Error(`WaitForActionInteraction _execute argument must be a valid Action, got ${typeof searchAction}`);
+
+    this._call = DetoxAssertionApi.waitForAssertMatcherWithSearchAction(
+      call(this._element._call),
+      call(this._originalMatcher._call).value,
+      call(searchAction._call),
+      call(this._searchMatcher._call).value
+    );
+  }
+}
+
+class WaitForActionInteraction extends WaitForActionInteractionBase {
+  async scroll(amount, direction = 'down', scrollPositionX, scrollPositionY) {
+    this._prepare(new ScrollAmountStopAtEdgeAction(direction, amount, scrollPositionX, scrollPositionY));
+    await this.execute();
+  }
+}
+
+module.exports = {
+  Interaction,
+  ActionInteraction,
+  MatcherAssertionInteraction,
+  WaitForActionInteraction,
+  WaitForActionInteractionBase,
+  WaitForInteraction,
+};
diff --git a/detox/src/android/interactions/web.js b/detox/src/android/interactions/web.js
new file mode 100644
index 000000000..3840c14a8
--- /dev/null
+++ b/detox/src/android/interactions/web.js
@@ -0,0 +1,31 @@
+class WebInteraction {
+  constructor(invocationManager) {
+    this._call = undefined;
+    this._invocationManager = invocationManager;
+  }
+
+  async execute() {
+    const resultObj = await this._invocationManager.execute(this._call);
+    return resultObj ? resultObj.result : undefined;
+  }
+}
+
+class ActionInteraction extends WebInteraction {
+  constructor(invocationManager, action) {
+    super(invocationManager);
+    this._call = action._call;
+  }
+}
+
+class WebAssertionInteraction extends WebInteraction {
+  constructor(invocationManager, assertion) {
+    super(invocationManager);
+    this._call = assertion._call;
+  }
+}
+
+module.exports = {
+  WebInteraction,
+  ActionInteraction,
+  WebAssertionInteraction,
+};
diff --git a/detox/src/android/matcher.js b/detox/src/android/matcher.js
deleted file mode 100644
index b12ef44e5..000000000
--- a/detox/src/android/matcher.js
+++ /dev/null
@@ -1,124 +0,0 @@
-const invoke = require('../invoke');
-const DetoxMatcherApi = require('./espressoapi/DetoxMatcher');
-
-class Matcher {
-  withAncestor(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherWithAncestor(this, matcher));
-    return this;
-  }
-
-  withDescendant(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherWithDescendant(this, matcher));
-    return this;
-  }
-
-  and(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForAnd(this, matcher));
-    return this;
-  }
-
-  or(matcher) {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForOr(this, matcher));
-    return this;
-  }
-
-  get not() {
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForNot(this));
-    return this;
-  }
-
-  _avoidProblematicReactNativeElements() {
-    /*
-    const _originalMatcherCall = this._call;
-    this._call = invoke.call(invoke.IOS.Class('GREYMatchers'), 'detoxMatcherAvoidingProblematicReactNativeElements:', _originalMatcherCall);
-    */
-    return this;
-  }
-  _extendToDescendantScrollViews() {
-    /*
-    const _originalMatcherCall = this._call;
-    this._call = invoke.call(invoke.IOS.Class('GREYMatchers'), 'detoxMatcherForScrollChildOfMatcher:', _originalMatcherCall);
-    */
-    return this;
-  }
-}
-
-class LabelMatcher extends Matcher {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForContentDescription(value));
-  }
-}
-
-class IdMatcher extends Matcher {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForTestId(value));
-  }
-}
-
-class TypeMatcher extends Matcher {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForClass(value));
-  }
-}
-
-class VisibleMatcher extends Matcher {
-  constructor() {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForSufficientlyVisible());
-  }
-}
-
-class ExistsMatcher extends Matcher {
-  constructor() {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForNotNull());
-  }
-}
-
-class TextMatcher extends Matcher {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForText(value));
-  }
-}
-
-class ValueMatcher extends Matcher {
-  constructor(value) {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForContentDescription(value));
-  }
-}
-
-class ToggleMatcher extends Matcher {
-  constructor(toggleState) {
-    super();
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForToggleable(toggleState));
-  }
-}
-
-// TODO
-// Please be aware, that this is just a dummy matcher
-class TraitsMatcher extends Matcher {
-  constructor(value) {
-    super();
-    if ((typeof value !== 'object') || (!value instanceof Array)) throw new Error(`TraitsMatcher ctor argument must be an array, got ${typeof value}`);
-
-    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForAnything());
-  }
-}
-
-module.exports = {
-  Matcher,
-  LabelMatcher,
-  IdMatcher,
-  TypeMatcher,
-  TraitsMatcher,
-  VisibleMatcher,
-  ExistsMatcher,
-  TextMatcher,
-  ValueMatcher,
-  ToggleMatcher,
-};
diff --git a/detox/src/android/matchers/index.js b/detox/src/android/matchers/index.js
new file mode 100644
index 000000000..082cfd87d
--- /dev/null
+++ b/detox/src/android/matchers/index.js
@@ -0,0 +1,23 @@
+const native = require('./native');
+const web = require('./web');
+
+module.exports = {
+  accessibilityLabel: (value) => new native.LabelMatcher(value),
+  id: (value) => new native.IdMatcher(value),
+  label: (value) => new native.LabelMatcher(value),
+  text: (value) => new native.TextMatcher(value),
+  traits: (value) => new native.TraitsMatcher(value),
+  type: (value) => new native.TypeMatcher(value),
+  value: (value) => new native.ValueMatcher(value),
+
+  html: {
+    id: (value) => new web.IdMatcher(value),
+    className: (value) => new web.ClassNameMatcher(value),
+    selector: (value) => new web.CssSelectorMatcher(value),
+    name: (value) => new web.NameMatcher(value),
+    tag: (value) => new web.TagNameMatcher(value),
+    xpath: (value) => new web.XPathMatcher(value),
+    href: (value) => new web.LinkTextMatcher(value),
+    hrefContains: (value) => new web.PartialLinkTextMatcher(value),
+  },
+};
diff --git a/detox/src/android/matchers/native.js b/detox/src/android/matchers/native.js
new file mode 100644
index 000000000..9a8835211
--- /dev/null
+++ b/detox/src/android/matchers/native.js
@@ -0,0 +1,82 @@
+const invoke = require('../../invoke');
+const DetoxMatcherApi = require('../espressoapi/DetoxMatcher');
+const { NativeMatcher } = require('../core/NativeMatcher');
+
+class LabelMatcher extends NativeMatcher {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForContentDescription(value));
+  }
+}
+
+class IdMatcher extends NativeMatcher {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForTestId(value));
+  }
+}
+
+class TypeMatcher extends NativeMatcher {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForClass(value));
+  }
+}
+
+class VisibleMatcher extends NativeMatcher {
+  constructor() {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForSufficientlyVisible());
+  }
+}
+
+class ExistsMatcher extends NativeMatcher {
+  constructor() {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForNotNull());
+  }
+}
+
+class TextMatcher extends NativeMatcher {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForText(value));
+  }
+}
+
+class ValueMatcher extends NativeMatcher {
+  constructor(value) {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForContentDescription(value));
+  }
+}
+
+class ToggleMatcher extends NativeMatcher {
+  constructor(toggleState) {
+    super();
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForToggleable(toggleState));
+  }
+}
+
+// TODO
+// Please be aware, that this is just a dummy matcher
+class TraitsMatcher extends NativeMatcher {
+  constructor(value) {
+    super();
+    if ((typeof value !== 'object') || (!value instanceof Array)) throw new Error(`TraitsMatcher ctor argument must be an array, got ${typeof value}`);
+
+    this._call = invoke.callDirectly(DetoxMatcherApi.matcherForAnything());
+  }
+}
+
+module.exports = {
+  LabelMatcher,
+  IdMatcher,
+  TypeMatcher,
+  TraitsMatcher,
+  VisibleMatcher,
+  ExistsMatcher,
+  TextMatcher,
+  ValueMatcher,
+  ToggleMatcher,
+};
diff --git a/detox/src/android/matchers/web.js b/detox/src/android/matchers/web.js
new file mode 100644
index 000000000..b22f794e3
--- /dev/null
+++ b/detox/src/android/matchers/web.js
@@ -0,0 +1,70 @@
+const invoke = require('../../invoke');
+const DetoxWebMatcherApi = require('../espressoapi/web/DetoxWebAtomMatcher');
+const { WebMatcher } = require('../core/WebMatcher');
+
+class IdMatcher extends WebMatcher {
+  constructor(id) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForId(id));
+  }
+}
+
+class ClassNameMatcher extends WebMatcher {
+  constructor(className) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForClassName(className));
+  }
+}
+
+class CssSelectorMatcher extends WebMatcher {
+  constructor(cssSelector) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForCssSelector(cssSelector));
+  }
+}
+
+class NameMatcher extends WebMatcher {
+  constructor(name) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForName(name));
+  }
+}
+
+class XPathMatcher extends WebMatcher {
+  constructor(xpath) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForXPath(xpath));
+  }
+}
+
+class LinkTextMatcher extends WebMatcher {
+  constructor(linkText) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForLinkText(linkText));
+  }
+}
+
+class PartialLinkTextMatcher extends WebMatcher {
+  constructor(partialLinkText) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForPartialLinkText(partialLinkText));
+  }
+}
+
+class TagNameMatcher extends WebMatcher {
+  constructor(tag) {
+    super();
+    this._call = invoke.callDirectly(DetoxWebMatcherApi.matcherForTagName(tag));
+  }
+}
+
+module.exports = {
+  IdMatcher,
+  ClassNameMatcher,
+  CssSelectorMatcher,
+  NameMatcher,
+  XPathMatcher,
+  LinkTextMatcher,
+  PartialLinkTextMatcher,
+  TagNameMatcher
+};
diff --git a/detox/src/devices/Device.js b/detox/src/devices/Device.js
index f49e6c328..193d58044 100644
--- a/detox/src/devices/Device.js
+++ b/detox/src/devices/Device.js
@@ -265,6 +265,13 @@ class Device {
     await this.deviceDriver.resetStatusBar(this._deviceId);
   }
 
+  /**
+   * @internal
+   */
+  async _typeText(text) {
+    await this.deviceDriver.typeText(this._deviceId, text);
+  }
+
   get _bundleId() {
     return this._getCurrentApp().bundleId;
   }
diff --git a/detox/src/devices/drivers/DeviceDriverBase.js b/detox/src/devices/drivers/DeviceDriverBase.js
index b500dcaf8..c7504ce44 100644
--- a/detox/src/devices/drivers/DeviceDriverBase.js
+++ b/detox/src/devices/drivers/DeviceDriverBase.js
@@ -197,6 +197,10 @@ class DeviceDriverBase {
     return await Promise.resolve('');
   }
 
+  async typeText(deviceId, text) {
+    return await Promise.resolve('');
+  }
+
   async setStatusBar(deviceId, flags) {
   }
 
diff --git a/detox/src/devices/drivers/android/AndroidDriver.js b/detox/src/devices/drivers/android/AndroidDriver.js
index 80a6bf558..5fa52e73b 100644
--- a/detox/src/devices/drivers/android/AndroidDriver.js
+++ b/detox/src/devices/drivers/android/AndroidDriver.js
@@ -160,6 +160,10 @@ class AndroidDriver extends DeviceDriverBase {
     await this.uiDevice.pressHome();
   }
 
+  async typeText(deviceId, text) {
+    await this.adb.typeText(deviceId, text);
+  }
+
   async terminate(deviceId, bundleId) {
     const adbName = this._getAdbName(deviceId);
     await this.emitter.emit('beforeTerminateApp', { deviceId: adbName, bundleId });
diff --git a/detox/src/devices/drivers/android/exec/ADB.js b/detox/src/devices/drivers/android/exec/ADB.js
index 416d6ae70..e6cdeb9db 100644
--- a/detox/src/devices/drivers/android/exec/ADB.js
+++ b/detox/src/devices/drivers/android/exec/ADB.js
@@ -60,6 +60,11 @@ class ADB {
     await this._sendKeyEvent(deviceId, 'KEYCODE_POWER');
   }
 
+  async typeText(deviceId, text) {
+    const actualText = text.replace(/ /g, '%s');
+    await this.shell(deviceId, `input text ${actualText}`);
+  }
+
   async _sendKeyEvent(deviceId, keyevent) {
     await this.shell(deviceId, `input keyevent ${keyevent}`);
   }
diff --git a/detox/src/invoke.js b/detox/src/invoke.js
index 681c07237..cee4d1263 100644
--- a/detox/src/invoke.js
+++ b/detox/src/invoke.js
@@ -1,6 +1,7 @@
 const Invoke = require('./invoke/Invoke');
 const EarlGrey = require('./invoke/EarlGrey');
 const Espresso = require('./invoke/Espresso');
+const EspressoWeb = require('./invoke/EspressoWeb');
 
 class InvocationManager {
   constructor(excutionHandler) {
@@ -16,6 +17,7 @@ module.exports = {
   InvocationManager,
   EarlGrey,
   Espresso: Espresso.target,
+  EspressoWeb: EspressoWeb.target,
   IOS: Invoke.genericInvokeObject,
   Android: Invoke.genericInvokeObject,
   call: Invoke.call,
diff --git a/detox/src/invoke/EspressoWeb.js b/detox/src/invoke/EspressoWeb.js
new file mode 100644
index 000000000..72480fd3a
--- /dev/null
+++ b/detox/src/invoke/EspressoWeb.js
@@ -0,0 +1,8 @@
+const target = {
+  type: 'Class',
+  value: 'androidx.test.espresso.web.sugar.Web'
+};
+
+module.exports = {
+  target
+};
diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js
index 60c102006..165aee5a6 100644
--- a/detox/src/ios/expectTwo.js
+++ b/detox/src/ios/expectTwo.js
@@ -300,6 +300,10 @@ class By {
   value(value) {
     return new Matcher().value(value);
   }
+
+  get html() {
+    throw new Error('Detox does not support by.html matchers on iOS.');
+  }
 }
 
 class Matcher {
@@ -596,6 +600,7 @@ class IosExpect {
     this.expect = this.expect.bind(this);
     this.waitFor = this.waitFor.bind(this);
     this.by = new By();
+    this.web.element = this.web;
   }
 
   element(matcher) {
@@ -609,6 +614,10 @@ class IosExpect {
   waitFor(element) {
     return waitFor(this._invocationManager, element);
   }
+
+  web(_matcher) {
+    throw new Error('Detox does not support web(), web.element() API on iOS.');
+  }
 }
 
 function throwMatcherError(param) {
diff --git a/detox/src/matchersRegistry.js b/detox/src/matchersRegistry.js
index ab9877b14..c9078ea3d 100644
--- a/detox/src/matchersRegistry.js
+++ b/detox/src/matchersRegistry.js
@@ -3,7 +3,7 @@ const resolveModuleFromPath = require('./utils/resolveModuleFromPath');
 function resolve(device, opts) {
   let MatcherClass;
   switch (device.getPlatform()) {
-    case 'android': MatcherClass = require('./android/expect'); break;
+    case 'android': MatcherClass = require('./android/AndroidExpect'); break;
     case 'ios': MatcherClass = require('./ios/expectTwo'); break;
     default: MatcherClass = resolveModuleFromPath(device.type).ExpectClass; break;
   }
diff --git a/detox/src/utils/MissingDetox.js b/detox/src/utils/MissingDetox.js
index 23c097baa..a7ec84514 100644
--- a/detox/src/utils/MissingDetox.js
+++ b/detox/src/utils/MissingDetox.js
@@ -17,6 +17,7 @@ class MissingDetox {
     this._defineRequiredProperty(context, 'element', this.throwError, readonly);
     this._defineRequiredProperty(context, 'expect', this.throwError, readonly);
     this._defineRequiredProperty(context, 'waitFor', this.throwError, readonly);
+    this._defineRequiredProperty(context, 'web', undefined, readonly);
   }
 
   cleanupContext(context) {
diff --git a/generation/adapters/android.js b/generation/adapters/android.js
index 367c84ec7..90e46aa5f 100644
--- a/generation/adapters/android.js
+++ b/generation/adapters/android.js
@@ -5,13 +5,16 @@ const { isNumber, isString, isBoolean, isArray, isDefined } = require('../core/t
 
 const typeCheckInterfaces = {
   'ArrayList<String>': isArray,
+  'ArrayList<Object>': isArray,
   'Matcher<View>': null, // isOfClass('Matcher') would be better,
   boolean: isBoolean,
   Double: isNumber,
   Integer: isNumber,
   String: isString,
   ViewAction: null, // there are optional view actions
-  ViewInteraction: null // there are optional view actions
+  ViewInteraction: null,
+  WebElement: null,
+  'Atom<List<ElementReference>>': null
 };
 
 const contentSanitizersForFunction = {
@@ -60,6 +63,7 @@ module.exports = generator({
   contentSanitizersForType,
   supportedTypes: [
     'ArrayList<String>',
+    'ArrayList<Object>',
     'boolean',
     'double',
     'Double',
@@ -68,12 +72,17 @@ module.exports = generator({
     'Matcher<View>',
     'String',
     'ViewAction',
-    'ViewInteraction'
+    'ViewInteraction',
+    'WebElement',
+    'Atom<List<ElementReference>>'
   ],
   renameTypesMap: {
     int: 'Integer', // TODO: add test
     double: 'Double',
-    ViewInteraction: 'Invocation'
+    ViewInteraction: 'Invocation',
+    WebInteraction: 'Invocation',
+    WebElement: 'Invocation',
+    'Atom<List<ElementReference>>': 'Invocation'
   },
   classValue: ({ package: pkg, name }) => `${pkg}.${name}`
 });
diff --git a/generation/index.js b/generation/index.js
index 171d5c605..98e17c98a 100755
--- a/generation/index.js
+++ b/generation/index.js
@@ -44,7 +44,12 @@ function createAndroidFiles() {
     '../detox/android/detox/src/main/java/com/wix/detox/espresso/DetoxMatcher.java': '../detox/src/android/espressoapi/DetoxMatcher.js',
     '../detox/android/detox/src/main/java/com/wix/detox/Detox.java': '../detox/src/android/espressoapi/Detox.js',
     '../detox/android/detox/src/main/java/com/wix/detox/espresso/EspressoDetox.java': '../detox/src/android/espressoapi/EspressoDetox.js',
-    '../detox/android/detox/src/main/java/com/wix/detox/uiautomator/UiAutomator.java': '../detox/src/android/espressoapi/UIAutomator.js'
+    '../detox/android/detox/src/main/java/com/wix/detox/uiautomator/UiAutomator.java': '../detox/src/android/espressoapi/UIAutomator.js',
+    '../detox/android/detox/src/main/java/com/wix/detox/espresso/web/EspressoWebDetox.java': '../detox/src/android/espressoapi/web/EspressoWebDetox.js',
+    '../detox/android/detox/src/main/java/com/wix/detox/espresso/web/DetoxWebAtomMatcher.java': '../detox/src/android/espressoapi/web/DetoxWebAtomMatcher.js',
+    '../detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebViewElement.java': '../detox/src/android/espressoapi/web/WebViewElement.js',
+    '../detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebElement.java': '../detox/src/android/espressoapi/web/WebElement.js',
+    '../detox/android/detox/src/main/java/com/wix/detox/espresso/web/WebExpect.java': '../detox/src/android/espressoapi/web/WebExpect.js'
   };
 }
 


Test patch:
diff --git a/detox/android/detox/src/test/java/com/wix/invoke/JsonParserTest.java b/detox/android/detox/src/test/java/com/wix/invoke/JsonParserTest.java
index 263d153b1..19c8ebd68 100644
--- a/detox/android/detox/src/test/java/com/wix/invoke/JsonParserTest.java
+++ b/detox/android/detox/src/test/java/com/wix/invoke/JsonParserTest.java
@@ -62,6 +62,17 @@ public class JsonParserTest {
         assertThat(parse("targetInvocationEspressoDetox.json")).isEqualToComparingFieldByFieldRecursively(perform);
     }
 
+    @Test
+    public void fromJsonTargetInvocationEspressoWebDetox() {
+//        EspressoWebDetox.getWebView().element(DetoxWebAtomMatcher.matcherForId("textInput")).tap();
+
+        Invocation getWebView = new Invocation(new ClassTarget("com.wix.detox.espresso.web.EspressoWebDetox"), "getWebView");
+        Invocation matcher = new Invocation(new ClassTarget("com.wix.detox.espresso.web.DetoxWebAtomMatcher"), "matcherForId", "textInput");
+        Invocation element = new Invocation(new InvocationTarget(getWebView), "element", matcher, 0);
+        Invocation tap = new Invocation(new InvocationTarget(element), "tap");
+        assertThat(parse("targetInvocationEspressoWebDetox.json")).usingRecursiveComparison().isEqualTo(tap);
+    }
+
     @Test
     public void fromJsonTargetInvocationWithListParams() {
         ArrayList<String> params = new ArrayList<>();
diff --git a/detox/src/Detox.test.js b/detox/src/Detox.test.js
index 2c906dbf4..b56ceae0a 100644
--- a/detox/src/Detox.test.js
+++ b/detox/src/Detox.test.js
@@ -120,6 +120,7 @@ describe('Detox', () => {
         const { emitter } = Device.mock.calls[0][0];
         expect(matchersRegistry.resolve).toHaveBeenCalledWith(device(), {
           invocationManager: invocationManager(),
+          device: device(),
           emitter,
         });
       });
diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js
new file mode 100644
index 000000000..965d9f68e
--- /dev/null
+++ b/detox/src/android/AndroidExpect.test.js
@@ -0,0 +1,602 @@
+describe('AndroidExpect', () => {
+  let e;
+
+  let mockExecutor;
+  let emitter;
+  let device;
+
+  beforeEach(() => {
+    jest.mock('tempfile');
+    jest.mock('fs-extra');
+
+    mockExecutor = new MockExecutor();
+
+    const Emitter = jest.genMockFromModule('../utils/AsyncEmitter');
+    emitter = new Emitter();
+
+    device = {
+      _typeText: jest.fn(),
+    };
+
+    const AndroidExpect = require('./AndroidExpect');
+    e = new AndroidExpect({
+      invocationManager: mockExecutor,
+      device,
+      emitter,
+    });
+  });
+
+  describe('native', () => {
+    it(`element by accessibilityLabel`, async () => {
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toBeVisible();
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toBeNotVisible();
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toBeVisible();
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toExist();
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotExist();
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toExist();
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveText('text');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveText('text');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveText('text');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveLabel('label');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveLabel('label');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveLabel('label');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveId('id');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveId('id');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveId('id');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveValue('value');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveValue('value');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveValue('value');
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveToggleValue(true);
+      await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveToggleValue(true);
+    });
+
+    it(`element by label (for backwards compat)`, async () => {
+      await e.expect(e.element(e.by.label('test'))).toBeVisible();
+      await e.expect(e.element(e.by.label('test'))).toBeNotVisible();
+      await e.expect(e.element(e.by.label('test'))).toExist();
+      await e.expect(e.element(e.by.label('test'))).toNotExist();
+      await e.expect(e.element(e.by.label('test'))).toHaveText('text');
+      await e.expect(e.element(e.by.label('test'))).toHaveLabel('label');
+      await e.expect(e.element(e.by.label('test'))).toHaveId('id');
+      await e.expect(e.element(e.by.label('test'))).toHaveValue('value');
+      await e.expect(e.element(e.by.label('test'))).toHaveToggleValue(false);
+      await e.expect(e.element(e.by.label('test'))).not.toHaveToggleValue(false);
+    });
+
+    it(`element by id`, async () => {
+      await e.expect(e.element(e.by.id('test'))).toBeVisible();
+    });
+
+    it(`element by type`, async () => {
+      await e.expect(e.element(e.by.type('test'))).toBeVisible();
+    });
+
+    it(`element by traits`, async () => {
+      await e.expect(e.element(e.by.traits(['button', 'link', 'header', 'search']))).toBeVisible();
+      await e.expect(e.element(e.by.traits(['image', 'selected', 'plays', 'key']))).toBeNotVisible();
+      await e.expect(e.element(e.by.traits(['text', 'summary', 'disabled', 'frequentUpdates']))).toBeNotVisible();
+      await e.expect(e.element(e.by.traits(['startsMedia', 'adjustable', 'allowsDirectInteraction', 'pageTurn']))).toBeNotVisible();
+    });
+
+    it(`matcher helpers`, async () => {
+      await e.expect(e.element(e.by.id('test').withAncestor(e.by.id('ancestor')))).toBeVisible();
+      await e.expect(e.element(e.by.id('test').withDescendant(e.by.id('descendant')))).toBeVisible();
+      await e.expect(e.element(e.by.id('test').and(e.by.type('type')))).toBeVisible();
+      await e.expect(e.element(e.by.id('test'))).not.toBeVisible();
+      await e.expect(e.element(e.by.id('test').or(e.by.id('test2')))).toBeVisible();
+    });
+
+    it(`expect with wrong parameters should throw`, async () => {
+      await expectToThrow(() => e.expect('notAnElement'));
+      await expectToThrow(() => e.expect(e.element('notAMatcher')));
+    });
+
+    it(`matchers with wrong parameters should throw`, async () => {
+      await expectToThrow(() => e.element(e.by.label(5)));
+      await expectToThrow(() => e.element(e.by.accessibilityLabel(5)));
+      await expectToThrow(() => e.element(e.by.id(5)));
+      await expectToThrow(() => e.element(e.by.type(0)));
+      await expectToThrow(() => e.by.traits(1));
+      await expectToThrow(() => e.by.traits(['nonExistentTrait']));
+      await expectToThrow(() => e.element(e.by.value(0)));
+      await expectToThrow(() => e.element(e.by.text(0)));
+      await expectToThrow(() => e.element(e.by.id('test').withAncestor('notAMatcher')));
+      await expectToThrow(() => e.element(e.by.id('test').withDescendant('notAMatcher')));
+      await expectToThrow(() => e.element(e.by.id('test').and('notAMatcher')));
+      await expectToThrow(() => e.element(e.by.id('test').or('notAMatcher')));
+    });
+
+    it(`waitFor (element)`, async () => {
+      await e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(0);
+      await e.waitFor(e.element(e.by.id('id'))).not.toExist().withTimeout(0);
+      await e.waitFor(e.element(e.by.id('id'))).toBeVisible();
+      await e.waitFor(e.element(e.by.id('id'))).toBeNotVisible();
+      await e.waitFor(e.element(e.by.id('id'))).toExist();
+      await e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(0);
+      await e.waitFor(e.element(e.by.id('id'))).toNotExist().withTimeout(0);
+
+      await e.waitFor(e.element(e.by.id('id'))).toHaveText('text');
+      await e.waitFor(e.element(e.by.id('id'))).toNotHaveText('value');
+      await e.waitFor(e.element(e.by.id('id'))).not.toHaveText('text');
+
+      await e.waitFor(e.element(e.by.id('id'))).toHaveLabel('text');
+      await e.waitFor(e.element(e.by.id('id'))).toNotHaveLabel('text');
+      await e.waitFor(e.element(e.by.id('id'))).not.toHaveLabel('text');
+
+      await e.waitFor(e.element(e.by.id('id'))).toHaveId('id');
+      await e.waitFor(e.element(e.by.id('id'))).toNotHaveId('id');
+      await e.waitFor(e.element(e.by.id('id'))).not.toHaveId('id');
+
+      await e.waitFor(e.element(e.by.id('id'))).toHaveValue('value');
+      await e.waitFor(e.element(e.by.id('id'))).toNotHaveValue('value');
+      await e.waitFor(e.element(e.by.id('id'))).not.toHaveValue('value');
+
+      await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scroll(50, 'down');
+      await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scroll(50);
+
+    });
+
+    it(`waitFor (element) with wrong parameters should throw`, async () => {
+      await expectToThrow(() => e.waitFor('notAnElement'));
+      await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout('notANumber'));
+      await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(-1));
+      await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement('notAnElement'));
+    });
+
+    it(`waitFor (element) with non-elements should throw`, async () => {
+      await expectToThrow(() => e.waitFor('notAnElement').toBeVisible());
+    });
+
+    describe('element interactions', () => {
+      it('should tap and long-press', async () => {
+        await e.element(e.by.label('Tap Me')).tap();
+        await e.element(e.by.label('Tap Me')).tap({ x: 10, y: 10 });
+        await e.element(e.by.label('Tap Me')).tapAtPoint({x: 100, y: 200});
+        await e.element(e.by.label('Tap Me')).longPress();
+        await e.element(e.by.id('UniqueId819')).multiTap(3);
+      });
+
+      it('should not tap and long-press given bad args', async () => {
+        await expectToThrow(() => e.element(e.by.id('UniqueId819')).multiTap('NaN'));
+      });
+
+      it('should press special keys', async () => {
+        await e.element(e.by.id('UniqueId937')).tapBackspaceKey();
+        await e.element(e.by.id('UniqueId937')).tapReturnKey();
+      });
+
+      it('should edit text', async () => {
+        await e.element(e.by.id('UniqueId937')).typeText('passcode');
+        await e.element(e.by.id('UniqueId005')).clearText();
+        await e.element(e.by.id('UniqueId005')).replaceText('replaceTo');
+      });
+
+      it('should not edit text given bad args', async () => {
+        await expectToThrow(() => e.element(e.by.id('UniqueId937')).typeText(0));
+        await expectToThrow(() => e.element(e.by.id('UniqueId005')).replaceText(3));
+      });
+
+      it('should scroll', async () => {
+        await e.element(e.by.id('ScrollView161')).scroll(100);
+        await e.element(e.by.id('ScrollView161')).scroll(100, 'down');
+        await e.element(e.by.id('ScrollView161')).scroll(100, 'up');
+        await e.element(e.by.id('ScrollView161')).scroll(100, 'right');
+        await e.element(e.by.id('ScrollView161')).scroll(100, 'left');
+        await e.element(e.by.id('ScrollView161')).scrollTo('bottom');
+        await e.element(e.by.id('ScrollView161')).scrollTo('top');
+        await e.element(e.by.id('ScrollView161')).scrollTo('left');
+        await e.element(e.by.id('ScrollView161')).scrollTo('right');
+      });
+
+      it('should not scroll given bad args', async () => {
+        await expectToThrow(() => e.element(e.by.id('ScrollView161')).scroll('NaN', 'down'));
+        await expectToThrow(() => e.element(e.by.id('ScrollView161')).scroll(100, 'noDirection'));
+        await expectToThrow(() => e.element(e.by.id('ScrollView161')).scroll(100, 0));
+        await expectToThrow(() => e.element(e.by.id('ScrollView161')).scrollTo(0));
+        await expectToThrow(() => e.element(e.by.id('ScrollView161')).scrollTo('noDirection'));
+      });
+
+      it('should swipe', async () => {
+        await e.element(e.by.id('ScrollView799')).swipe('down');
+        await e.element(e.by.id('ScrollView799')).swipe('down', 'fast');
+        await e.element(e.by.id('ScrollView799')).swipe('up', 'slow');
+        await e.element(e.by.id('ScrollView799')).swipe('left', 'fast');
+        await e.element(e.by.id('ScrollView799')).swipe('right', 'slow');
+        await e.element(e.by.id('ScrollView799')).swipe('down', 'fast', 0.9);
+        await e.element(e.by.id('ScrollView799')).swipe('up', 'slow', 0.9);
+        await e.element(e.by.id('ScrollView799')).swipe('left', 'fast', 0.9);
+        await e.element(e.by.id('ScrollView799')).swipe('right', 'slow', 0.9);
+        await e.element(e.by.id('ScrollView799')).swipe('down', 'fast', undefined, undefined, 0.25);
+        await e.element(e.by.id('ScrollView799')).swipe('up', 'slow', 0.9, 0.5, 0.5);
+      });
+
+      it('should not swipe given bad args', async () => {
+        await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe(4, 'fast'));
+        await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 0));
+        await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 'fast'));
+        await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow'));
+        await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow', 0.9));
+      });
+
+      it('should allow for index-based element discrepancy resolution', async () => {
+        await e.element(e.by.id('ScrollView799')).atIndex(1);
+      });
+
+      it('should fail to find index-based element given invalid args', async () => {
+        await expectToThrow(() => e.element(e.by.id('ScrollView799')).atIndex('NaN'));
+      });
+    });
+
+    describe('element screenshots', () => {
+      const tempFilePath = '/path/to/temp-file.png';
+      const invokeResultInBase64 = 'VGhlcmUgaXMgbm8gc3Bvb24h';
+
+      let tempfile;
+      let fs;
+      let _element;
+      beforeEach(() => {
+        mockExecutor.executeResult = Promise.resolve(invokeResultInBase64);
+
+        fs = require('fs-extra');
+        tempfile = require('tempfile');
+        tempfile.mockReturnValue(tempFilePath);
+
+        _element = e.element(e.by.id('FancyElement'));
+      });
+
+      it('should take and save the screenshot in a temp-file', async () => {
+        await _element.takeScreenshot();
+        expect(fs.writeFile).toHaveBeenCalledWith(tempFilePath, invokeResultInBase64, 'base64');
+        expect(tempfile).toHaveBeenCalledWith('detox.element-screenshot.png');
+      });
+
+      it('should return the path to the temp-file containing screenshot data', async () => {
+        const result = await _element.takeScreenshot();
+        expect(result).toEqual(tempFilePath);
+      });
+
+      it('should emit a named-artifact creation event', async () => {
+        const screenshotName = 'mock-screenshot-name';
+        await _element.takeScreenshot(screenshotName);
+        expect(emitter.emit).toHaveBeenCalledWith('createExternalArtifact', {
+          pluginId: 'screenshot',
+          artifactName: screenshotName,
+          artifactPath: tempFilePath,
+        });
+      });
+
+      it('should emit an artifact creation event with a default name', async () => {
+        await _element.takeScreenshot(undefined);
+        expect(emitter.emit).toHaveBeenCalledWith(
+          expect.any(String),
+          expect.objectContaining({
+              artifactName: 'temp-file',
+            },
+          ));
+      });
+    });
+  });
+
+  describe('web', () => {
+    describe('General', () => {
+      it('should return undefined', async () => {
+        mockExecutor.executeResult = Promise.resolve(undefined);
+        await e.web(e.by.id('webview_id')).element(e.by.html.id('any')).tap();
+      });
+    })
+
+    describe('web', () => {
+
+      it('default', async () => {
+        await e.web.element(e.by.html.id('id')).tap();
+      });
+
+      it('default explicit', async () => {
+        await e.web().element(e.by.html.id('id')).tap();
+      });
+
+      it('with webview matcher', async () => {
+        await e.web(e.by.id('webview_id')).element(e.by.html.id('id')).tap();
+      });
+
+      it(`with wrong matcher should throw`, async () => {
+        await expectToThrow(() => e.web(e.by.html.className('webMatcher')));
+        await expectToThrow(() => e.web(e.by.html.selector('webMatcher')));
+        await expectToThrow(() => e.web(e.by.html.id('webMatcher')));
+        await expectToThrow(() => e.web(e.by.html.href('webMatcher')));
+        await expectToThrow(() => e.web(e.by.html.name('webMatcher')));
+        await expectToThrow(() => e.web(e.by.html.hrefContains('webMatcher')));
+        await expectToThrow(() => e.web(e.by.html.tag('webMatcher')));
+        await expectToThrow(() => e.web(e.by.html.xpath('webMatcher')));
+      });
+
+      it(`inner element with wrong matcher should throw`, async () => {
+        await expectToThrow(() => e.web.element(e.by.accessibilityLabel('nativeMatcher')));
+        await expectToThrow(() => e.web.element(e.by.id('nativeMatcher')));
+        await expectToThrow(() => e.web.element(e.by.label('nativeMatcher')));
+        await expectToThrow(() => e.web.element(e.by.text('nativeMatcher')));
+        await expectToThrow(() => e.web.element(e.by.traits('nativeMatcher')));
+        await expectToThrow(() => e.web.element(e.by.value('nativeMatcher')));
+      });
+    });
+
+    describe('WebElement Actions', () => {
+      it('tap', async () => {
+        await e.web.element(e.by.html.id('id')).tap();
+        await e.web.element(e.by.html.className('className')).tap();
+        await e.web.element(e.by.html.selector('cssSelector')).tap();
+        await e.web.element(e.by.html.name('name')).tap();
+        await e.web.element(e.by.html.xpath('xpath')).tap();
+        await e.web.element(e.by.html.href('linkText')).tap();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).tap();
+        await e.web.element(e.by.html.tag('tag')).tap();
+      });
+
+      it('typeText', async () => {
+        await e.web.element(e.by.html.id('id')).typeText('text');
+        await e.web.element(e.by.html.className('className')).typeText('text');
+        await e.web.element(e.by.html.selector('cssSelector')).typeText('text');
+        await e.web.element(e.by.html.name('name')).typeText('text');
+        await e.web.element(e.by.html.xpath('xpath')).typeText('text');
+        await e.web.element(e.by.html.href('linkText')).typeText('text');
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).typeText('text');
+        await e.web.element(e.by.html.tag('tag')).typeText('text');
+      });
+
+      it('typeText with isContentEditable=false', async () => {
+        await e.web.element(e.by.html.id('id')).typeText('text', false);
+        global.expect(device._typeText).not.toHaveBeenCalled();
+      });
+
+      it('typeText with isContentEditable=true', async () => {
+        await e.web.element(e.by.html.id('id')).typeText('text', true);
+        global.expect(device._typeText).toHaveBeenCalled();
+      });
+
+      it('typeText default isContentEditable is false', async () => {
+        await e.web.element(e.by.html.id('id')).typeText('text');
+        global.expect(device._typeText).not.toHaveBeenCalled();
+      });
+
+      it('replaceText', async () => {
+        await e.web.element(e.by.html.id('id')).replaceText('text');
+        await e.web.element(e.by.html.className('className')).replaceText('text');
+        await e.web.element(e.by.html.selector('cssSelector')).replaceText('text');
+        await e.web.element(e.by.html.name('name')).replaceText('text');
+        await e.web.element(e.by.html.xpath('xpath')).replaceText('text');
+        await e.web.element(e.by.html.href('linkText')).replaceText('text');
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).replaceText('text');
+        await e.web.element(e.by.html.tag('tag')).replaceText('text');
+      });
+
+      it('clearText', async () => {
+        await e.web.element(e.by.html.id('id')).clearText();
+        await e.web.element(e.by.html.className('className')).clearText();
+        await e.web.element(e.by.html.selector('cssSelector')).clearText();
+        await e.web.element(e.by.html.name('name')).clearText();
+        await e.web.element(e.by.html.xpath('xpath')).clearText();
+        await e.web.element(e.by.html.href('linkText')).clearText();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).clearText();
+        await e.web.element(e.by.html.tag('tag')).clearText();
+      });
+
+      it('scrollToView', async () => {
+        await e.web.element(e.by.html.id('id')).scrollToView();
+        await e.web.element(e.by.html.className('className')).scrollToView();
+        await e.web.element(e.by.html.selector('cssSelector')).scrollToView();
+        await e.web.element(e.by.html.name('name')).scrollToView();
+        await e.web.element(e.by.html.xpath('xpath')).scrollToView();
+        await e.web.element(e.by.html.href('linkText')).scrollToView();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).scrollToView();
+        await e.web.element(e.by.html.tag('tag')).scrollToView();
+      });
+
+      it('getText', async () => {
+        await e.web.element(e.by.html.id('id')).getText();
+        await e.web.element(e.by.html.className('className')).getText();
+        await e.web.element(e.by.html.selector('cssSelector')).getText();
+        await e.web.element(e.by.html.name('name')).getText();
+        await e.web.element(e.by.html.xpath('xpath')).getText();
+        await e.web.element(e.by.html.href('linkText')).getText();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).getText();
+        await e.web.element(e.by.html.tag('tag')).getText();
+      });
+
+      it('focus', async () => {
+        await e.web.element(e.by.html.id('id')).focus();
+        await e.web.element(e.by.html.className('className')).focus();
+        await e.web.element(e.by.html.selector('cssSelector')).focus();
+        await e.web.element(e.by.html.name('name')).focus();
+        await e.web.element(e.by.html.xpath('xpath')).focus();
+        await e.web.element(e.by.html.href('linkText')).focus();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).focus();
+        await e.web.element(e.by.html.tag('tag')).focus();
+      });
+
+      it('selectAllText', async () => {
+        await e.web.element(e.by.html.id('id')).selectAllText();
+        await e.web.element(e.by.html.className('className')).selectAllText();
+        await e.web.element(e.by.html.selector('cssSelector')).selectAllText();
+        await e.web.element(e.by.html.name('name')).selectAllText();
+        await e.web.element(e.by.html.xpath('xpath')).selectAllText();
+        await e.web.element(e.by.html.href('linkText')).selectAllText();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).selectAllText();
+        await e.web.element(e.by.html.tag('tag')).selectAllText();
+      });
+
+      it('moveCursorToEnd', async () => {
+        await e.web.element(e.by.html.id('id')).moveCursorToEnd();
+        await e.web.element(e.by.html.className('className')).moveCursorToEnd();
+        await e.web.element(e.by.html.selector('cssSelector')).moveCursorToEnd();
+        await e.web.element(e.by.html.name('name')).moveCursorToEnd();
+        await e.web.element(e.by.html.xpath('xpath')).moveCursorToEnd();
+        await e.web.element(e.by.html.href('linkText')).moveCursorToEnd();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).moveCursorToEnd();
+        await e.web.element(e.by.html.tag('tag')).moveCursorToEnd();
+      });
+
+      it('runScript', async () => {
+        const script = 'function foo(el) {}';
+        await e.web.element(e.by.html.id('id')).runScript(script);
+        await e.web.element(e.by.html.className('className')).runScript(script);
+        await e.web.element(e.by.html.selector('cssSelector')).runScript(script);
+        await e.web.element(e.by.html.name('name')).runScript(script);
+        await e.web.element(e.by.html.xpath('xpath')).runScript(script);
+        await e.web.element(e.by.html.href('linkText')).runScript(script);
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).runScript(script);
+        await e.web.element(e.by.html.tag('tag')).runScript(script);
+      });
+
+      it('runScriptWithArgs', async () => {
+        const script = 'function bar(a,b) {}';
+        const argsArr = ['fooA','barB'];
+        await e.web.element(e.by.html.id('id')).runScriptWithArgs(script, argsArr);
+        await e.web.element(e.by.html.className('className')).runScriptWithArgs(script, argsArr);
+        await e.web.element(e.by.html.selector('cssSelector')).runScriptWithArgs(script, argsArr);
+        await e.web.element(e.by.html.name('name')).runScriptWithArgs(script, argsArr);
+        await e.web.element(e.by.html.xpath('xpath')).runScriptWithArgs(script, argsArr);
+        await e.web.element(e.by.html.href('linkText')).runScriptWithArgs(script, argsArr);
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).runScriptWithArgs(script, argsArr);
+        await e.web.element(e.by.html.tag('tag')).runScriptWithArgs(script, argsArr);
+      });
+
+      it('getCurrentUrl', async () => {
+        await e.web.element(e.by.html.id('id')).getCurrentUrl();
+        await e.web.element(e.by.html.className('className')).getCurrentUrl();
+        await e.web.element(e.by.html.selector('cssSelector')).getCurrentUrl();
+        await e.web.element(e.by.html.name('name')).getCurrentUrl();
+        await e.web.element(e.by.html.xpath('xpath')).getCurrentUrl();
+        await e.web.element(e.by.html.href('linkText')).getCurrentUrl();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).getCurrentUrl();
+        await e.web.element(e.by.html.tag('tag')).getCurrentUrl();
+      });
+
+      it('getTitle', async () => {
+        await e.web.element(e.by.html.id('id')).getTitle();
+        await e.web.element(e.by.html.className('className')).getTitle();
+        await e.web.element(e.by.html.selector('cssSelector')).getTitle();
+        await e.web.element(e.by.html.name('name')).getTitle();
+        await e.web.element(e.by.html.xpath('xpath')).getTitle();
+        await e.web.element(e.by.html.href('linkText')).getTitle();
+        await e.web.element(e.by.html.hrefContains('partialLinkText')).getTitle();
+        await e.web.element(e.by.html.tag('tag')).getTitle();
+      });
+
+      it('should allow for access to by via element', async () => {
+        const webview = await e.web;
+        await webview.element(e.by.html.id('id')).tap();
+      });
+    })
+
+    describe('Web Matchers',() => {
+      it('by.html.id', async () => {
+        await e.expect(e.web.element(e.by.html.id('id'))).toExist();
+        await e.expect(e.web.element(e.by.html.id('id'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.id('id'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.id('id'))).not.toHaveText('text');
+      });
+
+      it('by.html.className', async () => {
+        await e.expect(e.web.element(e.by.html.className('className'))).toExist();
+        await e.expect(e.web.element(e.by.html.className('className'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.className('className'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.className('className'))).not.toHaveText('text');
+      });
+
+      it('by.html.selector', async () => {
+        await e.expect(e.web.element(e.by.html.selector('cssSelector'))).toExist();
+        await e.expect(e.web.element(e.by.html.selector('cssSelector'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.selector('cssSelector'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.selector('cssSelector'))).not.toHaveText('text');
+      });
+
+      it('by.html.name', async () => {
+        await e.expect(e.web.element(e.by.html.name('name'))).toExist();
+        await e.expect(e.web.element(e.by.html.name('name'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.name('name'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.name('name'))).not.toHaveText('text');
+      });
+
+      it('by.html.xpath', async () => {
+        await e.expect(e.web.element(e.by.html.xpath('xpath'))).toExist();
+        await e.expect(e.web.element(e.by.html.xpath('xpath'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.xpath('xpath'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.xpath('xpath'))).not.toHaveText('text');
+      });
+
+      it('by.html.href', async () => {
+        await e.expect(e.web.element(e.by.html.href('link'))).toExist();
+        await e.expect(e.web.element(e.by.html.href('link'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.href('link'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.href('link'))).not.toHaveText('text');
+      });
+
+      it('by.html.hrefContains', async () => {
+        await e.expect(e.web.element(e.by.html.hrefContains('lin'))).toExist();
+        await e.expect(e.web.element(e.by.html.hrefContains('lin'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.hrefContains('lin'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.hrefContains('lin'))).not.toHaveText('text');
+      });
+
+      it('by.html.tag', async () => {
+        await e.expect(e.web.element(e.by.html.tag('tag'))).toExist();
+        await e.expect(e.web.element(e.by.html.tag('tag'))).not.toExist();
+        await e.expect(e.web.element(e.by.html.tag('tag'))).toHaveText('text');
+        await e.expect(e.web.element(e.by.html.tag('tag'))).not.toHaveText('text');
+      });
+
+      it('should allow for access to except via element', async () => {
+        const webview = e.web();
+        await e.expect(webview.element(e.by.html.id('id'))).toExist();
+      });
+    });
+  });
+});
+
+async function expectToThrow(func) {
+  try {
+    await func();
+  } catch (ex) {
+    expect(ex).toBeDefined();
+  }
+}
+
+class MockExecutor {
+  constructor() {
+    this.executeResult = undefined;
+  }
+
+  async execute(invocation) {
+    if (typeof invocation === 'function') {
+      invocation = invocation();
+    }
+    expect(invocation.target).toBeDefined();
+    expect(invocation.target.type).toBeDefined();
+    expect(invocation.target.value).toBeDefined();
+
+    this.recurse(invocation);
+    await this.timeout(1);
+    return this.executeResult ? {
+      result: this.executeResult,
+    } : undefined;
+  }
+
+  recurse(invocation) {
+    for (const key in invocation) {
+      if (invocation.hasOwnProperty(key)) {
+        if (invocation[key] instanceof Object) {
+          this.recurse(invocation[key]);
+        }
+        if (key === 'target' && invocation[key].type === 'Invocation') {
+          const innerValue = invocation.target.value;
+          expect(innerValue.target.type).toBeDefined();
+          expect(innerValue.target.value).toBeDefined();
+        }
+      }
+    }
+  }
+
+  async timeout(ms) {
+    return new Promise((resolve) => setTimeout(resolve, ms));
+  }
+}
diff --git a/detox/src/android/expect.test.js b/detox/src/android/expect.test.js
deleted file mode 100644
index 9bbd9d6c1..000000000
--- a/detox/src/android/expect.test.js
+++ /dev/null
@@ -1,318 +0,0 @@
-describe('expect', () => {
-  let e;
-
-  let mockExecutor;
-  let emitter;
-  beforeEach(() => {
-    jest.mock('tempfile');
-    jest.mock('fs-extra');
-
-    mockExecutor = new MockExecutor();
-
-    const Emitter = jest.genMockFromModule('../utils/AsyncEmitter');
-    emitter = new Emitter();
-
-    const AndroidExpect = require('./expect');
-    e = new AndroidExpect({
-      invocationManager: mockExecutor,
-      emitter,
-    });
-  });
-
-  it(`element by accessibilityLabel`, async () => {
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toBeVisible();
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toBeNotVisible();
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toBeVisible();
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toExist();
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotExist();
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toExist();
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveText('text');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveText('text');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveText('text');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveLabel('label');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveLabel('label');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveLabel('label');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveId('id');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveId('id');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveId('id');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveValue('value');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toNotHaveValue('value');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveValue('value');
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).toHaveToggleValue(true);
-    await e.expect(e.element(e.by.accessibilityLabel('test'))).not.toHaveToggleValue(true);
-  });
-
-  it(`element by label (for backwards compat)`, async () => {
-    await e.expect(e.element(e.by.label('test'))).toBeVisible();
-    await e.expect(e.element(e.by.label('test'))).toBeNotVisible();
-    await e.expect(e.element(e.by.label('test'))).toExist();
-    await e.expect(e.element(e.by.label('test'))).toNotExist();
-    await e.expect(e.element(e.by.label('test'))).toHaveText('text');
-    await e.expect(e.element(e.by.label('test'))).toHaveLabel('label');
-    await e.expect(e.element(e.by.label('test'))).toHaveId('id');
-    await e.expect(e.element(e.by.label('test'))).toHaveValue('value');
-    await e.expect(e.element(e.by.label('test'))).toHaveToggleValue(false);
-    await e.expect(e.element(e.by.label('test'))).not.toHaveToggleValue(false);
-  });
-
-  it(`element by id`, async () => {
-    await e.expect(e.element(e.by.id('test'))).toBeVisible();
-  });
-
-  it(`element by type`, async () => {
-    await e.expect(e.element(e.by.type('test'))).toBeVisible();
-  });
-
-  it(`element by traits`, async () => {
-    await e.expect(e.element(e.by.traits(['button', 'link', 'header', 'search']))).toBeVisible();
-    await e.expect(e.element(e.by.traits(['image', 'selected', 'plays', 'key']))).toBeNotVisible();
-    await e.expect(e.element(e.by.traits(['text', 'summary', 'disabled', 'frequentUpdates']))).toBeNotVisible();
-    await e.expect(e.element(e.by.traits(['startsMedia', 'adjustable', 'allowsDirectInteraction', 'pageTurn']))).toBeNotVisible();
-  });
-
-  it(`matcher helpers`, async () => {
-    await e.expect(e.element(e.by.id('test').withAncestor(e.by.id('ancestor')))).toBeVisible();
-    await e.expect(e.element(e.by.id('test').withDescendant(e.by.id('descendant')))).toBeVisible();
-    await e.expect(e.element(e.by.id('test').and(e.by.type('type')))).toBeVisible();
-    await e.expect(e.element(e.by.id('test'))).not.toBeVisible();
-    await e.expect(e.element(e.by.id('test').or(e.by.id('test2')))).toBeVisible();
-  });
-
-  it(`expect with wrong parameters should throw`, async () => {
-    await expectToThrow(() => e.expect('notAnElement'));
-    await expectToThrow(() => e.expect(e.element('notAMatcher')));
-  });
-
-  it(`matchers with wrong parameters should throw`, async () => {
-    await expectToThrow(() => e.element(e.by.label(5)));
-    await expectToThrow(() => e.element(e.by.accessibilityLabel(5)));
-    await expectToThrow(() => e.element(e.by.id(5)));
-    await expectToThrow(() => e.element(e.by.type(0)));
-    await expectToThrow(() => e.by.traits(1));
-    await expectToThrow(() => e.by.traits(['nonExistentTrait']));
-    await expectToThrow(() => e.element(e.by.value(0)));
-    await expectToThrow(() => e.element(e.by.text(0)));
-    await expectToThrow(() => e.element(e.by.id('test').withAncestor('notAMatcher')));
-    await expectToThrow(() => e.element(e.by.id('test').withDescendant('notAMatcher')));
-    await expectToThrow(() => e.element(e.by.id('test').and('notAMatcher')));
-    await expectToThrow(() => e.element(e.by.id('test').or('notAMatcher')));
-  });
-
-  it(`waitFor (element)`, async () => {
-    await e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(0);
-    await e.waitFor(e.element(e.by.id('id'))).not.toExist().withTimeout(0);
-    await e.waitFor(e.element(e.by.id('id'))).toBeVisible();
-    await e.waitFor(e.element(e.by.id('id'))).toBeNotVisible();
-    await e.waitFor(e.element(e.by.id('id'))).toExist();
-    await e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(0);
-    await e.waitFor(e.element(e.by.id('id'))).toNotExist().withTimeout(0);
-
-    await e.waitFor(e.element(e.by.id('id'))).toHaveText('text');
-    await e.waitFor(e.element(e.by.id('id'))).toNotHaveText('value');
-    await e.waitFor(e.element(e.by.id('id'))).not.toHaveText('text');
-
-    await e.waitFor(e.element(e.by.id('id'))).toHaveLabel('text');
-    await e.waitFor(e.element(e.by.id('id'))).toNotHaveLabel('text');
-    await e.waitFor(e.element(e.by.id('id'))).not.toHaveLabel('text');
-
-    await e.waitFor(e.element(e.by.id('id'))).toHaveId('id');
-    await e.waitFor(e.element(e.by.id('id'))).toNotHaveId('id');
-    await e.waitFor(e.element(e.by.id('id'))).not.toHaveId('id');
-
-    await e.waitFor(e.element(e.by.id('id'))).toHaveValue('value');
-    await e.waitFor(e.element(e.by.id('id'))).toNotHaveValue('value');
-    await e.waitFor(e.element(e.by.id('id'))).not.toHaveValue('value');
-
-    await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scroll(50, 'down');
-    await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scroll(50);
-
-  });
-
-  it(`waitFor (element) with wrong parameters should throw`, async () => {
-    await expectToThrow(() => e.waitFor('notAnElement'));
-    await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout('notANumber'));
-    await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toExist().withTimeout(-1));
-    await expectToThrow(() => e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement('notAnElement'));
-  });
-
-  it(`waitFor (element) with non-elements should throw`, async () => {
-    await expectToThrow(() => e.waitFor('notAnElement').toBeVisible());
-  });
-
-  describe('element interactions', () => {
-    it('should tap and long-press', async () => {
-      await e.element(e.by.label('Tap Me')).tap();
-      await e.element(e.by.label('Tap Me')).tap({ x: 10, y: 10 });
-      await e.element(e.by.label('Tap Me')).tapAtPoint({x: 100, y: 200});
-      await e.element(e.by.label('Tap Me')).longPress();
-      await e.element(e.by.id('UniqueId819')).multiTap(3);
-    });
-
-    it('should not tap and long-press given bad args', async () => {
-      await expectToThrow(() => e.element(e.by.id('UniqueId819')).multiTap('NaN'));
-    });
-
-    it('should press special keys', async () => {
-      await e.element(e.by.id('UniqueId937')).tapBackspaceKey();
-      await e.element(e.by.id('UniqueId937')).tapReturnKey();
-    });
-
-    it('should edit text', async () => {
-      await e.element(e.by.id('UniqueId937')).typeText('passcode');
-      await e.element(e.by.id('UniqueId005')).clearText();
-      await e.element(e.by.id('UniqueId005')).replaceText('replaceTo');
-    });
-
-    it('should not edit text given bad args', async () => {
-      await expectToThrow(() => e.element(e.by.id('UniqueId937')).typeText(0));
-      await expectToThrow(() => e.element(e.by.id('UniqueId005')).replaceText(3));
-    });
-
-    it('should scroll', async () => {
-      await e.element(e.by.id('ScrollView161')).scroll(100);
-      await e.element(e.by.id('ScrollView161')).scroll(100, 'down');
-      await e.element(e.by.id('ScrollView161')).scroll(100, 'up');
-      await e.element(e.by.id('ScrollView161')).scroll(100, 'right');
-      await e.element(e.by.id('ScrollView161')).scroll(100, 'left');
-      await e.element(e.by.id('ScrollView161')).scrollTo('bottom');
-      await e.element(e.by.id('ScrollView161')).scrollTo('top');
-      await e.element(e.by.id('ScrollView161')).scrollTo('left');
-      await e.element(e.by.id('ScrollView161')).scrollTo('right');
-    });
-
-    it('should not scroll given bad args', async () => {
-      await expectToThrow(() => e.element(e.by.id('ScrollView161')).scroll('NaN', 'down'));
-      await expectToThrow(() => e.element(e.by.id('ScrollView161')).scroll(100, 'noDirection'));
-      await expectToThrow(() => e.element(e.by.id('ScrollView161')).scroll(100, 0));
-      await expectToThrow(() => e.element(e.by.id('ScrollView161')).scrollTo(0));
-      await expectToThrow(() => e.element(e.by.id('ScrollView161')).scrollTo('noDirection'));
-    });
-
-    it('should swipe', async () => {
-      await e.element(e.by.id('ScrollView799')).swipe('down');
-      await e.element(e.by.id('ScrollView799')).swipe('down', 'fast');
-      await e.element(e.by.id('ScrollView799')).swipe('up', 'slow');
-      await e.element(e.by.id('ScrollView799')).swipe('left', 'fast');
-      await e.element(e.by.id('ScrollView799')).swipe('right', 'slow');
-      await e.element(e.by.id('ScrollView799')).swipe('down', 'fast', 0.9);
-      await e.element(e.by.id('ScrollView799')).swipe('up', 'slow', 0.9);
-      await e.element(e.by.id('ScrollView799')).swipe('left', 'fast', 0.9);
-      await e.element(e.by.id('ScrollView799')).swipe('right', 'slow', 0.9);
-      await e.element(e.by.id('ScrollView799')).swipe('down', 'fast', undefined, undefined, 0.25);
-      await e.element(e.by.id('ScrollView799')).swipe('up', 'slow', 0.9, 0.5, 0.5);
-    });
-
-    it('should not swipe given bad args', async () => {
-      await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe(4, 'fast'));
-      await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 0));
-      await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('noDirection', 'fast'));
-      await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow'));
-      await expectToThrow(() => e.element(e.by.id('ScrollView799')).swipe('down', 'NotFastNorSlow', 0.9));
-    });
-
-    it('should allow for index-based element discrepancy resolution', async () => {
-      await e.element(e.by.id('ScrollView799')).atIndex(1);
-    });
-
-    it('should fail to find index-based element given invalid args', async () => {
-      await expectToThrow(() => e.element(e.by.id('ScrollView799')).atIndex('NaN'));
-    });
-  });
-
-  describe('element screenshots', () => {
-    const tempFilePath = '/path/to/temp-file.png';
-    const invokeResultInBase64 = 'VGhlcmUgaXMgbm8gc3Bvb24h';
-
-    let tempfile;
-    let fs;
-    let _element;
-    beforeEach(() => {
-      mockExecutor.executeResult = Promise.resolve(invokeResultInBase64);
-
-      fs = require('fs-extra');
-      tempfile = require('tempfile');
-      tempfile.mockReturnValue(tempFilePath);
-
-      _element = e.element(e.by.id('FancyElement'));
-    });
-
-    it('should take and save the screenshot in a temp-file', async () => {
-      await _element.takeScreenshot();
-      expect(fs.writeFile).toHaveBeenCalledWith(tempFilePath, invokeResultInBase64, 'base64');
-      expect(tempfile).toHaveBeenCalledWith('detox.element-screenshot.png');
-    });
-
-    it('should return the path to the temp-file containing screenshot data', async () => {
-      const result = await _element.takeScreenshot();
-      expect(result).toEqual(tempFilePath);
-    });
-
-    it('should emit a named-artifact creation event', async () => {
-      const screenshotName = 'mock-screenshot-name';
-      await _element.takeScreenshot(screenshotName);
-      expect(emitter.emit).toHaveBeenCalledWith('createExternalArtifact', {
-        pluginId: 'screenshot',
-        artifactName: screenshotName,
-        artifactPath: tempFilePath,
-      });
-    });
-
-    it('should emit an artifact creation event with a default name', async () => {
-      await _element.takeScreenshot(undefined);
-      expect(emitter.emit).toHaveBeenCalledWith(
-        expect.any(String),
-        expect.objectContaining({
-          artifactName: 'temp-file',
-        },
-      ));
-    });
-  });
-});
-
-async function expectToThrow(func) {
-  try {
-    await func();
-  } catch (ex) {
-    expect(ex).toBeDefined();
-  }
-}
-
-class MockExecutor {
-  constructor() {
-    this.executeResult = undefined;
-  }
-
-  async execute(invocation) {
-    if (typeof invocation === 'function') {
-      invocation = invocation();
-    }
-    expect(invocation.target).toBeDefined();
-    expect(invocation.target.type).toBeDefined();
-    expect(invocation.target.value).toBeDefined();
-
-    this.recurse(invocation);
-    await this.timeout(1);
-    return this.executeResult ? {
-      result: this.executeResult,
-    } : undefined;
-  }
-
-  recurse(invocation) {
-    for (const key in invocation) {
-      if (invocation.hasOwnProperty(key)) {
-        if (invocation[key] instanceof Object) {
-          this.recurse(invocation[key]);
-        }
-        if (key === 'target' && invocation[key].type === 'Invocation') {
-          const innerValue = invocation.target.value;
-          expect(innerValue.target.type).toBeDefined();
-          expect(innerValue.target.value).toBeDefined();
-        }
-      }
-    }
-  }
-
-  async timeout(ms) {
-    return new Promise((resolve) => setTimeout(resolve, ms));
-  }
-}
diff --git a/detox/src/devices/Device.test.js b/detox/src/devices/Device.test.js
index 991e50b9f..655f2d164 100644
--- a/detox/src/devices/Device.test.js
+++ b/detox/src/devices/Device.test.js
@@ -772,6 +772,13 @@ describe('Device', () => {
     expect(driverMock.driver.resetStatusBar).toHaveBeenCalledWith(device.id);
   });
 
+  it(`_typeText() should pass to device driver`, async () => {
+    const device = await aValidDevice();
+    await device._typeText('Text');
+
+    expect(driverMock.driver.typeText).toHaveBeenCalledWith(device.id, 'Text');
+  });
+
   it(`shake() should pass to device driver`, async () => {
     const device = await aValidDevice();
     await device.shake();
diff --git a/detox/src/index.test.js b/detox/src/index.test.js
index 66bae0905..2096679e1 100644
--- a/detox/src/index.test.js
+++ b/detox/src/index.test.js
@@ -54,6 +54,7 @@ describe('index (regular)', () => {
       ['a',  'function', 'element'],
       ['a',  'function', 'expect'],
       ['a',  'function', 'waitFor'],
+      ['an',  'object',   'web'],
     ])('should export %s %s called .%s', (_1, type, name) => {
       expect(typeof detox[name]).toBe(type);
     });
diff --git a/detox/src/ios/expectTwo.test.js b/detox/src/ios/expectTwo.test.js
index 302b6ba5d..a3ad8612f 100644
--- a/detox/src/ios/expectTwo.test.js
+++ b/detox/src/ios/expectTwo.test.js
@@ -426,6 +426,18 @@ describe('expectTwo', () => {
       expect(e.message).toContain('not supported on iOS');
     }
   });
+
+  it('by.html should throw', () => {
+    expect(() => e.by.html).toThrowError(/not support/);
+  });
+
+  it('web() should throw', () => {
+    expect(() => e.web(e.by.id('someId'))).toThrowError(/not support/);
+  });
+
+  it('web.element() should throw', () => {
+    expect(() => e.web.element(e.by.id('someId'))).toThrowError(/not support/);
+  });
 });
 
 expect.extend({
@@ -444,4 +456,4 @@ class MockExecutor {
   execute(invocation) {
     return { invocation };
   };
-}
\ No newline at end of file
+}
diff --git a/detox/src/matchersRegistry.test.js b/detox/src/matchersRegistry.test.js
index 10484e784..41c59cdac 100644
--- a/detox/src/matchersRegistry.test.js
+++ b/detox/src/matchersRegistry.test.js
@@ -12,8 +12,8 @@ describe('Detox matchers registry', () => {
   let resolveModuleFromPath;
   let uut;
   beforeEach(() => {
-    jest.mock('./android/expect');
-    AndroidExpect = require('./android/expect');
+    jest.mock('./android/AndroidExpect');
+    AndroidExpect = require('./android/AndroidExpect');
 
     jest.mock('./ios/expectTwo');
     IosExpect = require('./ios/expectTwo');
diff --git a/detox/test/android/app/build.gradle b/detox/test/android/app/build.gradle
index 66774cc51..380389470 100644
--- a/detox/test/android/app/build.gradle
+++ b/detox/test/android/app/build.gradle
@@ -92,6 +92,8 @@ dependencies {
     // noinspection GradleDynamicVersion
     implementation(project(path: ':AsyncStorage'))
 
+    implementation project(':react-native-webview')
+
     androidTestImplementation(project(path: ':detox'))
     androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.1.0'
     androidTestUtil 'com.linkedin.testbutler:test-butler-app:2.1.0'
diff --git a/detox/test/android/app/proguard-rules.pro b/detox/test/android/app/proguard-rules.pro
index 57c9a6783..4085c8983 100644
--- a/detox/test/android/app/proguard-rules.pro
+++ b/detox/test/android/app/proguard-rules.pro
@@ -26,3 +26,4 @@
 -keepclassmembers class * {
     @com.facebook.jni.annotations.DoNotStrip *;
 }
+-keep class kotlin.** { *; }
diff --git a/detox/test/android/app/src/main/java/com/example/MainApplication.java b/detox/test/android/app/src/main/java/com/example/MainApplication.java
index 5a6f98954..680261332 100644
--- a/detox/test/android/app/src/main/java/com/example/MainApplication.java
+++ b/detox/test/android/app/src/main/java/com/example/MainApplication.java
@@ -3,6 +3,7 @@ package com.example;
 import android.app.Application;
 
 import com.facebook.react.ReactApplication;
+import com.reactnativecommunity.webview.RNCWebViewPackage;
 import com.facebook.react.ReactNativeHost;
 import com.facebook.react.ReactPackage;
 import com.facebook.react.shell.MainReactPackage;
@@ -11,6 +12,7 @@ import com.reactnativecommunity.asyncstorage.AsyncStoragePackage;
 
 import java.util.Arrays;
 import java.util.List;
+import android.webkit.WebView;
 
 public class MainApplication extends Application implements ReactApplication {
     private final ReactNativeHost mReactNativeHost = new ReactNativeHost(this) {
@@ -23,6 +25,7 @@ public class MainApplication extends Application implements ReactApplication {
         protected List<ReactPackage> getPackages() {
             return Arrays.<ReactPackage>asList(
                     new MainReactPackage(),
+                    new RNCWebViewPackage(),
                     new NativeModulePackage(),
                     new AsyncStoragePackage()
             );
@@ -38,5 +41,6 @@ public class MainApplication extends Application implements ReactApplication {
     public void onCreate() {
         super.onCreate();
         SoLoader.init(this, /* native exopackage */ false);
+        WebView.setWebContentsDebuggingEnabled(true);
     }
 }
diff --git a/detox/test/android/gradle.properties b/detox/test/android/gradle.properties
index 4bd11de57..f40f022a4 100644
--- a/detox/test/android/gradle.properties
+++ b/detox/test/android/gradle.properties
@@ -16,3 +16,5 @@
 # This option should only be used with decoupled projects. More details, visit
 # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects
 # org.gradle.parallel=true
+android.useAndroidX=true
+android.enableJetifier=true
\ No newline at end of file
diff --git a/detox/test/android/settings.gradle b/detox/test/android/settings.gradle
index a3ba9c269..d0065bcc7 100644
--- a/detox/test/android/settings.gradle
+++ b/detox/test/android/settings.gradle
@@ -1,5 +1,4 @@
 rootProject.name = 'DetoxTest'
-
 include ':app'
 
 include ':detox'
@@ -10,3 +9,6 @@ project(':ReactAndroid').projectDir = new File(rootProject.projectDir, '../node_
 
 include ':AsyncStorage'
 project(':AsyncStorage').projectDir = new File(rootProject.projectDir, '../node_modules/@react-native-community/async-storage/android')
+
+include ':react-native-webview'
+project(':react-native-webview').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-webview/android')
\ No newline at end of file
diff --git a/detox/test/e2e/28.webview.test.js b/detox/test/e2e/28.webview.test.js
new file mode 100644
index 000000000..319a83ee6
--- /dev/null
+++ b/detox/test/e2e/28.webview.test.js
@@ -0,0 +1,155 @@
+const MOCK_TEXT = 'Mock Text';
+
+describe(':android: WebView', () => {
+  let webview_1;
+
+  beforeEach(async () => {
+    await device.reloadReactNative();
+    await element(by.text('WebView')).tap();
+    webview_1 = web(by.id('webview_1'));
+  });
+
+  describe('Expectations',() => {
+    it('expect element to exists', async () => {
+      await expect(webview_1.element(by.html.id('testingPar'))).toExist();
+    });
+
+    it('expect element to NOT exists', async () => {
+      await expect(webview_1.element(by.html.id('not_found'))).not.toExist();
+    });
+
+    it('expect element to have text', async () => {
+      await expect(webview_1.element(by.html.id('testingPar'))).toHaveText('Message');
+    });
+
+    it('expect element to NOT have text', async () => {
+      await expect(webview_1.element(by.html.id('testingPar'))).not.toHaveText(MOCK_TEXT);
+    });
+  });
+
+  describe('Element Matchers',() => {
+    it('expect to find element by id', async () => {
+      await expect(webview_1.element(by.html.id('testingh1'))).toExist();
+    });
+
+    it('expect to find element by class name', async () => {
+      await expect(webview_1.element(by.html.className('a'))).toExist();
+    });
+
+    it('expect to find element by css selector', async () => {
+      await expect(webview_1.element(by.html.selector('#cssSelector'))).toExist();
+    });
+
+    it('expect to find element by name', async () => {
+      await expect(webview_1.element(by.html.name('sec_input'))).toExist();
+    });
+
+    it('expect to find element by xpath', async () => {
+      await expect(webview_1.element(by.html.xpath('//*[@id="testingh1-1"]'))).toExist();
+    });
+
+    it('expect to find element by href', async () => {
+      await expect(webview_1.element(by.html.href('disney.com'))).toExist();
+    });
+
+    it('expect to find element by hrefContains', async () => {
+      await expect(webview_1.element(by.html.hrefContains('disney'))).toExist();
+    });
+
+    it('expect to find element by tag name', async () => {
+      await expect(webview_1.element(by.html.tag('mark'))).toExist();
+    });
+  });
+
+  describe('ContentEditable', () => {
+
+    it('should replace text by selecting all text', async () => {
+        const editable = await webview_1.element(by.html.className('public-DraftEditor-content'));
+        const text = await editable.getText();
+
+        await editable.scrollToView();
+        await editable.selectAllText();
+
+        //tapping, (at the moment not working on content-editable)
+        const uiDevice = device.getUiDevice();
+        await uiDevice.click(40, 150);
+
+        await editable.typeText(MOCK_TEXT, true);
+
+        await expect(editable).not.toHaveText(text);
+        await expect(editable).toHaveText(MOCK_TEXT);
+    });
+
+    it('move cursor to end and add text', async () => {
+      const editable = await webview_1.element(by.html.className('public-DraftEditor-content'));
+      await editable.scrollToView();
+
+      //tapping, (at the moment not working on content-editable)
+      const uiDevice = device.getUiDevice();
+      await uiDevice.click(40, 150);
+
+      //Initial Text
+      await editable.selectAllText();
+      await editable.typeText(MOCK_TEXT, true);
+
+      //Addition Text
+      const ADDITION_TEXT = ' AdditionText';
+      await editable.moveCursorToEnd();
+      await editable.typeText(ADDITION_TEXT, true);
+
+      await expect(editable).toHaveText(MOCK_TEXT + ADDITION_TEXT);
+    });
+  });
+
+  it('should set input and change text', async () => {
+    // Verify initial value
+    const para = webview_1.element(by.html.id('testingPar'));
+    await expect(para).toHaveText('Message');
+
+    const textInput = await webview_1.element(by.html.id('textInput'));
+    await textInput.scrollToView();
+    await textInput.tap();
+    await textInput.typeText(MOCK_TEXT);
+
+    await webview_1.element(by.html.id('changeTextBtn')).tap();
+    // Verify text updated
+    await expect(para).toHaveText(MOCK_TEXT);
+  });
+
+  it('should header get text and verify its value', async () => {
+    const text = await webview_1.element(by.html.id('testingh1')).getText();
+
+    const textInput = await webview_1.element(by.html.id('textInput'));
+    await textInput.scrollToView();
+    await textInput.tap();
+    await textInput.typeText(text);
+
+    await webview_1.element(by.html.id('changeTextBtn')).tap();
+
+    // Verify text is the title text
+    await expect(webview_1.element(by.html.id('testingPar'))).toHaveText(text);
+
+  });
+
+  it('should replace text', async () => {
+    const textInput = await webview_1.element(by.html.id('textInput'));
+    await textInput.scrollToView();
+    await textInput.tap();
+    await textInput.typeText('first text');
+
+    await webview_1.element(by.html.id('changeTextBtn')).tap();
+    await expect(webview_1.element(by.html.id('testingPar'))).toHaveText('first text');
+
+    await textInput.replaceText(MOCK_TEXT);
+    await webview_1.element(by.html.id('changeTextBtn')).tap();
+
+    // Verify param value is the latest changed text
+    await expect(webview_1.element(by.html.id('testingPar'))).toHaveText(MOCK_TEXT);
+  });
+
+  it('getWebView with matcher id', async () => {
+    const webview_2 = await web(by.id('webview_2'));
+    await expect(webview_2.element(by.html.tag('p'))).toHaveText('Second Webview');
+  });
+
+});
diff --git a/detox/test/package.json b/detox/test/package.json
index b939600ff..d6c0c9f59 100644
--- a/detox/test/package.json
+++ b/detox/test/package.json
@@ -29,7 +29,8 @@
     "@react-native-community/async-storage": "^1.12.0",
     "moment": "^2.24.0",
     "react": "16.11.x",
-    "react-native": "0.62.x"
+    "react-native": "0.62.x",
+    "react-native-webview": "7.0.1"
   },
   "devDependencies": {
     "@babel/core": "^7.8.7",
diff --git a/detox/test/src/Screens/WebViewScreen.js b/detox/test/src/Screens/WebViewScreen.js
new file mode 100644
index 000000000..5ba8c71fc
--- /dev/null
+++ b/detox/test/src/Screens/WebViewScreen.js
@@ -0,0 +1,141 @@
+import React, { Component } from 'react';
+import { View } from 'react-native';
+import { WebView } from 'react-native-webview';
+
+export default class WebViewScreen extends Component {
+  render() {
+    // const debugSource = require('../assets/html/test.html');
+    // const releaseSourcePrefix = Platform.OS === 'android' ? 'file:///android_asset' : './assets';
+    // const releaseSource = { uri: `${releaseSourcePrefix}/assets/html/test.html` };
+    // const webViewSource = Image.resolveAssetSource(global.__DEV__ ? debugSource : releaseSource);
+    return (
+        <View style={{flex: 1,flexDirection: 'column' ,backgroundColor:'blue'}}>
+          <View style={{flex: 8}}>
+            <WebView testID={'webview_1'} source={{html: webpageSource}}/>
+          </View>
+          <View style={{flex: 1}}>
+            <WebView testID={'webview_2'} source={{html: webpageSource2}} scrollEnabled={false}/>
+          </View>
+        </View>
+
+    );
+  }
+}
+
+const webpageSource2 = `
+<!DOCTYPE html>
+<html>
+<head>
+  <meta name="viewport" content="width=320, user-scalable=no">
+</head>
+  <body>
+  <p>Second Webview</p>
+  </body>
+</html>
+`;
+
+const webpageSource = `
+<!DOCTYPE html>
+<html>
+<head>
+  <meta name="viewport" content="width=320, user-scalable=no">
+</head>
+<body>
+<style>
+span.a {
+  display: inline; /* the default for span */
+  width: 100px;
+  height: 100px;
+  padding: 5px;
+  border: 1px solid blue;
+  background-color: yellow;
+}
+
+span.b {
+  display: inline-block;
+  width: 100px;
+  height: 100px;
+  padding: 5px;
+  border: 1px solid blue;
+  background-color: yellow;
+}
+
+span.c {
+  display: block;
+  width: 100px;
+  height: 100px;
+  padding: 5px;
+  border: 1px solid blue;
+  background-color: yellow;
+}
+
+#cssSelector {
+  background-color: yellow;
+}
+npm
+
+</style>
+<h1 id="testingh1">Testing <mark>Heading1</mark></h1>
+
+<div id="spacer" style="height:600px;"></div>
+<a id="cssSelector" href="http://www.disney.com">disney.com</a>
+
+<div id="root" style="min-height:80px;">
+   <div class="QiJxn LG3cc _2KPOx" dir="ltr" data-id="rce">
+      <style id="dynamicStyles"></style>
+      <div class="Tux6h _2N_hG">
+         <div class="DraftEditor-root">
+            <div class="DraftEditor-editorContainer">
+               <div aria-autocomplete="list" aria-describedby="placeholder-editor" aria-expanded="false" class="notranslate public-DraftEditor-content has-custom-focus" contenteditable="true" role="combobox" spellcheck="true" style="outline: none; user-select: text; white-space: pre-wrap; overflow-wrap: break-word;">
+                  <div data-contents="true">
+                     <div class="jwLWP _2hXa7 public-DraftStyleDefault-block-depth0 public-DraftStyleDefault-text-ltr" data-block="true" data-editor="editor" data-offset-key="00000-0-0">
+                        <div data-offset-key="00000-0-0" class="public-DraftStyleDefault-block public-DraftStyleDefault-ltr"><span data-offset-key="00000-0-0"><span data-text="true">Rich Content Test</span></span></div>
+                     </div>
+                  </div>
+               </div>
+            </div>
+         </div>
+      </div>
+   </div>
+</div>
+
+<div id="testingDiv">
+    <div id="testingDiv2">
+        <div id="testingDiv3">
+            <div id="testingDiv4">
+                <div id="testingDiv5">
+                    <div id="testingDiv6">
+                        <div id="testingDiv7">
+                            <div id="testingDiv8">
+                                <h1 id="testingh1-2">Testing Heading2</h1>
+                            </div>
+                        </div>
+                    </div>
+                </div>
+            </div>
+        </div>
+    </div>
+</div>
+<span class="a">Aa</span>
+<span class="b">Ba</span>
+<span class="c">Ca</span>
+<input type="text" id="textInput" placeholder="type something"/><br>
+<input type="button" value="Change Text" onclick="changeText()" id="changeTextBtn"/><br>
+<p id="testingPar">Message</p>
+
+<div id="spacer2" style="height:2000px;"></div>
+
+<input name="sec_input" type="text" id="textInput2" placeholder="Second Input"/><br>
+
+<div id="testingDiv9">
+    <h2 id="testingh1-1">Xpath</h2>
+</div>
+<script>
+    function changeText() {
+        var typedText = document.getElementById('textInput').value;
+        document.getElementById('testingPar').innerHTML=typedText;
+    }
+</script>
+
+</body>
+</html>`
diff --git a/detox/test/src/Screens/index.js b/detox/test/src/Screens/index.js
index 4a62ff6c7..c56d9149a 100644
--- a/detox/test/src/Screens/index.js
+++ b/detox/test/src/Screens/index.js
@@ -22,6 +22,7 @@ import LaunchNotificationScreen from './LaunchNotificationScreen';
 import PickerViewScreen from './PickerViewScreen';
 import DeviceScreen from './DeviceScreen';
 import ElementScreenshotScreen from './ElementScreenshotScreen';
+import WebViewScreen from './WebViewScreen';
 
 export {
   SanityScreen,
@@ -47,5 +48,6 @@ export {
   LaunchArgsScreen,
   LaunchNotificationScreen,
   DeviceScreen,
-  ElementScreenshotScreen
+  ElementScreenshotScreen,
+  WebViewScreen,
 };
diff --git a/detox/test/src/app.js b/detox/test/src/app.js
index 4717b68e1..767e5f963 100644
--- a/detox/test/src/app.js
+++ b/detox/test/src/app.js
@@ -117,6 +117,7 @@ class example extends Component {
         {this.renderScreenButton('Location', Screens.LocationScreen)}
         {!isAndroid && this.renderScreenButton('DatePicker', Screens.DatePickerScreen)}
         {!isAndroid && this.renderScreenButton('Picker', Screens.PickerViewScreen)}
+        {isAndroid && this.renderScreenButton('WebView', Screens.WebViewScreen)}
 
         { /* TODO: Push this into a dedicated screen */ }
         <View style={{flexDirection: 'row', justifyContent: 'center'}}>
diff --git a/detox/test/types/detox-global-tests.ts b/detox/test/types/detox-global-tests.ts
index 2a9bd5703..cad2796eb 100644
--- a/detox/test/types/detox-global-tests.ts
+++ b/detox/test/types/detox-global-tests.ts
@@ -37,5 +37,12 @@ describe("Test", () => {
             .toBeVisible()
             .whileElement(by.id("ScrollView630"))
             .scroll(50, "down");
+
+        await web.element(by.html.id("btnSave")).tap();
+        await web.element(by.html.className("scroll-end")).atIndex(0).scrollToView();
+
+        const webview = web(by.id("webview"));
+        await expect(webview.element(by.html.selector(".button"))).toExist();
+        await expect(webview.element(by.html.selector(".button")).atIndex(1)).toExist();
     });
 });