diff --git a/.github/workflows/run-test.yml b/.github/workflows/run-test.yml index 65b04d4b1..aba3b72fe 100644 --- a/.github/workflows/run-test.yml +++ b/.github/workflows/run-test.yml @@ -37,6 +37,12 @@ jobs: - name: Run test run: ./gradlew test + env: + LOUDIUS_CLIENT_SECRET: ${{ secrets.LOUDIUS_CLIENT_SECRET }} + LOUDIUS_CLIENT_ID: ${{ secrets.LOUDIUS_CLIENT_ID }} + LOUDIUS_GITHUB_USER_PASSWORD: ${{ secrets.LOUDIUS_GITHUB_USER_PASSWORD }} + LOUDIUS_GITHUB_USER_NAME: ${{ secrets.LOUDIUS_GITHUB_USER_NAME }} + LOUDIUS_GITHUB_USER_OTP_SECRET: ${{ secrets.LOUDIUS_GITHUB_USER_OTP_SECRET }} - name: Upload tests results if: always() @@ -66,6 +72,12 @@ jobs: - name: Assemble App Debug APK and Android Instrumentation Tests run: ./gradlew assembleDebug assembleDebugAndroidTest + env: + LOUDIUS_CLIENT_SECRET: ${{ secrets.LOUDIUS_CLIENT_SECRET }} + LOUDIUS_CLIENT_ID: ${{ secrets.LOUDIUS_CLIENT_ID }} + LOUDIUS_GITHUB_USER_PASSWORD: ${{ secrets.LOUDIUS_GITHUB_USER_PASSWORD }} + LOUDIUS_GITHUB_USER_NAME: ${{ secrets.LOUDIUS_GITHUB_USER_NAME }} + LOUDIUS_GITHUB_USER_OTP_SECRET: ${{ secrets.LOUDIUS_GITHUB_USER_OTP_SECRET }} - id: auth name: Authenticate to Google Cloud diff --git a/README.md b/README.md index 0ca85f4b8..94b5f4ea0 100644 --- a/README.md +++ b/README.md @@ -66,22 +66,33 @@ Here's an example of an experiment that meets our rules: ## 🚀 Project setup -In order to properly start the application and use it, the CLIENT_SECRET environment variable must -be set on your computer. CLIENT_SECRET is a GitHub client secret key provided -from ``Settings -> Developer Settings -> OAuth Apps -> my application``. +In order to properly start the application and use it, the `LOUDIUS_CLIENT_SECRET` and +`LOUDIUS_CLIENT_ID` environment variables must be set on your computer. -If you're AppUniter, you can find this secrets [here](https://www.notion.so/appunite/Github-Secrets-0c2c6c1b56e2472c8a4752241f1e20d3?pvs=4). +* `LOUDIUS_CLIENT_SECRET` is a GitHub client secret key +* `LOUDIUS_CLIENT_ID` is a GitHub client id +* +both are provided from ``Settings -> Developer Settings -> OAuth Apps -> my application``. -If you're not, don't worry, here's a video to help you create a new one: +If you're not AppUniter, here's a video to help you create such appliation: - +. + +If you'd like to run end-to-end tests you'd also need `LOUDIUS_GITHUB_USER_NAME` and +`LOUDIUS_GITHUB_USER_PASSWORD` which are credentials to GitHub test account. +This is just a standard GitHub account that you can create by yourself. + +If you're AppUniter, you can find those secrets [here](https://www.notion.so/appunite/Github-Secrets-0c2c6c1b56e2472c8a4752241f1e20d3?pvs=4). ### How to set environmental variable on mac? 1. Launch zsh (command `zsh`) -2. `$ echo 'export CLIENT_SECRET=you know what' >> ~/.zshenv` -3. Restart Android studio and Terminal. -4. `$ echo $CLIENT_SECRET` +2. `$ echo 'export LOUDIUS_CLIENT_SECRET="you know what"' >> ~/.zshenv` +3. `$ echo 'export LOUDIUS_CLIENT_ID="you know what"' >> ~/.zshenv` +4. optionally: `$ echo 'export LOUDIUS_GITHUB_USER_NAME="you know what"' >> ~/.zshenv` +5. optionally: `$ echo 'export LOUDIUS_GITHUB_USER_PASSWORD="you know what"' >> ~/.zshenv` +6. Restart Android studio and Terminal. +7. `$ echo $LOUDIUS_CLIENT_ID/$LOUDIUS_CLIENT_SECRET $LOUDIUS_GITHUB_USER_NAME/$LOUDIUS_GITHUB_USER_PASSWORD` ### Screenshots tests diff --git a/app-shared-tests/build.gradle b/app-shared-tests/build.gradle index ad6102989..a6b226bed 100644 --- a/app-shared-tests/build.gradle +++ b/app-shared-tests/build.gradle @@ -17,6 +17,18 @@ android { versionName "1.0" testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + if (System.env.LOUDIUS_GITHUB_USER_PASSWORD == null || System.env.LOUDIUS_GITHUB_USER_PASSWORD.isEmpty()) { + logger.warn("You need to set LOUDIUS_GITHUB_USER_PASSWORD in your environment variables") + } + buildConfigField "String", "LOUDIUS_GITHUB_USER_PASSWORD", "\"${System.env.LOUDIUS_GITHUB_USER_PASSWORD}\"" + if (System.env.LOUDIUS_GITHUB_USER_NAME == null || System.env.LOUDIUS_GITHUB_USER_NAME.isEmpty()) { + logger.warn("You need to set LOUDIUS_GITHUB_USER_NAME in your environment variables") + } + buildConfigField "String", "LOUDIUS_GITHUB_USER_NAME", "\"${System.env.LOUDIUS_GITHUB_USER_NAME}\"" + if (System.env.LOUDIUS_GITHUB_USER_OTP_SECRET == null || System.env.LOUDIUS_GITHUB_USER_OTP_SECRET.isEmpty()) { + logger.warn("You need to set LOUDIUS_GITHUB_USER_OTP_SECRET in your environment variables") + } + buildConfigField "String", "LOUDIUS_GITHUB_USER_OTP_SECRET", "\"${System.env.LOUDIUS_GITHUB_USER_OTP_SECRET}\"" } buildFeatures { compose true @@ -92,6 +104,7 @@ dependencies { // Firebase instrumentation lib api 'com.google.firebase:testlab-instr-lib:0.2' + api 'dev.samstevens.totp:totp:1.7.1' // ktlint ktlintRuleset project(":custom-ktlint-rules") diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/AbsPullRequestsScreenTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/AbsPullRequestsScreenTest.kt index c36ca466d..cd38e2b50 100644 --- a/app-shared-tests/src/main/java/com/appunite/loudius/AbsPullRequestsScreenTest.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/AbsPullRequestsScreenTest.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.test.onNodeWithText import com.appunite.loudius.components.theme.LoudiusTheme import com.appunite.loudius.ui.pullrequests.PullRequestsScreen import com.appunite.loudius.util.IntegrationTestRule +import com.appunite.loudius.util.MockWebServerRule import com.appunite.loudius.util.Register import com.appunite.loudius.util.waitUntilLoadingDoesNotExist import org.junit.Before @@ -29,9 +30,12 @@ import org.junit.Test abstract class AbsPullRequestsScreenTest { - @get:Rule + @get:Rule(order = 0) val integrationTestRule by lazy { IntegrationTestRule(this) } + @get:Rule(order = 1) + var mockWebServer: MockWebServerRule = MockWebServerRule() + @Before fun setUp() { integrationTestRule.setUp() diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/AbsReviewersScreenTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/AbsReviewersScreenTest.kt index 2fc4f33fd..99c8ad9dd 100644 --- a/app-shared-tests/src/main/java/com/appunite/loudius/AbsReviewersScreenTest.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/AbsReviewersScreenTest.kt @@ -22,6 +22,7 @@ import androidx.compose.ui.test.performClick import com.appunite.loudius.components.theme.LoudiusTheme import com.appunite.loudius.ui.reviewers.ReviewersScreen import com.appunite.loudius.util.IntegrationTestRule +import com.appunite.loudius.util.MockWebServerRule import com.appunite.loudius.util.Register import com.appunite.loudius.util.waitUntilLoadingDoesNotExist import org.junit.Before @@ -30,9 +31,12 @@ import org.junit.Test abstract class AbsReviewersScreenTest { - @get:Rule + @get:Rule(order = 0) val integrationTestRule by lazy { IntegrationTestRule(this) } + @get:Rule(order = 1) + var mockWebServer: MockWebServerRule = MockWebServerRule() + @Before fun setUp() { integrationTestRule.initTests() diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/AbsWalkThroughAppTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/AbsWalkThroughAppTest.kt index 682009090..da8ffa556 100644 --- a/app-shared-tests/src/main/java/com/appunite/loudius/AbsWalkThroughAppTest.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/AbsWalkThroughAppTest.kt @@ -20,35 +20,25 @@ import android.app.Activity import android.app.Instrumentation import android.content.Intent import android.net.Uri -import androidx.compose.ui.test.assertIsDisplayed -import androidx.compose.ui.test.onNodeWithText -import androidx.compose.ui.test.performClick import androidx.test.core.app.ActivityScenario import androidx.test.espresso.intent.Intents.intending import androidx.test.espresso.intent.matcher.IntentMatchers import androidx.test.espresso.intent.rule.IntentsRule -import com.appunite.loudius.util.IntegrationTestRule +import com.appunite.loudius.util.MockWebServerRule import com.appunite.loudius.util.Register -import com.appunite.loudius.util.waitUntilLoadingDoesNotExist import org.junit.Before import org.junit.Rule -import org.junit.Test -abstract class AbsWalkThroughAppTest { - - @get:Rule(order = 0) - val integrationTestRule by lazy { IntegrationTestRule(this, MainActivity::class.java) } +abstract class AbsWalkThroughAppTest : UniversalWalkThroughAppTest() { @get:Rule(order = 1) + var mockWebServer: MockWebServerRule = MockWebServerRule() + + @get:Rule(order = 2) val intents = IntentsRule() @Before - fun setUp() { - integrationTestRule.setUp() - } - - @Test - fun whenLoginScreenIsVisible_LoginButtonOpensGithubAuth(): Unit = with(integrationTestRule) { + fun prepareMocks() { Register.run { user(mockWebServer) accessToken(mockWebServer) @@ -62,31 +52,17 @@ abstract class AbsWalkThroughAppTest { .respondWithFunction { Instrumentation.ActivityResult(Activity.RESULT_OK, null) } + } - composeTestRule.onNodeWithText("Log in").performClick() - + override fun performGitHubLogin() { // simulate opening a deeplink ActivityScenario.launch( Intent( Intent.ACTION_VIEW, Uri.parse("loudius://callback?code=example_code"), ).apply { - setPackage(composeTestRule.activity.packageName) + setPackage(integrationTestRule.composeTestRule.activity.packageName) }, ) - - composeTestRule.waitUntilLoadingDoesNotExist() - - composeTestRule.onNodeWithText("First Pull-Request title").performClick() - - composeTestRule.waitUntilLoadingDoesNotExist() - - composeTestRule.onNodeWithText("Notify").performClick() - - composeTestRule.waitUntilLoadingDoesNotExist() - - composeTestRule - .onNodeWithText("Awesome! Your collaborator have been pinged for some serious code review action! \uD83C\uDF89") - .assertIsDisplayed() } } diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/UniversalWalkThroughAppTest.kt b/app-shared-tests/src/main/java/com/appunite/loudius/UniversalWalkThroughAppTest.kt new file mode 100644 index 000000000..0b83b6b6f --- /dev/null +++ b/app-shared-tests/src/main/java/com/appunite/loudius/UniversalWalkThroughAppTest.kt @@ -0,0 +1,60 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import androidx.compose.ui.test.assertIsDisplayed +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import com.appunite.loudius.util.IntegrationTestRule +import com.appunite.loudius.util.waitUntilLoadingDoesNotExist +import org.junit.Before +import org.junit.Rule +import org.junit.Test + +abstract class UniversalWalkThroughAppTest { + + @get:Rule(order = 0) + val integrationTestRule by lazy { IntegrationTestRule(this, MainActivity::class.java) } + + @Before + fun setUp() { + integrationTestRule.setUp() + } + + @Test + fun whenLoginScreenIsVisible_LoginButtonOpensGithubAuth(): Unit = with(integrationTestRule) { + composeTestRule.onNodeWithText("Log in").performClick() + + performGitHubLogin() + + composeTestRule.waitUntilLoadingDoesNotExist() + + composeTestRule.onNodeWithText("First Pull-Request title").performClick() + + composeTestRule.waitUntilLoadingDoesNotExist() + + composeTestRule.onNodeWithText("Notify").performClick() + + composeTestRule.waitUntilLoadingDoesNotExist() + + composeTestRule + .onNodeWithText("Awesome! Your collaborator have been pinged for some serious code review action! \uD83C\uDF89") + .assertIsDisplayed() + } + + abstract fun performGitHubLogin() +} diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/util/Described.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/Described.kt new file mode 100644 index 000000000..b6f11268e --- /dev/null +++ b/app-shared-tests/src/main/java/com/appunite/loudius/util/Described.kt @@ -0,0 +1,57 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.util + +/** + * Adds description to a test, so failures are better recorded + * + * Example usage: + * ```kt + * description("Log-in") { + * description("Fill e-mail and password") { + * onView(withHint("E-mail")).perform(type("jacek.marchwicki@gmail.com")) + * onView(withHint("Password")).perform(type("password")) + * } + * description("Submit") { + * onView(withId(R.id.login_button)).perform(click()) + * } + * } + * description("Ensure home screen is displayed") { + * onView(withClass("com.example.com.LoginScreen")).check(isDisplayed()) + * } + * ``` + * + * In case of failure, you'll see: + * + * ``` + * Exception thrown DescriptionAssertionError("Error in step: Log-in -> Fill e-mail and password") + * ``` + * + * The exception is always thrown with the root cause attached. + */ +fun description(description: String, lambda: () -> T): T { + try { + return lambda() + } catch (error: DescriptionAssertionError) { + throw DescriptionAssertionError("$description -> ${error.step}", error.cause!!) + } catch (error: AssertionError) { + throw DescriptionAssertionError(description, error) + } +} + +class DescriptionAssertionError(val step: String, cause: Throwable) : + AssertionError("Error in step: \"$step\"", cause) diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/util/IntegrationTestRule.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/IntegrationTestRule.kt index ceace7101..108d960fe 100644 --- a/app-shared-tests/src/main/java/com/appunite/loudius/util/IntegrationTestRule.kt +++ b/app-shared-tests/src/main/java/com/appunite/loudius/util/IntegrationTestRule.kt @@ -32,7 +32,6 @@ class IntegrationTestRule( testActivity: Class = TestActivity::class.java, ) : TestRule { - val mockWebServer = MockWebServerRule() val composeTestRule = createAndroidComposeRule(testActivity).apply { registerIdlingResource(countingResource.toIdlingResource()) } @@ -40,7 +39,7 @@ class IntegrationTestRule( private val screenshotTestRule = ScreenshotTestRule() override fun apply(base: Statement, description: Description): Statement { - return RuleChain.outerRule(mockWebServer) + return RuleChain.emptyRuleChain() .around(hiltRule) .around(composeTestRule) .around(screenshotTestRule) diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/util/KeyGrouping.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/KeyGrouping.kt new file mode 100644 index 000000000..6f1ce7a79 --- /dev/null +++ b/app-shared-tests/src/main/java/com/appunite/loudius/util/KeyGrouping.kt @@ -0,0 +1,50 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +@file:Suppress("ktlint:filename") + +package com.appunite.loudius.util + +import android.view.KeyEvent + +/** + * Optimize typing by grouping characters with the same metaState + * + * UiDevice has UiDevice.pressKeyCode and UiDevice.pressKeyCodes methods. + * Using UiDevice.pressKeyCodes is much faster for typing multiple characters, but + * it couldn't be used for characters with a different metaState (e.g. Alt/Shift/Ctr). + * i.e. to write "Jacek Marchwicki" we need to use: + * * pressKeyCodes "j" with Shift + * * pressKeyCodes "acek " without metaState + * * pressKeyCodes "m" with Shift + * * pressKeyCodes "archwicki " without metaState + * + * so instead of calling "pressKeyCode" 16 times, we call "pressKeyCodes" 4 times - this is + * significantly faster. + * + * This method groups those calls, so we can type faster. + */ +fun groupKeys(list: List): List = list + .filter { it.action == KeyEvent.ACTION_UP } + .fold(listOf()) { acc, event -> + val last = acc.lastOrNull() + if (last != null && last.metaState == event.metaState) { + acc.dropLast(1).plus(KeyTypeEvent(last.keyCodes.plus(event.keyCode), event.metaState)) + } else { + acc.plus(KeyTypeEvent(listOf(event.keyCode), event.metaState)) + } + } + +data class KeyTypeEvent(val keyCodes: List, val metaState: Int) diff --git a/app-shared-tests/src/main/java/com/appunite/loudius/util/TestUtils.kt b/app-shared-tests/src/main/java/com/appunite/loudius/util/TestUtils.kt new file mode 100644 index 000000000..3d0b97044 --- /dev/null +++ b/app-shared-tests/src/main/java/com/appunite/loudius/util/TestUtils.kt @@ -0,0 +1,41 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.util + +import com.appunite.loudius.BuildConfig +import dev.samstevens.totp.code.DefaultCodeGenerator +import dev.samstevens.totp.code.DefaultCodeVerifier +import dev.samstevens.totp.time.SystemTimeProvider +import strikt.api.expectThat +import strikt.assertions.isTrue + +const val githubUserPassword = BuildConfig.LOUDIUS_GITHUB_USER_PASSWORD +const val githubUserName = BuildConfig.LOUDIUS_GITHUB_USER_NAME +const val githubOtpSecret = BuildConfig.LOUDIUS_GITHUB_USER_OTP_SECRET + +/** + * Generates Google Authenticator One Time Password for Log-in via Github + */ +fun generateOtp(): String { + val timeProvider = SystemTimeProvider() + val codeGenerator = DefaultCodeGenerator() + val code = codeGenerator.generate(githubOtpSecret, Math.floorDiv(timeProvider.time, 30L)) + val verifier = DefaultCodeVerifier(codeGenerator, timeProvider) + + expectThat(verifier.isValidCode(githubOtpSecret, code)).isTrue() + return code +} diff --git a/app-shared-tests/src/test/java/com/appunite/loudius/util/DescribedKtTest.kt b/app-shared-tests/src/test/java/com/appunite/loudius/util/DescribedKtTest.kt new file mode 100644 index 000000000..39c37dcb9 --- /dev/null +++ b/app-shared-tests/src/test/java/com/appunite/loudius/util/DescribedKtTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.util + +import org.junit.Test +import strikt.api.expectCatching +import strikt.assertions.isA +import strikt.assertions.isEqualTo +import strikt.assertions.isFailure +import strikt.assertions.isNotNull +import strikt.assertions.isSuccess +import java.lang.Exception + +class DescribedKtTest { + + @Test + fun `test without failure, is success`() { + expectCatching { + description("test without failure") { + } + } + .isSuccess() + } + + @Test + fun `test with unknown error, is not described`() { + expectCatching { + description("test with assertion error") { + throw Exception("Some error") + } + } + .isFailure() + .isA() + .get(Exception::message) + .isEqualTo("Some error") + } + + @Test + fun `test with assertion error, is described`() { + expectCatching { + description("test with assertion error") { + throw AssertionError("Some error") + } + } + .isFailure() + .isA() + .and { + get(DescriptionAssertionError::message).isEqualTo("Error in step: \"test with assertion error\"") + get(DescriptionAssertionError::cause).isNotNull() + .get(Throwable::message) + .isEqualTo("Some error") + } + } + + @Test + fun `test with multiple descriptions, descriptions are merged`() { + expectCatching { + description("first description") { + description("second description") { + throw AssertionError("Some error") + } + } + } + .isFailure() + .isA() + .and { + get(DescriptionAssertionError::message).isEqualTo("Error in step: \"first description -> second description\"") + get(DescriptionAssertionError::cause).isNotNull() + .get(Throwable::message) + .isEqualTo("Some error") + } + } +} diff --git a/app-shared-tests/src/test/java/com/appunite/loudius/util/KeyGroupingKtTest.kt b/app-shared-tests/src/test/java/com/appunite/loudius/util/KeyGroupingKtTest.kt new file mode 100644 index 000000000..3019f8c23 --- /dev/null +++ b/app-shared-tests/src/test/java/com/appunite/loudius/util/KeyGroupingKtTest.kt @@ -0,0 +1,110 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.util + +import android.view.KeyEvent +import io.mockk.every +import io.mockk.mockk +import org.junit.Test +import strikt.api.expectThat +import strikt.assertions.containsExactly +import strikt.assertions.isEmpty + +class KeyGroupingKtTest { + @Test + fun `empty list`() { + val grouped = groupKeys(listOf()) + expectThat(grouped).isEmpty() + } + + @Test + fun `pass events without arguments`() { + val grouped = groupKeys( + listOf( + mockKeyEvent(1), + mockKeyEvent(2), + mockKeyEvent(3), + ), + ) + expectThat(grouped) + .containsExactly( + KeyTypeEvent(listOf(1, 2, 3), 0), + ) + } + + @Test + fun `ignore events with no action up`() { + val grouped = groupKeys( + listOf( + mockKeyEvent(1, action = KeyEvent.ACTION_DOWN), + mockKeyEvent(1, action = KeyEvent.ACTION_UP), + mockKeyEvent(2, action = KeyEvent.ACTION_DOWN), + mockKeyEvent(2, action = KeyEvent.ACTION_UP), + ), + ) + expectThat(grouped) + .containsExactly( + KeyTypeEvent(listOf(1, 2), 0), + ) + } + + @Test + fun `don't group events with different meta state`() { + val grouped = groupKeys( + listOf( + mockKeyEvent(1, metaState = 1), + mockKeyEvent(2, metaState = 2), + mockKeyEvent(3, metaState = 3), + ), + ) + expectThat(grouped) + .containsExactly( + KeyTypeEvent(listOf(1), 1), + KeyTypeEvent(listOf(2), 2), + KeyTypeEvent(listOf(3), 3), + ) + } + + @Test + fun `group events with the same meta state`() { + val grouped = groupKeys( + listOf( + mockKeyEvent(1, metaState = 1), + mockKeyEvent(2, metaState = 1), + mockKeyEvent(3, metaState = 2), + mockKeyEvent(4, metaState = 2), + mockKeyEvent(5, metaState = 1), + ), + ) + expectThat(grouped) + .containsExactly( + KeyTypeEvent(listOf(1, 2), 1), + KeyTypeEvent(listOf(3, 4), 2), + KeyTypeEvent(listOf(5), 1), + ) + } + + private fun mockKeyEvent( + keyCode: Int, + metaState: Int = 0, + action: Int = KeyEvent.ACTION_UP, + ): KeyEvent = mockk { + every { getKeyCode() } returns keyCode + every { getAction() } returns action + every { getMetaState() } returns metaState + } +} diff --git a/app/build.gradle b/app/build.gradle index 9b278d074..73365c19c 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -22,10 +22,14 @@ android { vectorDrawables { useSupportLibrary true } - if (System.env.CLIENT_SECRET == null || System.env.CLIENT_SECRET.isEmpty()) { - logger.warn("You need to set CLIENT_SECRET in your environment variables") + if (System.env.LOUDIUS_CLIENT_SECRET == null || System.env.LOUDIUS_CLIENT_SECRET.isEmpty()) { + logger.warn("You need to set LOUDIUS_CLIENT_SECRET in your environment variables") } - buildConfigField "String", "CLIENT_SECRET", "\"${System.env.CLIENT_SECRET}\"" + buildConfigField "String", "LOUDIUS_CLIENT_SECRET", "\"${System.env.LOUDIUS_CLIENT_SECRET}\"" + if (System.env.LOUDIUS_CLIENT_ID == null || System.env.LOUDIUS_CLIENT_ID.isEmpty()) { + logger.warn("You need to set LOUDIUS_CLIENT_ID in your environment variables") + } + buildConfigField "String", "LOUDIUS_CLIENT_ID", "\"${System.env.LOUDIUS_CLIENT_ID}\"" } buildTypes { @@ -148,6 +152,7 @@ dependencies { exclude group: 'org.robolectric', module: 'robolectric' } androidTestImplementation "io.mockk:mockk-android:1.13.3" + androidTestImplementation('androidx.test.uiautomator:uiautomator:2.3.0-alpha03') // ktlint ktlintRuleset project(":custom-ktlint-rules") diff --git a/app/src/androidTest/java/com/appunite/loudius/End2EndWalkThroughAppTest.kt b/app/src/androidTest/java/com/appunite/loudius/End2EndWalkThroughAppTest.kt new file mode 100644 index 000000000..1406c9b19 --- /dev/null +++ b/app/src/androidTest/java/com/appunite/loudius/End2EndWalkThroughAppTest.kt @@ -0,0 +1,109 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius + +import android.view.KeyEvent +import androidx.test.ext.junit.runners.AndroidJUnit4 +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.By +import androidx.test.uiautomator.Until +import com.appunite.loudius.util.AutomatorTestRule +import com.appunite.loudius.util.description +import com.appunite.loudius.util.ensure +import com.appunite.loudius.util.generateOtp +import com.appunite.loudius.util.githubUserName +import com.appunite.loudius.util.githubUserPassword +import com.appunite.loudius.util.hasAnyOfObjects +import com.appunite.loudius.util.type +import com.appunite.loudius.util.waitAndFind +import dagger.hilt.android.testing.HiltAndroidTest +import org.junit.Before +import org.junit.Rule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +@HiltAndroidTest +class End2EndWalkThroughAppTest : UniversalWalkThroughAppTest() { + + @get:Rule + val automatorTestRule: AutomatorTestRule = AutomatorTestRule() + + @Before + fun prepareGoogleChrome() { + // Clear Google Chrome user data to ensure that Google Chrome behaves always the same + automatorTestRule.device.executeShellCommand("pm clear com.android.chrome") + } + + override fun performGitHubLogin(): Unit = with(integrationTestRule) { + // Here we need to use `composeTestRule.waitUntil` instead of `device.waitAndFind` + // because compose actions need to finished to execute startActivity(). + composeTestRule.waitUntil(30_000L) { + automatorTestRule.device.findObject(By.pkg("com.android.chrome")) != null + } + + description("Wait for onboarding or the webpage") { + automatorTestRule.device.ensure( + hasAnyOfObjects( + By.text("Accept & continue"), + By.text("Username or email address"), + ), + ) + } + + description("Skip Google Chrome onboarding process") { + val hasOnboarding = + automatorTestRule.device.findObject(By.text("Accept & continue")) != null + if (hasOnboarding) { + automatorTestRule.device.waitAndFind(By.text("Accept & continue")) + .clickAndWait(Until.newWindow(), 3_000L) + + automatorTestRule.device.waitAndFind(By.text("No thanks")) + .clickAndWait(Until.newWindow(), 3_000L) + + automatorTestRule.device.waitAndFind(By.text("No thanks")) + .clickAndWait(Until.newWindow(), 3_000L) + } + } + + description("Fill user name") { + automatorTestRule.device.waitAndFind(By.text("Username or email address")) + .click() + Thread.sleep(5_000L) + automatorTestRule.device.type(githubUserName) + } + + description("Fill password") { + automatorTestRule.device.pressKeyCode(KeyEvent.KEYCODE_TAB) + automatorTestRule.device.type(githubUserPassword) + } + + description("Click log-in") { + automatorTestRule.device.pressEnter() + } + + description("Fill authentication code") { + automatorTestRule.device.waitAndFind(By.text("Verify")) + automatorTestRule.device.type(generateOtp()) + } + + description("Wait for return to the app") { + automatorTestRule.device.waitAndFind( + By.pkg(InstrumentationRegistry.getInstrumentation().targetContext.packageName), + ) + } + } +} diff --git a/app/src/androidTest/java/com/appunite/loudius/util/AutomatorTestRule.kt b/app/src/androidTest/java/com/appunite/loudius/util/AutomatorTestRule.kt new file mode 100644 index 000000000..b97a03f4c --- /dev/null +++ b/app/src/androidTest/java/com/appunite/loudius/util/AutomatorTestRule.kt @@ -0,0 +1,126 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.util + +import android.view.KeyCharacterMap +import androidx.test.platform.app.InstrumentationRegistry +import androidx.test.uiautomator.BySelector +import androidx.test.uiautomator.Condition +import androidx.test.uiautomator.UiDevice +import androidx.test.uiautomator.UiObject2 +import androidx.test.uiautomator.Until +import org.junit.rules.TestWatcher +import org.junit.runner.Description + +class AutomatorTestRule : TestWatcher() { + private var internalDevice: UiDevice? = null + val device: UiDevice get() = internalDevice!! + + override fun starting(description: Description?) { + internalDevice = UiDevice.getInstance(InstrumentationRegistry.getInstrumentation()) + } + + override fun finished(description: Description?) { + internalDevice = null + } +} + +/** + * Type some text + * + * **Example:** + * + * ```kotlin + * device.type("jacek.marchwicki@gmail.com") + * ``` + */ +fun UiDevice.type(text: String) { + val keyMap = KeyCharacterMap.load(KeyCharacterMap.VIRTUAL_KEYBOARD) + waitForIdle() + groupKeys(keyMap.getEvents(text.toCharArray()).toList()) + .forEach { + pressKeyCodes( + it.keyCodes.toIntArray(), + it.metaState, + ) + } + waitForIdle() +} + +/** + * Wait for a view to appear on the screen, and returns it for actions + * + * **Example:** + * + * To wait for "Some button" and click it. + * ```kotlin + * device.waitAndFind(By.text("Some button")) + * .click() + * ``` + */ +fun UiDevice.waitAndFind( + selector: BySelector, +): UiObject2 { + ensure(Until.hasObject(selector)) + + return findObject(selector) ?: throw AssertionError("Could not find object: $selector") +} + +/** + * Ensures given condition is satisfied before continuing executing. + * + * **Example:** + * + * To wait for "Some text" and ensure it is displayed on the screen. + * + * ```kotlin + * device.ensure(Until.hasObject(By.text("Some text")) + * ``` + */ +fun UiDevice.ensure( + condition: Condition, + timeout: Long = 30_000L, +) { + val result = wait(condition, timeout) ?: throw AssertionError("Error in condition") + if (!result) { + throw AssertionError("Could not satisfy: $condition") + } +} + +/** + * Condition that returns true if any one of selectors is displayed + * + * **Example:** + * + * To wait for "Some text" or "Other text" is displayed on the screen: + * + * ```kotlin + * device.ensure(hasAnyOfObjects( + * By.text("Some text"), + * By.text("Other text") + * ) + * ``` + */ +fun hasAnyOfObjects(vararg selectors: BySelector): Condition { + return object : Condition { + override fun apply(device: UiDevice): Boolean = + selectors.any { selector -> device.hasObject(selector) } + + override fun toString(): String = + "hasAnyOfObjects[${selectors.joinToString(separator = ",")}]" + } +} diff --git a/app/src/main/java/com/appunite/loudius/common/Constants.kt b/app/src/main/java/com/appunite/loudius/common/Constants.kt index db864697c..8cf848b47 100644 --- a/app/src/main/java/com/appunite/loudius/common/Constants.kt +++ b/app/src/main/java/com/appunite/loudius/common/Constants.kt @@ -16,6 +16,8 @@ package com.appunite.loudius.common +import com.appunite.loudius.BuildConfig + object Constants { const val AUTH_API_URL = "https://github.com" @@ -23,7 +25,7 @@ object Constants { const val AUTH_PATH = "/login/oauth/authorize" const val NAME_PARAM_CLIENT_ID = "?client_id=" const val SCOPE_PARAM = "&scope=repo" - const val CLIENT_ID = "91131449e417c7e29912" + const val CLIENT_ID = BuildConfig.LOUDIUS_CLIENT_ID const val REDIRECT_URL = "loudius://callback" const val AUTHORIZATION_URL = AUTH_API_URL + AUTH_PATH + NAME_PARAM_CLIENT_ID + CLIENT_ID + SCOPE_PARAM diff --git a/app/src/main/java/com/appunite/loudius/ui/authenticating/AuthenticatingViewModel.kt b/app/src/main/java/com/appunite/loudius/ui/authenticating/AuthenticatingViewModel.kt index c3493ee1a..0134c409e 100644 --- a/app/src/main/java/com/appunite/loudius/ui/authenticating/AuthenticatingViewModel.kt +++ b/app/src/main/java/com/appunite/loudius/ui/authenticating/AuthenticatingViewModel.kt @@ -91,7 +91,7 @@ class AuthenticatingViewModel @Inject constructor( viewModelScope.launch { authRepository.fetchAccessToken( clientId = CLIENT_ID, - clientSecret = BuildConfig.CLIENT_SECRET, + clientSecret = BuildConfig.LOUDIUS_CLIENT_SECRET, code = code, ).onSuccess { state = state.copy( diff --git a/app/src/test/java/com/appunite/loudius/util/TestUtilsKtTest.kt b/app/src/test/java/com/appunite/loudius/util/TestUtilsKtTest.kt new file mode 100644 index 000000000..f99f2948d --- /dev/null +++ b/app/src/test/java/com/appunite/loudius/util/TestUtilsKtTest.kt @@ -0,0 +1,29 @@ +/* + * Copyright 2023 AppUnite S.A. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.appunite.loudius.util + +import org.junit.Test +import strikt.api.expectThat +import strikt.assertions.hasLength + +class TestUtilsKtTest { + + @Test + fun verifyOtpIsGenerated() { + expectThat(generateOtp()).hasLength(6) + } +}