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 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..d6eb8e56 100644 --- a/sdk/build.gradle +++ b/sdk/build.gradle @@ -12,11 +12,13 @@ android { // See https://developer.android.com/studio/publish/versioning // versionCode must be integer and be incremented by one for every new update // android system uses this to prevent downgrades - versionCode 7 + versionCode 8 // version number visible to the user // should follow semantic versioning (See https://semver.org) - versionName "1.2.0" + versionName "1.3.0" + + buildConfigField 'String', 'VERSION_NAME', "\"${defaultConfig.versionName}_${defaultConfig.versionCode}\"" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" consumerProguardFiles "consumer-rules.pro" @@ -40,19 +42,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..fe3818cb --- /dev/null +++ b/sdk/src/androidTest/assets/hcaptcha-form.html @@ -0,0 +1,39 @@ + + + + + +
+ + + + + + + + + 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/assets/hcaptcha-form.html b/sdk/src/main/assets/hcaptcha-form.html index 002858b5..88c23bcc 100644 --- a/sdk/src/main/assets/hcaptcha-form.html +++ b/sdk/src/main/assets/hcaptcha-form.html @@ -28,6 +28,7 @@