Skip to content

feat(Android): Remove ActivityTestRule and support ActivityScenario instead #4488

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 2 commits into
base: master
Choose a base branch
from
Open
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
@@ -1,26 +1,32 @@
package com.wix.detox

import android.app.Activity
import android.app.Instrumentation.ActivityMonitor
import android.content.Context
import android.content.Intent
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule

class ActivityLaunchHelper
@JvmOverloads constructor(
private val activityTestRule: ActivityTestRule<*>,
private val launchArgs: LaunchArgs = LaunchArgs(),
private val intentsFactory: LaunchIntentsFactory = LaunchIntentsFactory(),
private val notificationDataParserGen: (String) -> NotificationDataParser = { path -> NotificationDataParser(path) }
private const val INTENT_LAUNCH_ARGS_KEY = "launchArgs"

class ActivityLaunchHelper @JvmOverloads constructor(
private val clazz: Class<out Activity>,
private val launchArgs: LaunchArgs = LaunchArgs(),
private val intentsFactory: LaunchIntentsFactory = LaunchIntentsFactory(),
private val notificationDataParserGen: (String) -> NotificationDataParser = { path -> NotificationDataParser(path) },
private val activityScenarioWrapperManager: ActivityScenarioWrapperManager = ActivityScenarioWrapperManager()
) {

private val activityScenarioRules: MutableList<ActivityScenarioWrapper> = mutableListOf()

fun launchActivityUnderTest() {
val intent = extractInitialIntent()
activityTestRule.launchActivity(intent)
activityScenarioRules.add(activityScenarioWrapperManager.launch(intent))
}

fun launchMainActivity() {
val activity = activityTestRule.activity
launchActivitySync(intentsFactory.activityLaunchIntent(activity))
launchActivitySync(intentsFactory.activityLaunchIntent(clazz, context = appContext))
}

fun startActivityFromUrl(url: String) {
@@ -33,6 +39,10 @@ class ActivityLaunchHelper
launchActivitySync(intent)
}

fun close() {
activityScenarioRules.forEach { it.close() }
}

private fun extractInitialIntent(): Intent =
(if (launchArgs.hasUrlOverride()) {
intentsFactory.intentWithUrl(launchArgs.urlOverride, true)
@@ -46,33 +56,11 @@ class ActivityLaunchHelper
}

private fun launchActivitySync(intent: Intent) {
// Ideally, we would just call sActivityTestRule.launchActivity(intent) and get it over with.
// BUT!!! as it turns out, Espresso has an issue where doing this for an activity running in the background
// would have Espresso set up an ActivityMonitor which will spend its time waiting for the activity to load, *without
// ever being released*. It will finally fail after a 45 seconds timeout.
// Without going into full details, it seems that activity test rules were not meant to be used this way. However,
// the all-new ActivityScenario implementation introduced in androidx could probably support this (e.g. by using
// dedicated methods such as moveToState(), which give better control over the lifecycle).
// In any case, this is the core reason for this issue: https://github.com/wix/Detox/issues/1125
// What it forces us to do, then, is this -
// 1. Launch the activity by "ourselves" from the OS (i.e. using context.startActivity()).
// 2. Set up an activity monitor by ourselves -- such that it would block until the activity is ready.
// ^ Hence the code below.
val activity = activityTestRule.activity
val activityMonitor = ActivityMonitor(activity.javaClass.name, null, true)
activity.startActivity(intent)

InstrumentationRegistry.getInstrumentation().run {
addMonitor(activityMonitor)
waitForMonitorWithTimeout(activityMonitor, ACTIVITY_LAUNCH_TIMEOUT)
}
activityScenarioRules.add(activityScenarioWrapperManager.launch(intent))
}

private val appContext: Context
get() = InstrumentationRegistry.getInstrumentation().targetContext.applicationContext

companion object {
private const val INTENT_LAUNCH_ARGS_KEY = "launchArgs"
private const val ACTIVITY_LAUNCH_TIMEOUT = 10000L
}

}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package com.wix.detox

import android.app.Activity
import android.content.Intent
import androidx.test.core.app.ActivityScenario

class ActivityScenarioWrapper private constructor(private val activityScenario: ActivityScenario<Activity>) {

fun close() {
activityScenario.close()
}

companion object {
fun launch(clazz: Class<Activity>): ActivityScenarioWrapper {
return ActivityScenarioWrapper(ActivityScenario.launch(clazz))
}

fun launch(intent: Intent): ActivityScenarioWrapper {
return ActivityScenarioWrapper(ActivityScenario.launch(intent))
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
package com.wix.detox

import android.app.Activity
import android.content.Intent

class ActivityScenarioWrapperManager {

fun launch(clazz: Class<Activity>): ActivityScenarioWrapper {
return ActivityScenarioWrapper.launch(clazz)
}

fun launch(intent: Intent): ActivityScenarioWrapper {
return ActivityScenarioWrapper.launch(intent)
}
}
18 changes: 10 additions & 8 deletions detox/android/detox/src/full/java/com/wix/detox/Detox.java
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.wix.detox;

import android.app.Activity;
import android.content.Context;

import androidx.annotation.NonNull;
@@ -79,8 +80,8 @@ private Detox() {
*
* @param activityTestRule the activityTestRule
*/
public static void runTests(ActivityTestRule activityTestRule) {
runTests(activityTestRule, getAppContext());
public static void runTests(Class<? extends Activity> clazz) {
runTests(clazz, getAppContext());
}

/**
@@ -89,8 +90,8 @@ public static void runTests(ActivityTestRule activityTestRule) {
*
* @param detoxConfig The configurations to apply.
*/
public static void runTests(ActivityTestRule activityTestRule, DetoxConfig detoxConfig) {
runTests(activityTestRule, getAppContext(), detoxConfig);
public static void runTests(Class<? extends Activity> clazz, DetoxConfig detoxConfig) {
runTests(clazz, getAppContext(), detoxConfig);
}

/**
@@ -108,8 +109,8 @@ public static void runTests(ActivityTestRule activityTestRule, DetoxConfig detox
* @param activityTestRule the activityTestRule
* @param context an object that has a {@code getReactNativeHost()} method
*/
public static void runTests(ActivityTestRule activityTestRule, @NonNull final Context context) {
runTests(activityTestRule, context, new DetoxConfig());
public static void runTests(Class<? extends Activity> clazz, @NonNull final Context context) {
runTests(clazz, context, new DetoxConfig());
}

/**
@@ -118,12 +119,13 @@ public static void runTests(ActivityTestRule activityTestRule, @NonNull final Co
*
* @param detoxConfig The configurations to apply.
*/
public static void runTests(ActivityTestRule activityTestRule, @NonNull final Context context, DetoxConfig detoxConfig) {
public static void runTests(Class<? extends Activity> clazz, @NonNull final Context context, DetoxConfig detoxConfig) {
DetoxConfig.CONFIG = detoxConfig;
DetoxConfig.CONFIG.apply();

sActivityLaunchHelper = new ActivityLaunchHelper(activityTestRule);
sActivityLaunchHelper = new ActivityLaunchHelper(clazz);
DetoxMain.run(context, sActivityLaunchHelper);
sActivityLaunchHelper.close();
}

public static void launchMainActivity() {
Original file line number Diff line number Diff line change
@@ -15,9 +15,8 @@ class LaunchIntentsFactory {
*
* @return The resulting intent.
*/
fun activityLaunchIntent(activity: Activity)
= Intent(activity.applicationContext,
activity.javaClass).apply {
fun activityLaunchIntent(clazz: Class<out Activity>, context: Context)
= Intent(context, clazz).apply {
flags = coreFlags
}

Original file line number Diff line number Diff line change
@@ -4,7 +4,6 @@ import android.app.Activity
import android.content.Intent
import android.os.Bundle
import org.mockito.kotlin.*
import androidx.test.rule.ActivityTestRule
import org.junit.runner.RunWith
import org.assertj.core.api.Assertions.assertThat
import org.junit.Before
@@ -23,34 +22,36 @@ class ActivityLaunchHelperTest {
private lateinit var intent: Intent
private lateinit var launchArgsAsBundle: Bundle
private lateinit var notificationDataAsBundle: Bundle
private lateinit var testRule: ActivityTestRule<Activity>
private lateinit var testClazz: Class<Activity>
private lateinit var launchArgs: LaunchArgs
private lateinit var intentsFactory: LaunchIntentsFactory
private lateinit var notificationDataParser: NotificationDataParser
private lateinit var activityScenarioWrapperManager: ActivityScenarioWrapperManager

private fun uut() = ActivityLaunchHelper(testRule, launchArgs, intentsFactory, { notificationDataParser })
private fun uut() = ActivityLaunchHelper(testClazz, launchArgs, intentsFactory, { notificationDataParser }, activityScenarioWrapperManager)

@Before
fun setup() {
intent = Intent()
launchArgsAsBundle = mock()
notificationDataAsBundle = mock()

testRule = mock()
testClazz = Activity::class.java
launchArgs = mock() {
on { asIntentBundle() }.thenReturn(launchArgsAsBundle)
}
intentsFactory = mock()
notificationDataParser = mock() {
on { toBundle() }.thenReturn(notificationDataAsBundle)
}
activityScenarioWrapperManager = mock()
}

@Test
fun `default-activity -- should launch using test rule, with a clean intent`() {
givenCleanLaunch()
uut().launchActivityUnderTest()
verify(testRule).launchActivity(eq(intent))
verify(activityScenarioWrapperManager).launch(eq(intent))
}

@Test
@@ -64,7 +65,7 @@ class ActivityLaunchHelperTest {
fun `default activity, with a url -- should launch based on the url`() {
givenLaunchWithInitialURL()
uut().launchActivityUnderTest()
verify(testRule).launchActivity(eq(intent))
verify(activityScenarioWrapperManager).launch(eq(intent))
verify(intentsFactory).intentWithUrl(initialURL, true)
}

@@ -79,7 +80,7 @@ class ActivityLaunchHelperTest {
fun `default activity, with notification data -- should launch with the data as bundle`() {
givenLaunchWithNotificationData()
uut().launchActivityUnderTest()
verify(testRule).launchActivity(eq(intent))
verify(activityScenarioWrapperManager).launch(eq(intent))
verify(intentsFactory).intentWithNotificationData(any(), eq(notificationDataAsBundle), eq(true))
}

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
package com.example

import android.app.Activity
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.rule.ActivityTestRule
import com.wix.detox.Detox
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

/**
* A selector arg that when set 'true', will launch the [SingleInstanceActivity] rather than the default [MainActivity].
* Important so as to allow for some testing of Detox in this particular mode, which has been proven to introduce caveats.
* Here for internal usage; Not external-API related.
*/
private const val USE_SINGLE_INSTANCE_ACTIVITY_ARG = "detoxAndroidSingleInstanceActivity"

/** Similar concept to that of [.USE_SINGLE_INSTANCE_ACTIVITY_ARG]. */
private const val USE_CRASHING_ACTIVITY_ARG = "detoxAndroidCrashingActivity"

@RunWith(AndroidJUnit4::class)
@LargeTest
class DetoxTest {

@Test
fun runDetoxTests() {
TestButlerProbe.assertReadyIfInstalled()
val rule = resolveTestRule()
Detox.runTests(rule)
}

private fun resolveTestRule(): Class<out Activity> {
val arguments =
InstrumentationRegistry.getArguments()
val useSingleTaskActivity =
arguments.getString(USE_SINGLE_INSTANCE_ACTIVITY_ARG, "false")
.toBoolean()
val useCrashingActivity =
arguments.getString(USE_CRASHING_ACTIVITY_ARG, "false").toBoolean()
return if (useSingleTaskActivity) SingleInstanceActivity::class.java else if (useCrashingActivity) SingleInstanceActivity::class.java else MainActivity::class.java
}
}

Unchanged files with check annotations Beta

'@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_'}],
// TODO: enable these rules gradually

Check warning on line 21 in detox/test/.eslintrc.js

GitHub Actions / Linux

Unexpected 'todo' comment without any conditions: 'TODO: enable these rules gradually'

Check warning on line 21 in detox/test/.eslintrc.js

GitHub Actions / Linux

Unexpected 'todo' comment without any conditions: 'TODO: enable these rules gradually'
'comma-dangle': 0,
'curly': 0,
'eol-last': 0,
});
});
it.skip(':android: should throw if tap handling is too slow', async () => {

Check warning on line 99 in detox/test/e2e/03.actions.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 99 in detox/test/e2e/03.actions.test.js

GitHub Actions / Linux

Disabled test
try {
await driver.sluggishTapElement.tap();
} catch (e) {
await expect(element(by.text('I contain some text'))).toHaveId('main-text');
});
it.skip(':ios: should assert an element has (accessibility) value', async () => {

Check warning on line 49 in detox/test/e2e/04.assertions.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 49 in detox/test/e2e/04.assertions.test.js

GitHub Actions / Linux

Disabled test
await expect(driver.toggleElement).toHaveValue('0');
await driver.toggleElement.tap();
await expect(driver.toggleElement).toHaveValue('1');
await expect(element(by.text('From push'))).toBeVisible();
});
xit('Init from calendar notification', async () => {

Check warning on line 14 in detox/test/e2e/11.user-notifications.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 14 in detox/test/e2e/11.user-notifications.test.js

GitHub Actions / Linux

Disabled test
await device.launchApp({newInstance: true, userNotification: userNotificationCalendarTrigger});
await expect(element(by.text('From calendar'))).toBeVisible();
});
await expect(element(by.text('From push'))).toBeVisible();
});
xit('Background calendar notification', async () => {

Check warning on line 26 in detox/test/e2e/11.user-notifications.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 26 in detox/test/e2e/11.user-notifications.test.js

GitHub Actions / Linux

Disabled test
await device.launchApp({newInstance: true});
await device.sendToHome();
await device.launchApp({newInstance: false, userNotification: userNotificationCalendarTrigger});
await expect(element(by.text('From push'))).toBeVisible();
});
xit('Foreground calendar notifications', async () => {

Check warning on line 39 in detox/test/e2e/11.user-notifications.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 39 in detox/test/e2e/11.user-notifications.test.js

GitHub Actions / Linux

Disabled test
await device.launchApp({newInstance: true});
await device.sendUserNotification(userNotificationCalendarTrigger);
await expect(element(by.text('From calendar'))).toBeVisible();
await expect(element(by.id('UniqueId_AnimationsScreen_afterAnimationText'))).toBeVisible();
});
it.skip(`should not wait for infinite animations`, async () => {

Check warning on line 44 in detox/test/e2e/12.animations.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 44 in detox/test/e2e/12.animations.test.js

GitHub Actions / Linux

Disabled test
await _startTest(driver, { loops: -1 });
await expect(element(by.id('UniqueId_AnimationsScreen_afterAnimationText'))).toBeVisible();
});
await expect(faceid).toHaveText(RESULTS.DENIED);
});
// todo: Skipped due to an error coming from react-native-permissions. Fix or implement a custom check.

Check warning on line 170 in detox/test/e2e/13.permissions.test.js

GitHub Actions / Linux

Unexpected 'todo' comment without any conditions: 'todo: Skipped due to an error coming...'

Check warning on line 170 in detox/test/e2e/13.permissions.test.js

GitHub Actions / Linux

Unexpected 'todo' comment without any conditions: 'todo: Skipped due to an error coming...'
it.skip('should grant permission', async () => {

Check warning on line 171 in detox/test/e2e/13.permissions.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 171 in detox/test/e2e/13.permissions.test.js

GitHub Actions / Linux

Disabled test
const permissions = { faceid: 'YES' };
await device.launchApp({ permissions, delete: true });
});
describe('- beforeAll hooks -', () => {
it.skip('trigger false test_start glitch', () => {});

Check warning on line 14 in detox/test/e2e/23.flows.test.js

GitHub Actions / Linux

Disabled test

Check warning on line 14 in detox/test/e2e/23.flows.test.js

GitHub Actions / Linux

Disabled test
describe('inner suite', () => {
beforeAll(async () => {
const rnVersion = (function parseRNVersion() {
let raw;
try {
const packageJson = require('react-native/package.json');

Check warning on line 6 in detox/src/utils/rn-consts/rn-consts.js

GitHub Actions / Linux

"react-native" is not published
raw = packageJson.version;
} catch {
// Default version for RN