From 913348e227523940f4e574ede04efd098bff7280 Mon Sep 17 00:00:00 2001 From: Alexande B Date: Thu, 27 Jan 2022 13:49:02 +0100 Subject: [PATCH 1/4] #11 fix and refactor connectedAndroidTest --- build.gradle | 6 +- sdk/build.gradle | 6 +- sdk/src/androidTest/assets/hcaptcha-form.html | 38 ++++++ .../java/com/hcaptcha/sdk/AssertUtil.java | 47 ++++++- .../sdk/HCaptchaDialogFragmentTest.java | 126 ++++++++++++++---- .../hcaptcha/sdk/HCaptchaDialogFragment.java | 12 +- 6 files changed, 194 insertions(+), 41 deletions(-) create mode 100644 sdk/src/androidTest/assets/hcaptcha-form.html diff --git a/build.gradle b/build.gradle index 60279d70..ce622ead 100644 --- a/build.gradle +++ b/build.gradle @@ -2,10 +2,10 @@ buildscript { repositories { google() - jcenter() + mavenCentral() } dependencies { - classpath 'com.android.tools.build:gradle:4.1.1' + classpath 'com.android.tools.build:gradle:4.1.3' // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files @@ -15,6 +15,6 @@ buildscript { allprojects { repositories { google() - jcenter() + mavenCentral() } } diff --git a/sdk/build.gradle b/sdk/build.gradle index 8e4c4096..6025060f 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -40,19 +40,19 @@ dependencies { implementation fileTree(dir: "libs", include: ["*.jar"]) implementation 'androidx.appcompat:appcompat:1.2.0' implementation 'com.fasterxml.jackson.core:jackson-databind:2.12.1' - implementation 'org.projectlombok:lombok:1.18.16' + compileOnly 'org.projectlombok:lombok:1.18.16' annotationProcessor 'org.projectlombok:lombok:1.18.16' testImplementation 'junit:junit:4.13.1' testImplementation 'org.mockito:mockito-core:3.6.28' testImplementation 'org.skyscreamer:jsonassert:1.5.0' - androidTestImplementation 'androidx.test:core:1.3.0' androidTestImplementation 'androidx.test:runner:1.3.0' androidTestImplementation 'androidx.test:rules:1.3.0' - androidTestImplementation 'androidx.test.ext:junit:1.1.2' + androidTestImplementation 'androidx.test.ext:junit:1.1.3' androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'androidx.test.espresso:espresso-web:3.3.0' androidTestImplementation 'org.hamcrest:hamcrest-library:1.3' debugImplementation 'androidx.fragment:fragment-testing:1.3.0' diff --git a/sdk/src/androidTest/assets/hcaptcha-form.html b/sdk/src/androidTest/assets/hcaptcha-form.html new file mode 100644 index 00000000..83f9bfd7 --- /dev/null +++ b/sdk/src/androidTest/assets/hcaptcha-form.html @@ -0,0 +1,38 @@ + + + + + +
+ + + + + + + + + diff --git a/sdk/src/androidTest/java/com/hcaptcha/sdk/AssertUtil.java b/sdk/src/androidTest/java/com/hcaptcha/sdk/AssertUtil.java index c2de9b66..c9309046 100644 --- a/sdk/src/androidTest/java/com/hcaptcha/sdk/AssertUtil.java +++ b/sdk/src/androidTest/java/com/hcaptcha/sdk/AssertUtil.java @@ -4,38 +4,40 @@ import androidx.test.espresso.PerformException; import androidx.test.espresso.UiController; import androidx.test.espresso.ViewAction; +import androidx.test.espresso.ViewAssertion; import androidx.test.espresso.util.HumanReadables; import androidx.test.espresso.util.TreeIterables; import org.hamcrest.Matcher; import java.util.concurrent.TimeoutException; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; import static androidx.test.espresso.matcher.ViewMatchers.*; +import static org.hamcrest.CoreMatchers.any; +import static org.hamcrest.CoreMatchers.not; public class AssertUtil { - public static ViewAction waitToBeDisplayed(final int viewId, final long millis) { + public static ViewAction waitToBeDisplayed(final long millis) { return new ViewAction() { @Override public Matcher getConstraints() { - return isRoot(); + return any(View.class); } @Override public String getDescription() { - return "wait for view (id: " + viewId + ") for " + millis + " ms"; + return "wait for view displayed for " + millis + " ms"; } @Override public void perform(final UiController uiController, final View view) { uiController.loopMainThreadUntilIdle(); - final long startTime = System.currentTimeMillis(); - final long endTime = startTime + millis; - final Matcher viewMatcher = withId(viewId); + final long endTime = System.currentTimeMillis() + millis; final Matcher viewIsDisplayed = isDisplayed(); do { for (View child : TreeIterables.breadthFirstViewTraversal(view)) { - if (viewMatcher.matches(child) && viewIsDisplayed.matches(child)) { + if (viewIsDisplayed.matches(child)) { return; } } @@ -51,4 +53,35 @@ public void perform(final UiController uiController, final View view) { }; } + public static ViewAction waitToDisappear(final long millis) { + return new ViewAction() { + @Override + public Matcher getConstraints() { + return any(View.class); + } + + @Override + public String getDescription() { + return "wait for view to be gone for " + millis + " ms "; + } + + @Override + public void perform(final UiController uiController, final View view) { + long endTime = System.currentTimeMillis() + millis; + + do { + if (view.getVisibility() == View.GONE || view.getVisibility() == View.INVISIBLE) { + return; + } + uiController.loopMainThreadForAtLeast(50); + } while (System.currentTimeMillis() < endTime); + + throw new PerformException.Builder() + .withActionDescription(this.getDescription()) + .withViewDescription(HumanReadables.describe(view)) + .withCause(new TimeoutException()) + .build(); + } + }; + } } diff --git a/sdk/src/androidTest/java/com/hcaptcha/sdk/HCaptchaDialogFragmentTest.java b/sdk/src/androidTest/java/com/hcaptcha/sdk/HCaptchaDialogFragmentTest.java index 3c2102a4..ed6a07ba 100644 --- a/sdk/src/androidTest/java/com/hcaptcha/sdk/HCaptchaDialogFragmentTest.java +++ b/sdk/src/androidTest/java/com/hcaptcha/sdk/HCaptchaDialogFragmentTest.java @@ -1,69 +1,141 @@ package com.hcaptcha.sdk; import android.os.Bundle; -import android.webkit.WebView; +import android.util.Log; + import androidx.fragment.app.testing.FragmentScenario; +import androidx.test.espresso.web.webdriver.DriverAtoms; +import androidx.test.espresso.web.webdriver.Locator; import androidx.test.ext.junit.runners.AndroidJUnit4; -import org.jetbrains.annotations.NotNull; + import org.junit.Test; import org.junit.runner.RunWith; import static androidx.test.espresso.Espresso.onView; import static androidx.test.espresso.assertion.ViewAssertions.matches; import static androidx.test.espresso.matcher.ViewMatchers.*; +import static androidx.test.espresso.web.sugar.Web.onWebView; +import static androidx.test.espresso.web.webdriver.DriverAtoms.clearElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.findElement; +import static androidx.test.espresso.web.webdriver.DriverAtoms.webClick; import static com.hcaptcha.sdk.AssertUtil.waitToBeDisplayed; +import static com.hcaptcha.sdk.AssertUtil.waitToDisappear; import static com.hcaptcha.sdk.HCaptchaDialogFragment.KEY_CONFIG; import static com.hcaptcha.sdk.HCaptchaDialogFragment.KEY_LISTENER; import static org.hamcrest.Matchers.not; +import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertNotNull; +import static org.junit.Assert.assertNull; +import static org.junit.Assert.assertTrue; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; @RunWith(AndroidJUnit4.class) public class HCaptchaDialogFragmentTest { + public class HCaptchaDialogTestAdapter extends HCaptchaDialogListener { + @Override + void onSuccess(HCaptchaTokenResponse hCaptchaTokenResponse) { + } + + @Override + void onFailure(HCaptchaException hCaptchaException) { + } + }; + + public FragmentScenario launchCaptchaFragment() { + return launchCaptchaFragment(true); + } + + public FragmentScenario launchCaptchaFragment(boolean showLoader) { + return launchCaptchaFragment(showLoader, new HCaptchaDialogTestAdapter()); + } + + public FragmentScenario launchCaptchaFragment(HCaptchaDialogListener listener) { + return launchCaptchaFragment(true, listener); + } - public FragmentScenario getTestScenario() { + public FragmentScenario launchCaptchaFragment(boolean showLoader, HCaptchaDialogListener listener) { final HCaptchaConfig hCaptchaConfig = HCaptchaConfig.builder() .siteKey("10000000-ffff-ffff-ffff-000000000001") .endpoint("https://js.hcaptcha.com/1/api.js") .locale("en") + .loading(showLoader) .size(HCaptchaSize.INVISIBLE) .theme(HCaptchaTheme.LIGHT) .build(); final Bundle args = new Bundle(); args.putSerializable(KEY_CONFIG, hCaptchaConfig); - args.putParcelable(KEY_LISTENER, new HCaptchaDialogListener() { - @Override - void onSuccess(HCaptchaTokenResponse hCaptchaTokenResponse) { - } - - @Override - void onFailure(HCaptchaException hCaptchaException) { - } - }); - return FragmentScenario.launch(HCaptchaDialogFragment.class, args); + args.putParcelable(KEY_LISTENER, listener); + return FragmentScenario.launchInContainer(HCaptchaDialogFragment.class, args); } @Test - public void loader_is_visible_while_webview_is_loading() { - getTestScenario(); + public void loaderVisible() { + launchCaptchaFragment(); onView(withId(R.id.loadingContainer)).check(matches(isDisplayed())); - onView(withId(R.id.webView)).check(matches(not(isDisplayed()))); + onView(withId(R.id.webView)).perform(waitToBeDisplayed(1000)); + onView(withId(R.id.loadingContainer)).perform(waitToDisappear(10000)); + } + + @Test + public void loaderDisabled() { + launchCaptchaFragment(false); + onView(withId(R.id.loadingContainer)).check(matches(not(isDisplayed()))); + onView(withId(R.id.webView)).perform(waitToBeDisplayed(1000)); } @Test - public void webview_is_visible_after_loading() { - final FragmentScenario scenario = getTestScenario(); - scenario.onFragment(new FragmentScenario.FragmentAction() { + public void webViewReturnToken() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final HCaptchaDialogListener listener = new HCaptchaDialogTestAdapter() { @Override - public void perform(@NotNull HCaptchaDialogFragment fragment) { - assertNotNull(fragment.getDialog()); - WebView.setWebContentsDebuggingEnabled(true); - fragment.onLoaded(); + void onSuccess(HCaptchaTokenResponse hCaptchaTokenResponse) { + assertEquals("test-token", hCaptchaTokenResponse.getTokenResult()); + latch.countDown(); } - }); - onView(withId(R.id.loadingContainer)).check(matches(not(isDisplayed()))); - onView(withId(R.id.webView)).check(matches(isDisplayed())); - onView(isRoot()).perform(waitToBeDisplayed(R.id.webView, 1000)); + }; + + final FragmentScenario scenario = launchCaptchaFragment(listener); + onView(withId(R.id.webView)).perform(waitToBeDisplayed(1000)); + + onWebView(withId(R.id.webView)).forceJavascriptEnabled(); + + onWebView().withElement(findElement(Locator.ID, "input-text")) + .perform(clearElement()) + .perform(DriverAtoms.webKeys("test-token")); + + onWebView().withElement(findElement(Locator.ID, "on-pass")) + .perform(webClick()); + + assertTrue(latch.await(1000, TimeUnit.MILLISECONDS)); // wait for callback } + @Test + public void webViewReturnsError() throws Exception { + CountDownLatch latch = new CountDownLatch(1); + final HCaptchaDialogListener listener = new HCaptchaDialogTestAdapter() { + @Override + void onFailure(HCaptchaException hCaptchaException) { + assertEquals(HCaptchaError.SESSION_TIMEOUT, hCaptchaException.getHCaptchaError()); + latch.countDown(); + } + }; + + final FragmentScenario scenario = launchCaptchaFragment(listener); + onView(withId(R.id.webView)).perform(waitToBeDisplayed(1000)); + + onWebView(withId(R.id.webView)).forceJavascriptEnabled(); + + onWebView().withElement(findElement(Locator.ID, "input-text")) + .perform(clearElement()) + .perform(DriverAtoms.webKeys( + String.valueOf(HCaptchaError.SESSION_TIMEOUT.getErrorId()))); + + onWebView().withElement(findElement(Locator.ID, "on-error")) + .perform(webClick()); + + assertTrue(latch.await(1000, TimeUnit.MILLISECONDS)); // wait for callback + } } diff --git a/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaDialogFragment.java b/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaDialogFragment.java index fefb901a..5b6c3cc4 100644 --- a/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaDialogFragment.java +++ b/sdk/src/main/java/com/hcaptcha/sdk/HCaptchaDialogFragment.java @@ -1,5 +1,7 @@ package com.hcaptcha.sdk; +import android.animation.Animator; +import android.animation.AnimatorListenerAdapter; import android.annotation.SuppressLint; import android.app.Dialog; import android.content.DialogInterface; @@ -20,6 +22,7 @@ import static com.hcaptcha.sdk.HCaptchaJSInterface.JS_INTERFACE_TAG; + /** * HCaptcha Dialog Fragment Class */ @@ -163,7 +166,14 @@ public void onLoaded() { @Override public void run() { if (showLoader) { - loadingContainer.animate().alpha(0.0f).setDuration(200); + loadingContainer.animate().alpha(0.0f).setDuration(200) + .setListener( + new AnimatorListenerAdapter() { + @Override + public void onAnimationEnd(Animator animation) { + loadingContainer.setVisibility(View.GONE); + } + }); } else { // Add back dialog shadow in case the checkbox or challenge is shown final Dialog dialog = getDialog(); From b1140b1fa455f5f37059a7218df7e4c52c967ec1 Mon Sep 17 00:00:00 2001 From: Alexande B Date: Thu, 27 Jan 2022 13:51:26 +0100 Subject: [PATCH 2/4] #11 add job to run ui-tests on CI --- .github/workflows/ci.yml | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 19071cfe..b5b5e102 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,13 +2,14 @@ name: Android SDK CI on: push jobs: build: - name: Build & Test + name: Build & Unit-test runs-on: ubuntu-latest steps: - uses: actions/checkout@v1 - - uses: actions/setup-java@v1 + - uses: actions/setup-java@v2 with: - java-version: '1.8' + java-version: '8' + distribution: adopt cache: 'gradle' - name: Assemble & Test run: ./gradlew build --stacktrace @@ -18,3 +19,25 @@ jobs: jshint --extract=always sdk/src/main/assets/hcaptcha-form.html - name: JitPack Test run: ./gradlew publishReleasePublicationToMavenLocal + ui-tests: + name: Android UI Tests + runs-on: macos-latest + strategy: + fail-fast: false + matrix: + api-level: [29] # , 23, 21] + target: [default] #, google_apis] + steps: + - uses: actions/checkout@v2 + - uses: actions/setup-java@v2 + with: + java-version: '8' + distribution: adopt + cache: 'gradle' + - uses: reactivecircus/android-emulator-runner@v2 + with: + api-level: ${{ matrix.api-level }} + target: ${{ matrix.target }} + arch: x86_64 + profile: Nexus 6 + script: ./gradlew connectedCheck From 08cf8e9208dddb4a093a05fd3193ab0cef06e578 Mon Sep 17 00:00:00 2001 From: Alexande B Date: Sat, 29 Jan 2022 12:57:36 +0100 Subject: [PATCH 3/4] Add debug info --- sdk/build.gradle | 2 + sdk/src/androidTest/assets/hcaptcha-form.html | 1 + sdk/src/main/assets/hcaptcha-form.html | 1 + .../com/hcaptcha/sdk/HCaptchaDebugInfo.java | 89 +++++++++++++++++++ .../hcaptcha/sdk/HCaptchaDialogFragment.java | 11 ++- 5 files changed, 100 insertions(+), 4 deletions(-) create mode 100644 sdk/src/main/java/com/hcaptcha/sdk/HCaptchaDebugInfo.java diff --git a/sdk/build.gradle b/sdk/build.gradle index 6025060f..a2a961b4 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -18,6 +18,8 @@ android { // should follow semantic versioning (See https://semver.org) versionName "1.2.0" + buildConfigField 'String', 'VERSION_NAME', "\"${defaultConfig.versionName}_${defaultConfig.versionCode}\"" + testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" proguardFiles getDefaultProguardFile('proguard-android-optimize.txt'), 'proguard-rules.pro' diff --git a/sdk/src/androidTest/assets/hcaptcha-form.html b/sdk/src/androidTest/assets/hcaptcha-form.html index 83f9bfd7..fe3818cb 100644 --- a/sdk/src/androidTest/assets/hcaptcha-form.html +++ b/sdk/src/androidTest/assets/hcaptcha-form.html @@ -11,6 +11,7 @@