diff --git a/.buildkite/jobs/pipeline.android_demo_app_rn_71.yml b/.buildkite/jobs/pipeline.android_demo_app_rn_71.yml index e6baf05220..5f36d72590 100644 --- a/.buildkite/jobs/pipeline.android_demo_app_rn_71.yml +++ b/.buildkite/jobs/pipeline.android_demo_app_rn_71.yml @@ -4,9 +4,9 @@ - "./scripts/demo-projects.android.sh" env: REACT_NATIVE_VERSION: 0.71.10 - REACT_NATIVE_COMPAT_TEST: true # Only set 'true' in jobs with the latest supported RN DETOX_DISABLE_POD_INSTALL: true DETOX_DISABLE_POSTINSTALL: true + JAVA_HOME: /opt/openjdk/jdk-17.0.9.jdk/Contents/Home/ artifact_paths: - "/Users/builder/work/coverage/**/*.lcov" - "/Users/builder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.android_demo_app_rn_73.yml b/.buildkite/jobs/pipeline.android_demo_app_rn_73.yml new file mode 100644 index 0000000000..9d3f2a1145 --- /dev/null +++ b/.buildkite/jobs/pipeline.android_demo_app_rn_73.yml @@ -0,0 +1,13 @@ + - label: ":android::react: RN .73 + Android: Demo app" + command: + - "nvm install" + - "./scripts/demo-projects.android.sh" + env: + REACT_NATIVE_VERSION: 0.73.2 + REACT_NATIVE_COMPAT_TEST: true # Only set 'true' in jobs with the latest supported RN + DETOX_DISABLE_POD_INSTALL: true + DETOX_DISABLE_POSTINSTALL: true + JAVA_HOME: /opt/openjdk/jdk-17.0.9.jdk/Contents/Home/ + artifact_paths: + - "/Users/builder/work/coverage/**/*.lcov" + - "/Users/builder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.android_rn_71.yml b/.buildkite/jobs/pipeline.android_rn_71.yml index 43861094f0..86b06155b1 100644 --- a/.buildkite/jobs/pipeline.android_rn_71.yml +++ b/.buildkite/jobs/pipeline.android_rn_71.yml @@ -6,6 +6,7 @@ REACT_NATIVE_VERSION: 0.71.10 DETOX_DISABLE_POD_INSTALL: true DETOX_DISABLE_POSTINSTALL: true + SKIP_UNIT_TESTS: true artifact_paths: - "/Users/builder/work/coverage/**/*.lcov" - "/Users/builder/work/**/allure-report-*.html" diff --git a/.buildkite/jobs/pipeline.android_rn_70.yml b/.buildkite/jobs/pipeline.android_rn_73.yml similarity index 67% rename from .buildkite/jobs/pipeline.android_rn_70.yml rename to .buildkite/jobs/pipeline.android_rn_73.yml index 1e99d452a5..a272b358d2 100644 --- a/.buildkite/jobs/pipeline.android_rn_70.yml +++ b/.buildkite/jobs/pipeline.android_rn_73.yml @@ -1,12 +1,12 @@ - - label: ":android::detox: RN .70 + Android: Tests app" + - label: ":android::detox: RN .73 + Android: Tests app" command: - "nvm install" - "./scripts/ci.android.sh" env: - REACT_NATIVE_VERSION: 0.70.7 + REACT_NATIVE_VERSION: 0.73.2 DETOX_DISABLE_POD_INSTALL: true DETOX_DISABLE_POSTINSTALL: true - SKIP_UNIT_TESTS: true + JAVA_HOME: /opt/openjdk/jdk-17.0.9.jdk/Contents/Home/ artifact_paths: - "/Users/builder/work/coverage/**/*.lcov" - "/Users/builder/work/**/allure-report-*.html" diff --git a/.buildkite/jobs/pipeline.ios_demo_app_rn_70.yml b/.buildkite/jobs/pipeline.ios_demo_app_rn_73.yml similarity index 70% rename from .buildkite/jobs/pipeline.ios_demo_app_rn_70.yml rename to .buildkite/jobs/pipeline.ios_demo_app_rn_73.yml index 188adfb375..956d7bb7e0 100644 --- a/.buildkite/jobs/pipeline.ios_demo_app_rn_70.yml +++ b/.buildkite/jobs/pipeline.ios_demo_app_rn_73.yml @@ -1,9 +1,9 @@ - - label: ":ios::react: RN. 70 + iOS: Demo app" + - label: ":ios::react: RN .73 + iOS: Demo app" command: - "nvm install" - "./scripts/demo-projects.ios.sh" env: - REACT_NATIVE_VERSION: 0.70.7 + REACT_NATIVE_VERSION: 0.73.2 artifact_paths: - "/Users/builder/work/coverage/**/*.lcov" - "/Users/builder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.ios_rn_70.yml b/.buildkite/jobs/pipeline.ios_rn_73.yml similarity index 74% rename from .buildkite/jobs/pipeline.ios_rn_70.yml rename to .buildkite/jobs/pipeline.ios_rn_73.yml index 9e3b6d3afa..661a72ea6c 100644 --- a/.buildkite/jobs/pipeline.ios_rn_70.yml +++ b/.buildkite/jobs/pipeline.ios_rn_73.yml @@ -1,9 +1,9 @@ - - label: ":ios::detox: RN .70 + iOS: Tests app" + - label: ":ios::detox: RN .73 + iOS: Tests app" command: - "nvm install" - "./scripts/ci.ios.sh" env: - REACT_NATIVE_VERSION: 0.70.7 + REACT_NATIVE_VERSION: 0.73.2 artifact_paths: - "/Users/builder/work/coverage/**/*.lcov" - "/Users/builder/work/**/allure-report-*.html" diff --git a/.buildkite/pipeline.release.fast.yml b/.buildkite/pipeline.release.fast.yml index 709cb7b8fb..74f1a3bd6d 100644 --- a/.buildkite/pipeline.release.fast.yml +++ b/.buildkite/pipeline.release.fast.yml @@ -15,6 +15,7 @@ env: DETOX_DISABLE_POSTINSTALL: true DETOX_DISABLE_POD_INSTALL: true + JAVA_HOME: /opt/openjdk/jdk-17.0.9.jdk/Contents/Home/ artifact_paths: "/Users/builder/work/detox/Detox-android/**/*" - label: ":shipit: Publish" diff --git a/.buildkite/pipeline_common.sh b/.buildkite/pipeline_common.sh index 1d9e7afc93..8b62ef7791 100755 --- a/.buildkite/pipeline_common.sh +++ b/.buildkite/pipeline_common.sh @@ -2,11 +2,12 @@ echo "steps:" +cat .buildkite/jobs/pipeline.ios_rn_73.yml cat .buildkite/jobs/pipeline.ios_rn_71.yml -cat .buildkite/jobs/pipeline.ios_rn_70.yml +cat .buildkite/jobs/pipeline.ios_demo_app_rn_73.yml +cat .buildkite/jobs/pipeline.ios_demo_app_rn_71.yml +cat .buildkite/jobs/pipeline.android_rn_73.yml cat .buildkite/jobs/pipeline.android_rn_71.yml -cat .buildkite/jobs/pipeline.android_rn_70.yml +cat .buildkite/jobs/pipeline.android_demo_app_rn_73.yml cat .buildkite/jobs/pipeline.android_demo_app_rn_71.yml -cat .buildkite/jobs/pipeline.ios_demo_app_rn_71.yml -cat .buildkite/jobs/pipeline.ios_demo_app_rn_70.yml cat .buildkite/pipeline.post_processing.yml diff --git a/.github/workflows/rapid-test.yml b/.github/workflows/rapid-test.yml index 500b4b69af..cbfd0dce58 100644 --- a/.github/workflows/rapid-test.yml +++ b/.github/workflows/rapid-test.yml @@ -25,7 +25,7 @@ jobs: with: node-version-file: '.nvmrc' - name: Bootstrap Lerna - run: npx lerna@3 bootstrap --no-ci + run: npx lerna@6 bootstrap --no-ci env: DETOX_DISABLE_POD_INSTALL: true DETOX_DISABLE_POSTINSTALL: true @@ -47,6 +47,13 @@ jobs: - name: Generation tests run: npm test working-directory: generation + - name: Pack Allure results + run: zip -r allure-results.zip detox/allure-results detox/test/allure-results generation/allure-results + - name: Upload Allure results + uses: actions/upload-artifact@v3 + with: + name: allure-results-linux + path: allure-results.zip windows: name: Windows @@ -57,7 +64,7 @@ jobs: with: node-version-file: '.nvmrc' - name: Bootstrap Lerna (Light) - run: npx lerna@3 bootstrap --no-ci --include-dependents --include-dependencies --scope=detox-test + run: npx lerna@6 bootstrap --no-ci --include-dependents --include-dependencies --scope=detox-test env: DETOX_DISABLE_POD_INSTALL: true DETOX_DISABLE_POSTINSTALL: true @@ -67,3 +74,12 @@ jobs: - name: Integration tests run: npm run integration working-directory: detox/test + - name: Pack Allure results + run: | + Compress-Archive -Path detox/allure-results -DestinationPath allure-results.zip + shell: pwsh + - name: Upload Allure results + uses: actions/upload-artifact@v3 + with: + name: allure-results-windows + path: allure-results.zip diff --git a/.nvmrc b/.nvmrc index 53d838af21..9de2256827 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -lts/gallium +lts/iron diff --git a/.xcoderc b/.xcoderc new file mode 100644 index 0000000000..ccc2f3b87f --- /dev/null +++ b/.xcoderc @@ -0,0 +1 @@ +15.1 \ No newline at end of file diff --git a/README.md b/README.md index 66f18d2648..a3fb3d46ed 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ Detox

-Gray box end-to-end testing and automation library for mobile apps. +Gray box end-to-end testing and automation framework for mobile apps.

Demo @@ -24,15 +24,19 @@ This is a test for a login screen, it runs on a device/simulator like an actual ```js describe('Login flow', () => { - it('should login successfully', async () => { + beforeEach(async () => { await device.reloadReactNative(); + }); + it('should login successfully', async () => { await element(by.id('email')).typeText('john@example.com'); await element(by.id('password')).typeText('123456'); - await element(by.text('Login')).tap(); - await expect(element(by.text('Welcome'))).toBeVisible(); - await expect(element(by.id('email'))).toNotExist(); + const loginButton = element(by.text('Login')); + await loginButton.tap(); + + await expect(loginButton).not.toExist(); + await expect(element(by.label('Welcome'))).toBeVisible(); }); }); ``` diff --git a/detox/.eslintignore b/detox/.eslintignore index 435d2f7ce2..1f41a35cbc 100644 --- a/detox/.eslintignore +++ b/detox/.eslintignore @@ -1,3 +1,4 @@ +*.d.ts /src/android/espressoapi/**/*.js /coverage /ios diff --git a/detox/.eslintrc.js b/detox/.eslintrc.js index 0ac4ef8f4b..7ad888d9d9 100644 --- a/detox/.eslintrc.js +++ b/detox/.eslintrc.js @@ -4,7 +4,7 @@ module.exports = { 'eslint:recommended', 'plugin:import/recommended', 'plugin:node/recommended', - 'plugin:ecmascript-compat/recommended' + 'plugin:ecmascript-compat/recommended', ], parser: '@typescript-eslint/parser', plugins: [ @@ -16,10 +16,6 @@ module.exports = { env: { node: true }, - globals: { - // TODO: remove use of fail() across the project because Jest Circus doesn't support it - 'fail': true - }, rules: { '@typescript-eslint/no-unused-vars': [ 'warn', diff --git a/detox/android/build.gradle b/detox/android/build.gradle index 9578539f43..d4ce719189 100644 --- a/detox/android/build.gradle +++ b/detox/android/build.gradle @@ -3,20 +3,12 @@ buildscript { ext { isOfficialDetoxLib = true - kotlinVersion = '1.6.21' - dokkaVersion = '1.6.0' - buildToolsVersion = '33.0.0' - compileSdkVersion = 33 - targetSdkVersion = 33 + kotlinVersion = '1.8.0' + dokkaVersion = '1.9.10' + buildToolsVersion = '34.0.0' + compileSdkVersion = 34 + targetSdkVersion = 34 minSdkVersion = 21 - - if (System.properties['os.arch'] == "aarch64") { - // For M1 Users we need to use the NDK 24 which added support for aarch64 - ndkVersion = "24.0.8215888" - } else { - // Otherwise we default to the side-by-side NDK version from AGP. - ndkVersion = "21.4.7075529" - } } ext.detoxKotlinVersion = ext.kotlinVersion @@ -28,7 +20,7 @@ buildscript { if (!rnInfo.isRN71OrNewer) { classpath "com.facebook.react:react-native-gradle-plugin" } - classpath 'com.android.tools.build:gradle:7.3.1' + classpath 'com.android.tools.build:gradle' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" classpath "org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion" diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle index 69cf1a0ea7..f7954411c1 100644 --- a/detox/android/detox/build.gradle +++ b/detox/android/detox/build.gradle @@ -3,7 +3,7 @@ apply plugin: 'kotlin-android' apply from: '../rninfo.gradle' def _kotlinMinVersion = '1.2.0' -def _materialMinVersion = '1.2.1' +def _materialMinVersion = '1.11.0' def _ext = rootProject.ext def _compileSdkVersion = _ext.has('compileSdkVersion') ? _ext.compileSdkVersion : 31 @@ -20,9 +20,15 @@ def _rnNativeArtifact = rnInfo.isRN71OrHigher ? "com.facebook.react:react-android:${rnInfo.version}" : 'com.facebook.react:react-native:+' +println "[$project] Resorted to RN native artifact $_rnNativeArtifact" + android { - compileSdkVersion _compileSdkVersion - buildToolsVersion _buildToolsVersion + def agpVersion = com.android.Version.ANDROID_GRADLE_PLUGIN_VERSION.tokenize('.')[0].toInteger() + if (agpVersion >= 7) { + namespace "com.wix.detox" + } + compileSdk _compileSdkVersion + buildToolsVersion = _buildToolsVersion defaultConfig { minSdkVersion _minSdkVersion @@ -33,7 +39,7 @@ android { consumerProguardFiles 'proguard-rules.pro' } - flavorDimensions 'detox' + flavorDimensions = ['detox'] productFlavors { full { dimension 'detox' @@ -48,7 +54,7 @@ android { unitTests.returnDefaultValues = true unitTests.all { t -> reports { - html.enabled true + html.required = true } testLogging { events "passed", "skipped", "failed", "standardOut", "standardError" @@ -59,7 +65,7 @@ android { def repeatLength = output.length() println '\n' + ('-' * repeatLength) + '\n' + output + '\n' + ('-' * repeatLength) + '\n' - println "see report at file://${t.reports.html.destination}/index.html" + println "see report at file://${t.reports.html.outputLocation}/index.html" } } } @@ -76,6 +82,26 @@ android { lintOptions { abortOnError false } + + if (rnInfo.isRN72OrHigher) { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + } else { + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } + + kotlinOptions { + jvmTarget = '11' + } + } } // In a nutshell: @@ -97,13 +123,13 @@ dependencies { // Versions are in-sync with the 'androidx-test-1.4.0' release/tag of the android-test github repo, // used by the Detox generator. See https://github.com/android/android-test/releases/tag/androidx-test-1.4.0 // Important: Should remain so when generator tag is replaced! - api('androidx.test.espresso:espresso-core:3.4.0') { + api('androidx.test.espresso:espresso-core:3.5.1') { because 'Needed all across Detox but also makes Espresso seamlessly provided to Detox users with hybrid apps/E2E-tests.' } - api('androidx.test.espresso:espresso-web:3.4.0') { + api('androidx.test.espresso:espresso-web:3.5.1') { because 'Web-View testing' } - api('androidx.test.espresso:espresso-contrib:3.4.0') { + api('androidx.test.espresso:espresso-contrib:3.5.1') { because 'Android datepicker support' exclude group: "org.checkerframework", module: "checker" } @@ -145,8 +171,8 @@ dependencies { testImplementation 'org.assertj:assertj-core:3.16.1' testImplementation "org.jetbrains.kotlin:kotlin-test:$_kotlinVersion" testImplementation 'org.apache.commons:commons-io:1.3.2' - testImplementation 'org.mockito.kotlin:mockito-kotlin:4.0.0' - testImplementation 'org.robolectric:robolectric:4.4' + testImplementation 'org.mockito.kotlin:mockito-kotlin:5.2.1' + testImplementation 'org.robolectric:robolectric:4.11.1' testImplementation("com.google.android.material:material:$_materialMinVersion") { because 'Material components are mentioned explicitly (e.g. Slider in get-attributes handler)' diff --git a/detox/android/detox/proguard-rules-app.pro b/detox/android/detox/proguard-rules-app.pro index f230633216..8dd1db667f 100644 --- a/detox/android/detox/proguard-rules-app.pro +++ b/detox/android/detox/proguard-rules-app.pro @@ -19,3 +19,9 @@ -keep class kotlin.text.** { *; } -keep class kotlin.io.** { *; } -keep class okhttp3.** { *; } + +-keep class androidx.concurrent.futures.** { *; } + +-dontwarn androidx.appcompat.** +-dontwarn javax.lang.model.element.** + diff --git a/detox/android/detox/proguard-rules.pro b/detox/android/detox/proguard-rules.pro index b4ad86197b..8bef018070 100644 --- a/detox/android/detox/proguard-rules.pro +++ b/detox/android/detox/proguard-rules.pro @@ -20,3 +20,6 @@ -keep class com.wix.detoxprofiler.** { *; } -dontnote com.wix.detox.instruments.reflected.** + +-dontwarn androidx.appcompat.** +-dontwarn javax.lang.model.element.** diff --git a/detox/android/detox/publishing.gradle b/detox/android/detox/publishing.gradle index f3f20a498e..9cfc1619a9 100644 --- a/detox/android/detox/publishing.gradle +++ b/detox/android/detox/publishing.gradle @@ -110,7 +110,7 @@ tasks.named("dokkaJavadoc") { // suppression config var or something. task dokkaDocJar(type: Jar, dependsOn: dokkaJavadoc) { from "$buildDir/dokkaDoc" - classifier = 'javadoc' + archiveClassifier.set("javadoc") } /* @@ -119,7 +119,7 @@ task dokkaDocJar(type: Jar, dependsOn: dokkaJavadoc) { task sourcesJar(type: Jar) { from android.sourceSets.main.java.srcDirs - classifier = 'sources' + archiveClassifier.set("sources") } /* @@ -193,11 +193,11 @@ publishing { // Register sources, javadoc as published artifacts (via equivalent tasks' output) artifact sourcesJar - artifact dokkaDocJar + //artifact dokkaDocJar // waiting for dokka to fix https://github.com/Kotlin/dokka/issues/3153 // Also register source, javadoc as archive-artifacts, for signing declareArchive sourcesJar - declareArchive dokkaDocJar + //declareArchive dokkaDocJar // waiting for dokka to fix https://github.com/Kotlin/dokka/issues/3153 // Add detox package metadata to the .pom pom { diff --git a/detox/android/detox/src/full/java/com/wix/detox/ActivityLaunchHelper.kt b/detox/android/detox/src/full/java/com/wix/detox/ActivityLaunchHelper.kt new file mode 100644 index 0000000000..7ccc113a6d --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/ActivityLaunchHelper.kt @@ -0,0 +1,78 @@ +package com.wix.detox + +import android.app.Instrumentation.ActivityMonitor +import android.content.Context +import android.content.Intent +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) } +) { + fun launchActivityUnderTest() { + val intent = extractInitialIntent() + activityTestRule.launchActivity(intent) + } + + fun launchMainActivity() { + val activity = activityTestRule.activity + launchActivitySync(intentsFactory.activityLaunchIntent(activity)) + } + + fun startActivityFromUrl(url: String) { + launchActivitySync(intentsFactory.intentWithUrl(url, false)) + } + + fun startActivityFromNotification(dataFilePath: String) { + val notificationData = notificationDataParserGen(dataFilePath).toBundle() + val intent = intentsFactory.intentWithNotificationData(appContext, notificationData, false) + launchActivitySync(intent) + } + + private fun extractInitialIntent(): Intent = + (if (launchArgs.hasUrlOverride()) { + intentsFactory.intentWithUrl(launchArgs.urlOverride, true) + } else if (launchArgs.hasNotificationPath()) { + val notificationData = notificationDataParserGen(launchArgs.notificationPath).toBundle() + intentsFactory.intentWithNotificationData(appContext, notificationData, true) + } else { + intentsFactory.cleanIntent() + }).also { + it.putExtra(INTENT_LAUNCH_ARGS_KEY, launchArgs.asIntentBundle()) + } + + 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) + } + } + + 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 + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/Detox.java b/detox/android/detox/src/full/java/com/wix/detox/Detox.java index 87d211c797..93769a11c4 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/Detox.java +++ b/detox/android/detox/src/full/java/com/wix/detox/Detox.java @@ -1,18 +1,13 @@ package com.wix.detox; -import android.app.Activity; -import android.app.Instrumentation; import android.content.Context; -import android.content.Intent; -import android.os.Bundle; - -import com.wix.detox.config.DetoxConfig; -import com.wix.detox.espresso.UiControllerSpy; import androidx.annotation.NonNull; import androidx.test.platform.app.InstrumentationRegistry; import androidx.test.rule.ActivityTestRule; +import com.wix.detox.config.DetoxConfig; + /** *

Static class.

* @@ -67,12 +62,7 @@ *

If not set, then Detox tests are no ops. So it's safe to mix it with other tests.

*/ public final class Detox { - private static final String INTENT_LAUNCH_ARGS_KEY = "launchArgs"; - private static final long ACTIVITY_LAUNCH_TIMEOUT = 10000L; - - private static final LaunchArgs sLaunchArgs = new LaunchArgs(); - private static final LaunchIntentsFactory sIntentsFactory = new LaunchIntentsFactory(); - private static ActivityTestRule sActivityTestRule; + private static ActivityLaunchHelper sActivityLaunchHelper; private Detox() { } @@ -132,72 +122,20 @@ public static void runTests(ActivityTestRule activityTestRule, @NonNull final Co DetoxConfig.CONFIG = detoxConfig; DetoxConfig.CONFIG.apply(); - sActivityTestRule = activityTestRule; - - UiControllerSpy.attachThroughProxy(); - - Intent intent = extractInitialIntent(); - sActivityTestRule.launchActivity(intent); - - try { - DetoxMain.run(context); - } catch (Exception e) { - Thread.currentThread().interrupt(); - throw new RuntimeException("Detox got interrupted prematurely", e); - } + sActivityLaunchHelper = new ActivityLaunchHelper(activityTestRule); + DetoxMain.run(context, sActivityLaunchHelper); } public static void launchMainActivity() { - final Activity activity = sActivityTestRule.getActivity(); - launchActivitySync(sIntentsFactory.activityLaunchIntent(activity)); + sActivityLaunchHelper.launchMainActivity(); } public static void startActivityFromUrl(String url) { - launchActivitySync(sIntentsFactory.intentWithUrl(url, false)); + sActivityLaunchHelper.startActivityFromUrl(url); } public static void startActivityFromNotification(String dataFilePath) { - Bundle notificationData = new NotificationDataParser(dataFilePath).toBundle(); - Intent intent = sIntentsFactory.intentWithNotificationData(getAppContext(), notificationData, false); - launchActivitySync(intent); - } - - private static Intent extractInitialIntent() { - Intent intent; - - if (sLaunchArgs.hasUrlOverride()) { - intent = sIntentsFactory.intentWithUrl(sLaunchArgs.getUrlOverride(), true); - } else if (sLaunchArgs.hasNotificationPath()) { - Bundle notificationData = new NotificationDataParser(sLaunchArgs.getNotificationPath()).toBundle(); - intent = sIntentsFactory.intentWithNotificationData(getAppContext(), notificationData, true); - } else { - intent = sIntentsFactory.cleanIntent(); - } - intent.putExtra(INTENT_LAUNCH_ARGS_KEY, sLaunchArgs.asIntentBundle()); - return intent; - } - - private static void 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. - - final Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); - final Activity activity = sActivityTestRule.getActivity(); - final Instrumentation.ActivityMonitor activityMonitor = new Instrumentation.ActivityMonitor(activity.getClass().getName(), null, true); - - activity.startActivity(intent); - instrumentation.addMonitor(activityMonitor); - instrumentation.waitForMonitorWithTimeout(activityMonitor, ACTIVITY_LAUNCH_TIMEOUT); + sActivityLaunchHelper.startActivityFromNotification(dataFilePath); } private static Context getAppContext() { diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt b/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt index 5e4f0bd88b..fd5200b18c 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt @@ -3,35 +3,62 @@ package com.wix.detox import android.content.Context import android.util.Log import com.wix.detox.adapters.server.* -import com.wix.detox.common.DetoxLog.Companion.LOG_TAG +import com.wix.detox.common.DetoxLog +import com.wix.detox.espresso.UiControllerSpy import com.wix.detox.instruments.DetoxInstrumentsManager import com.wix.detox.reactnative.ReactNativeExtension import com.wix.invoke.MethodInvocation +import java.util.concurrent.CountDownLatch -private const val INIT_ACTION = "_init" -private const val IS_READY_ACTION = "isReady" private const val TERMINATION_ACTION = "_terminate" object DetoxMain { + private val handshakeLock = CountDownLatch(1) + @JvmStatic - fun run(rnHostHolder: Context) { + fun run(rnHostHolder: Context, activityLaunchHelper: ActivityLaunchHelper) { val detoxServerInfo = DetoxServerInfo() - Log.i(LOG_TAG, "Detox server connection details: $detoxServerInfo") - val testEngineFacade = TestEngineFacade() val actionsDispatcher = DetoxActionsDispatcher() - val externalAdapter = DetoxServerAdapter(actionsDispatcher, detoxServerInfo, IS_READY_ACTION, TERMINATION_ACTION) - initActionHandlers(actionsDispatcher, externalAdapter, testEngineFacade, rnHostHolder) - actionsDispatcher.dispatchAction(INIT_ACTION, "", 0) + val serverAdapter = DetoxServerAdapter(actionsDispatcher, detoxServerInfo, TERMINATION_ACTION) + + initCrashHandler(serverAdapter) + initANRListener(serverAdapter) + initEspresso() + initReactNative() + + setupActionHandlers(actionsDispatcher, serverAdapter, testEngineFacade, rnHostHolder) + serverAdapter.connect() + + launchActivityOnCue(rnHostHolder, activityLaunchHelper) actionsDispatcher.join() } - private fun doInit(externalAdapter: DetoxServerAdapter, rnHostHolder: Context) { - externalAdapter.connect() + /** + * Launch the tested activity "on cue", namely, right after a connection is established and the handshake + * completes successfully. + * + * This has to be synchronized so that an `isReady` isn't handled *before* the activity is launched (albeit not fully + * initialized - all native modules and everything) and a react context is available. + * + * As a better alternative, it would make sense to execute this as a simple action from within the actions + * dispatcher (i.e. handler of `loginSuccess`), in which case, no inter-thread locking would be required + * thanks to the usage of Handlers. However, in this type of a solution, errors / crashes would be reported + * not by instrumentation itself, but based on the `AppWillTerminateWithError` message; In it's own, it is a good + * thing, but for a reason we're not sure of yet, it is ignored by the test runner at this point in the flow. + */ + @Synchronized + private fun launchActivityOnCue(rnHostHolder: Context, activityLaunchHelper: ActivityLaunchHelper) { + awaitHandshake() + launchActivity(rnHostHolder, activityLaunchHelper) + } + + private fun awaitHandshake() { + handshakeLock.await() + } - initCrashHandler(externalAdapter) - initANRListener(externalAdapter) - initReactNativeIfNeeded(rnHostHolder) + private fun onLoginSuccess() { + handshakeLock.countDown() } private fun doTeardown(serverAdapter: DetoxServerAdapter, actionsDispatcher: DetoxActionsDispatcher, testEngineFacade: TestEngineFacade) { @@ -41,35 +68,28 @@ object DetoxMain { actionsDispatcher.teardown() } - private fun initActionHandlers(actionsDispatcher: DetoxActionsDispatcher, serverAdapter: DetoxServerAdapter, testEngineFacade: TestEngineFacade, rnHostHolder: Context) { + private fun setupActionHandlers(actionsDispatcher: DetoxActionsDispatcher, serverAdapter: DetoxServerAdapter, testEngineFacade: TestEngineFacade, rnHostHolder: Context) { + class SynchronizedActionHandler(private val actionHandler: DetoxActionHandler): DetoxActionHandler { + override fun handle(params: String, messageId: Long) { + synchronized(this@DetoxMain) { + actionHandler.handle(params, messageId) + } + } + } + // Primary actions with(actionsDispatcher) { - val rnReloadHandler = ReactNativeReloadActionHandler(rnHostHolder, serverAdapter, testEngineFacade) + val readyHandler = SynchronizedActionHandler( ReadyActionHandler(serverAdapter, testEngineFacade) ) + val rnReloadHandler = SynchronizedActionHandler( ReactNativeReloadActionHandler(rnHostHolder, serverAdapter, testEngineFacade) ) - associateActionHandler(INIT_ACTION, object : DetoxActionHandler { - override fun handle(params: String, messageId: Long) = - synchronized(this@DetoxMain) { - this@DetoxMain.doInit(serverAdapter, rnHostHolder) - } - }) - associateActionHandler(IS_READY_ACTION, ReadyActionHandler(serverAdapter, testEngineFacade)) - - associateActionHandler("loginSuccess", ScarceActionHandler()) - associateActionHandler("reactNativeReload", object: DetoxActionHandler { - override fun handle(params: String, messageId: Long) = - synchronized(this@DetoxMain) { - rnReloadHandler.handle(params, messageId) - } - }) + associateActionHandler("loginSuccess", ::onLoginSuccess) + associateActionHandler("isReady", readyHandler) + associateActionHandler("reactNativeReload", rnReloadHandler) associateActionHandler("invoke", InvokeActionHandler(MethodInvocation(), serverAdapter)) associateActionHandler("cleanup", CleanupActionHandler(serverAdapter, testEngineFacade) { dispatchAction(TERMINATION_ACTION, "", 0) }) - associateActionHandler(TERMINATION_ACTION, object: DetoxActionHandler { - override fun handle(params: String, messageId: Long) { - this@DetoxMain.doTeardown(serverAdapter, actionsDispatcher, testEngineFacade) - } - }) + associateActionHandler(TERMINATION_ACTION) { -> doTeardown(serverAdapter, actionsDispatcher, testEngineFacade) } if (DetoxInstrumentsManager.supports()) { val instrumentsManager = DetoxInstrumentsManager(rnHostHolder) @@ -80,13 +100,8 @@ object DetoxMain { // Secondary actions with(actionsDispatcher) { - val queryStatusHandler = QueryStatusActionHandler(serverAdapter, testEngineFacade) - associateActionHandler("currentStatus", object: DetoxActionHandler { - override fun handle(params: String, messageId: Long) = - synchronized(this@DetoxMain) { - queryStatusHandler.handle(params, messageId) - } - }, false) + val queryStatusHandler = SynchronizedActionHandler( QueryStatusActionHandler(serverAdapter, testEngineFacade) ) + associateSecondaryActionHandler("currentStatus", queryStatusHandler) } } @@ -98,7 +113,17 @@ object DetoxMain { DetoxANRHandler(outboundServerAdapter).attach() } - private fun initReactNativeIfNeeded(rnHostHolder: Context) { + private fun initEspresso() { + UiControllerSpy.attachThroughProxy() + } + + private fun initReactNative() { + ReactNativeExtension.initIfNeeded() + } + + private fun launchActivity(rnHostHolder: Context, activityLaunchHelper: ActivityLaunchHelper) { + Log.i(DetoxLog.LOG_TAG, "Launching the tested activity!") + activityLaunchHelper.launchActivityUnderTest() ReactNativeExtension.waitForRNBootstrap(rnHostHolder) } } diff --git a/detox/android/detox/src/full/java/com/wix/detox/LaunchIntentsFactory.kt b/detox/android/detox/src/full/java/com/wix/detox/LaunchIntentsFactory.kt index 43bed23a44..80f5801c23 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/LaunchIntentsFactory.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/LaunchIntentsFactory.kt @@ -6,7 +6,7 @@ import android.content.Intent import android.net.Uri import android.os.Bundle -internal class LaunchIntentsFactory { +class LaunchIntentsFactory { /** * Constructs an intent tightly associated with a specific activity. diff --git a/detox/android/detox/src/full/java/com/wix/detox/NotificationDataParser.kt b/detox/android/detox/src/full/java/com/wix/detox/NotificationDataParser.kt index 455599b825..f2e2d8a97f 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/NotificationDataParser.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/NotificationDataParser.kt @@ -5,7 +5,7 @@ import com.wix.detox.common.JsonConverter import com.wix.detox.common.TextFileReader import org.json.JSONObject -internal class NotificationDataParser(private val notificationPath: String) { +class NotificationDataParser(private val notificationPath: String) { fun toBundle(): Bundle { val rawData = readNotificationFromFile() val json = JSONObject(rawData) diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt index ef0416465e..1404f63abf 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt @@ -152,7 +152,3 @@ class InstrumentsEventsActionsHandler( outboundServerAdapter.sendMessage("eventDone", emptyMap(), messageId) } } - -class ScarceActionHandler: DetoxActionHandler { - override fun handle(params: String, messageId: Long) {} -} diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt index ac82d2c3e7..c3d83d50ff 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt @@ -11,11 +11,18 @@ class DetoxActionsDispatcher { private val primaryExec = ActionsExecutor("detox.primary") private val secondaryExec = ActionsExecutor("detox.secondary") - fun associateActionHandler(type: String, actionHandler: DetoxActionHandler, isPrimary: Boolean = true) { - val actionsExecutor = (if (isPrimary) primaryExec else secondaryExec) - actionsExecutor.associateHandler(type, actionHandler) + fun associateActionHandler(type: String, actionHandler: DetoxActionHandler) = + associateActionHandler(type, actionHandler, true) + + fun associateActionHandler(type: String, handlerFunc: () -> Unit) { + associateActionHandler(type, object: DetoxActionHandler { + override fun handle(params: String, messageId: Long) = handlerFunc() + }) } + fun associateSecondaryActionHandler(type: String, actionHandler: DetoxActionHandler) = + associateActionHandler(type, actionHandler, false) + fun dispatchAction(type: String, params: String, messageId: Long) { (primaryExec.executeAction(type, params, messageId) || secondaryExec.executeAction(type, params, messageId)) @@ -33,6 +40,11 @@ class DetoxActionsDispatcher { primaryExec.join() secondaryExec.join() } + + private fun associateActionHandler(type: String, actionHandler: DetoxActionHandler, isPrimary: Boolean = true) { + val actionsExecutor = (if (isPrimary) primaryExec else secondaryExec) + actionsExecutor.associateHandler(type, actionHandler) + } } private class ActionsExecutor(name: String) { @@ -74,7 +86,5 @@ private class ActionsExecutor(name: String) { handler.looper.quit() } - fun join() { - thread.join() - } + fun join() = thread.join() } diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt index 2d8b11fdcf..9873a72bc3 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt @@ -10,7 +10,6 @@ interface OutboundServerAdapter { class DetoxServerAdapter( private val actionsDispatcher: DetoxActionsDispatcher, private val detoxServerInfo: DetoxServerInfo, - private val readyActionType: String, private val terminationActionType: String) : WebSocketClient.WSEventsHandler, OutboundServerAdapter { @@ -27,7 +26,6 @@ class DetoxServerAdapter( override fun onConnect() { Log.i(DetoxLog.LOG_TAG, "Connected to server!") - actionsDispatcher.dispatchAction(readyActionType, "", -1000L) } override fun onClosed() { diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt index f2828de4ea..947c69ea27 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt @@ -1,7 +1,9 @@ package com.wix.detox.adapters.server +import android.util.Log import androidx.test.platform.app.InstrumentationRegistry import com.wix.detox.LaunchArgs +import com.wix.detox.common.DetoxLog private const val DEFAULT_URL = "ws://localhost:8099" @@ -9,7 +11,7 @@ class DetoxServerInfo internal constructor(launchArgs: LaunchArgs = LaunchArgs() val serverUrl: String = launchArgs.detoxServerUrl ?: DEFAULT_URL val sessionId: String = launchArgs.detoxSessionId ?: InstrumentationRegistry.getInstrumentation().targetContext.applicationInfo.packageName - override fun toString(): String { - return "url=$serverUrl, sessionId=$sessionId" + init { + Log.i(DetoxLog.LOG_TAG, "Detox server connection details: url=$serverUrl, sessionId=$sessionId") } } diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java index 8474e3526d..48903678ce 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java @@ -79,9 +79,14 @@ public float[] calculateCoordinates(View view) { * Scrolls to the edge of the given scrollable view. * * @param edge Direction to scroll (see {@link MotionDir}) + * @param startOffsetPercentX Percentage denoting where the scroll should start from on the X-axis, with respect to the scrollable view. + * @param startOffsetPercentY Percentage denoting where the scroll should start from on the Y-axis, with respect to the scrollable view. * @return ViewAction */ - public static ViewAction scrollToEdge(final int edge) { + public static ViewAction scrollToEdge(final int edge, double startOffsetPercentX, double startOffsetPercentY) { + final Float _startOffsetPercentX = startOffsetPercentX < 0 ? null : (float) startOffsetPercentX; + final Float _startOffsetPercentY = startOffsetPercentY < 0 ? null : (float) startOffsetPercentY; + return actionWithAssertions(new ViewAction() { @Override public Matcher getConstraints() { @@ -97,7 +102,7 @@ public String getDescription() { public void perform(UiController uiController, View view) { try { for (int i = 0; i < 100; i++) { - ScrollHelper.performOnce(uiController, view, edge); + ScrollHelper.performOnce(uiController, view, edge, _startOffsetPercentX, _startOffsetPercentY); } throw new DetoxRuntimeException("Scrolling a lot without reaching the edge: force-breaking the loop"); } catch (ScrollEdgeException e) { @@ -112,8 +117,8 @@ public void perform(UiController uiController, View view) { * * @param direction Direction to scroll (see {@link MotionDir}) * @param amountInDP Density Independent Pixels - * @param startOffsetPercentX Percentage denoting where X-swipe should start, with respect to the scrollable view. - * @param startOffsetPercentY Percentage denoting where Y-swipe should start, with respect to the scrollable view. + * @param startOffsetPercentX Percentage denoting where the scroll should start from on the X-axis, with respect to the scrollable view. + * @param startOffsetPercentY Percentage denoting where the scroll should start from on the Y-axis, with respect to the scrollable view. */ public static ViewAction scrollInDirection(final int direction, final double amountInDP, double startOffsetPercentX, double startOffsetPercentY) { final Float _startOffsetPercentX = startOffsetPercentX < 0 ? null : (float) startOffsetPercentX; @@ -129,8 +134,8 @@ public static ViewAction scrollInDirection(final int direction, final double amo * * @param direction Direction to scroll (see {@link MotionDir}) * @param amountInDP Density Independent Pixels - * @param startOffsetPercentX Percentage denoting where X-swipe should start, with respect to the scrollable view. - * @param startOffsetPercentY Percentage denoting where Y-swipe should start, with respect to the scrollable view. + * @param startOffsetPercentX Percentage denoting where the scroll should start from on the X-axis, with respect to the scrollable view. + * @param startOffsetPercentY Percentage denoting where the scroll should start from on the Y-axis, with respect to the scrollable view. */ public static ViewAction scrollInDirectionStaleAtEdge(final int direction, final double amountInDP, double startOffsetPercentX, double startOffsetPercentY) { final Float _startOffsetPercentX = startOffsetPercentX < 0 ? null : (float) startOffsetPercentX; @@ -170,10 +175,16 @@ public static ViewAction setDatePickerDate(String dateString, String formatStrin Calendar cal = Calendar.getInstance(); cal.setTime(date); - return PickerActions.setDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); + + // if formatString contain hh:mm, we need to set time + if (formatString.toLowerCase().contains("hh:mm")) { + return PickerActions.setTime(cal.get(Calendar.HOUR_OF_DAY), cal.get(Calendar.MINUTE)); + } else { + return PickerActions.setDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH)); + } } - public static ViewAction adjustSliderToPosition(final double newPosition) { + public static ViewAction adjustSliderToPosition(final Float newPosition) { return new AdjustSliderToPositionAction(newPosition); } diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/AdjustSliderToPositionAction.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/AdjustSliderToPositionAction.kt index e6562c20b8..08e609a67c 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/AdjustSliderToPositionAction.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/AdjustSliderToPositionAction.kt @@ -10,7 +10,7 @@ import com.wix.detox.espresso.common.ReactSliderHelper import org.hamcrest.Matcher import org.hamcrest.Matchers -class AdjustSliderToPositionAction(private val targetPositionPct: Double) : ViewAction { +class AdjustSliderToPositionAction(private val targetPositionPct: Float) : ViewAction { override fun getDescription() = "adjustSliderToPosition" override fun getConstraints(): Matcher? = Matchers.allOf( isDisplayed(), isAssignableFrom(AppCompatSeekBar::class.java) ) diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java index 68bf039064..6814390ec0 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java @@ -40,7 +40,17 @@ public RNClickAction(CoordinatesProvider coordinatesProvider) { @Override public Matcher getConstraints() { - return isDisplayingAtLeast(75); + Matcher matcher = isDisplayingAtLeast(75); + // if no view matched, wait a second and try again with a lower threshold + if (!matcher.matches(null)) { + try { + Thread.sleep(1000); + } catch (InterruptedException e) { + System.out.println(e); + } + return isDisplayingAtLeast(50); + } + return matcher; } @Override diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/assertion/ViewAssertions.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/assertion/ViewAssertions.java index 7784de010f..7559232a12 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/assertion/ViewAssertions.java +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/assertion/ViewAssertions.java @@ -24,8 +24,9 @@ public class ViewAssertions { * which is more suitable for Detox' separated interaction-matcher architecture. * See {@link MatchesViewAssertion} for more details. */ + @SuppressWarnings("unchecked") public static ViewAssertion matches(final Matcher viewMatcher) { - return new MatchesViewAssertion(checkNotNull(viewMatcher)); + return new MatchesViewAssertion((Matcher) checkNotNull(viewMatcher)); } /** diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/common/ReactSliderHelper.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/ReactSliderHelper.kt index 8da0ebbfa5..c39667aacf 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/espresso/common/ReactSliderHelper.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/ReactSliderHelper.kt @@ -6,10 +6,10 @@ import com.facebook.react.bridge.JavaOnlyMap import com.facebook.react.uimanager.ReactStylesDiffMap import com.wix.detox.common.DetoxErrors.DetoxIllegalStateException import com.wix.detox.espresso.action.common.ReflectUtils -import com.facebook.react.views.slider.ReactSlider import org.joor.Reflect private const val CLASS_REACT_SLIDER_LEGACY = "com.facebook.react.views.slider.ReactSlider" +private const val CLASS_REACT_SLIDER_LEGACY_MANAGER = "com.facebook.react.views.slider.ReactSliderManager" private const val CLASS_REACT_SLIDER_COMMUNITY = "com.reactnativecommunity.slider.ReactSlider" private const val CLASS_REACT_SLIDER_COMMUNITY_MANAGER = "com.reactnativecommunity.slider.ReactSliderManager" @@ -21,13 +21,13 @@ abstract class ReactSliderHelper(protected val slider: AppCompatSeekBar) { } // TODO Make this more testable (e.g. by delegating the set action away) - fun setProgressPct(valuePct: Double) { + fun setProgressPct(valuePct: Float) { val maxJSProgress = calcMaxJSProgress() val valueJS = valuePct * maxJSProgress - setProgressJS(valueJS) + setProgressJS(valueJS.toFloat()) } - protected abstract fun setProgressJS(valueJS: Double) + protected abstract fun setProgressJS(valueJS: Float) private fun calcMaxJSProgress(): Double { val nativeProgress = slider.progress.toDouble() @@ -50,7 +50,7 @@ abstract class ReactSliderHelper(protected val slider: AppCompatSeekBar) { fun maybeCreate(view: View): ReactSliderHelper? = when { ReflectUtils.isAssignableFrom(view, CLASS_REACT_SLIDER_LEGACY) - -> LegacySliderHelper(view as ReactSlider) + -> LegacySliderHelper(view as AppCompatSeekBar) ReflectUtils.isAssignableFrom(view, CLASS_REACT_SLIDER_COMMUNITY) -> CommunitySliderHelper(view as AppCompatSeekBar) else @@ -59,17 +59,17 @@ abstract class ReactSliderHelper(protected val slider: AppCompatSeekBar) { } } -private class LegacySliderHelper(slider: ReactSlider): ReactSliderHelper(slider) { - override fun setProgressJS(valueJS: Double) { - val reactSliderManager = com.facebook.react.views.slider.ReactSliderManager() - reactSliderManager.updateProperties(slider as ReactSlider, buildStyles("value", valueJS)) +private class LegacySliderHelper(slider: AppCompatSeekBar): ReactSliderHelper(slider) { + override fun setProgressJS(valueJS: Float) { + val reactSliderManager = Class.forName(CLASS_REACT_SLIDER_LEGACY_MANAGER).newInstance() + Reflect.on(reactSliderManager).call("updateProperties", slider, buildStyles("value", valueJS.toDouble())) } private fun buildStyles(vararg keysAndValues: Any) = ReactStylesDiffMap(JavaOnlyMap.of(*keysAndValues)) } private class CommunitySliderHelper(slider: AppCompatSeekBar): ReactSliderHelper(slider) { - override fun setProgressJS(valueJS: Double) { + override fun setProgressJS(valueJS: Float) { val reactSliderManager = Class.forName(CLASS_REACT_SLIDER_COMMUNITY_MANAGER).newInstance() Reflect.on(reactSliderManager).call("setValue", slider, valueJS) } diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactMarkersLogger.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactMarkersLogger.kt new file mode 100644 index 0000000000..bbabce01d3 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactMarkersLogger.kt @@ -0,0 +1,44 @@ +package com.wix.detox.reactnative + +import android.util.Log +import com.facebook.react.bridge.ReactMarker +import com.facebook.react.bridge.ReactMarkerConstants +import com.facebook.react.bridge.ReactMarkerConstants.* + +object ReactMarkersLogger : ReactMarker.MarkerListener { + + fun attach() { + ReactMarker.addListener(this) + } + + override fun logMarker(marker: ReactMarkerConstants, p1: String?, p2: Int) { + when { + marker == DOWNLOAD_START || + marker == DOWNLOAD_END || + marker == BUILD_REACT_INSTANCE_MANAGER_START || + marker == BUILD_REACT_INSTANCE_MANAGER_END || + marker == REACT_BRIDGE_LOADING_START || + marker == REACT_BRIDGE_LOADING_END || + marker == REACT_BRIDGELESS_LOADING_START || + marker == REACT_BRIDGELESS_LOADING_END || + marker == CREATE_MODULE_START || + marker == CREATE_MODULE_END || + marker == NATIVE_MODULE_SETUP_START || + marker == NATIVE_MODULE_SETUP_END || + marker == PRE_RUN_JS_BUNDLE_START || + marker == RUN_JS_BUNDLE_START || + marker == RUN_JS_BUNDLE_END || + marker == CONTENT_APPEARED || + marker == CREATE_CATALYST_INSTANCE_START || + marker == CREATE_CATALYST_INSTANCE_END || + marker == DESTROY_CATALYST_INSTANCE_START || + marker == DESTROY_CATALYST_INSTANCE_END || + marker == CREATE_REACT_CONTEXT_START || + marker == CREATE_REACT_CONTEXT_END || + marker == PROCESS_PACKAGES_START || + marker == PROCESS_PACKAGES_END || + false -> + Log.d("Detox.RNMarker", "$marker ($p1)") + } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt index 19e4ae2473..1711eea37c 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt +++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/ReactNativeExtension.kt @@ -14,6 +14,31 @@ private const val LOG_TAG = "DetoxRNExt" object ReactNativeExtension { private var rnIdlingResources: ReactNativeIdlingResources? = null + fun initIfNeeded() { + if (!ReactNativeInfo.isReactNativeApp()) { + return + } + + ReactMarkersLogger.attach() + } + + /** + * Wait for React-Native to finish loading (i.e. make RN context available). + * + * @param applicationContext The app context, implicitly assumed to be a [ReactApplication] instance. + */ + fun waitForRNBootstrap(applicationContext: Context) { + if (!ReactNativeInfo.isReactNativeApp()) { + return + } + + (applicationContext as ReactApplication).let { + val reactContext = awaitNewReactNativeContext(it, null) + + enableOrDisableSynchronization(reactContext) + } + } + /** * Reloads the React Native context and thus all javascript code. * @@ -40,26 +65,6 @@ object ReactNativeExtension { val reactContext = awaitNewReactNativeContext(it, previousReactContext) enableOrDisableSynchronization(reactContext, networkSyncEnabled) - hackRN50OrHigherWaitForReady() - } - } - - /** - * Wait for React-Native to finish loading (i.e. make RN context available). - * - * @param applicationContext The app context, implicitly assumed to be a [ReactApplication] instance. - */ - @JvmStatic - fun waitForRNBootstrap(applicationContext: Context) { - if (!ReactNativeInfo.isReactNativeApp()) { - return - } - - (applicationContext as ReactApplication).let { - val reactContext = awaitNewReactNativeContext(it, null) - - enableOrDisableSynchronization(reactContext) - hackRN50OrHigherWaitForReady() } } @@ -145,18 +150,6 @@ object ReactNativeExtension { } } - private fun hackRN50OrHigherWaitForReady() { - if (ReactNativeInfo.rnVersion().minor in 50..62) { - try { - //TODO- Temp hack to make Detox usable for RN>=50 till we find a better sync solution. - Thread.sleep(1000) - } catch (e: InterruptedException) { - e.printStackTrace() - } - - } - } - private fun clearIdlingResources() { rnIdlingResources?.unregisterAll() rnIdlingResources = null diff --git a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java index 7381312973..2991418b49 100644 --- a/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java +++ b/detox/android/detox/src/full/java/com/wix/detox/reactnative/idlingresources/AnimatedModuleIdlingResource.java @@ -55,8 +55,20 @@ public class AnimatedModuleIdlingResource implements DescriptiveIdlingResource, private final static String METHOD_HAS_ACTIVE_ANIMATIONS = "hasActiveAnimations"; + // max timeout is 5 seconds + private final static long MAX_TIMEOUT = 5000L; + // loading max timeout is 15 seconds + private final static long MAX_LOADING_TIMEOUT = 15000L; + // assume it idle for 1 seconds + private final static long IDLE_TIME = 1000L; + + private static boolean isInit = true; + + private static long last_idle_time = System.currentTimeMillis(); + private final static Map busyHint = new HashMap() {{ - put("reason", "Animations running on screen"); + float busy_time = System.currentTimeMillis() - last_idle_time; + put("reason", "Animations running on screen: " + busy_time + " ms"); }}; private ResourceCallback callback = null; @@ -85,6 +97,21 @@ public Map getBusyHint() { @Override public boolean isIdleNow() { + // if busy for more than max_timeout seconds, we assume it's stuck + // the longest animation we have is loading screen, which is only showed at initial + final long max_timeout = isInit ? MAX_LOADING_TIMEOUT : MAX_TIMEOUT; + if (System.currentTimeMillis() - last_idle_time > max_timeout) { + Log.e(LOG_TAG, "AnimatedModule is stuck."); + if (callback != null) { + callback.onTransitionToIdle(); + } + if (System.currentTimeMillis() - last_idle_time > max_timeout + IDLE_TIME) { + isInit = false; + last_idle_time = System.currentTimeMillis(); + } + return true; + } + Class animModuleClass = null; try { animModuleClass = Class.forName(CLASS_ANIMATED_MODULE); @@ -93,6 +120,7 @@ public boolean isIdleNow() { if (callback != null) { callback.onTransitionToIdle(); } + last_idle_time = System.currentTimeMillis(); return true; } @@ -109,15 +137,18 @@ public boolean isIdleNow() { if (callback != null) { callback.onTransitionToIdle(); } + last_idle_time = System.currentTimeMillis(); return true; } if (ReactNativeInfo.rnVersion().getMinor() >= 51) { if(isIdleRN51(animModuleClass)) { + last_idle_time = System.currentTimeMillis(); return true; } } else { if (isIdleRNOld(animModuleClass)) { + last_idle_time = System.currentTimeMillis(); return true; } } @@ -134,6 +165,7 @@ public boolean isIdleNow() { callback.onTransitionToIdle(); } // Log.i(LOG_TAG, "AnimatedModule is idle."); + last_idle_time = System.currentTimeMillis(); return true; } diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt index 72012b181c..f882edeba4 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/DeviceDisplay.kt @@ -17,7 +17,7 @@ object DeviceDisplay { } @JvmStatic - fun getScreenSizeInPX(): FloatArray? { + fun getScreenSizeInPX(): FloatArray { val metrics = getDisplayMetrics() return floatArrayOf(metrics.widthPixels.toFloat(), metrics.heightPixels.toFloat()) } diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/UiControllerSpy.kt b/detox/android/detox/src/main/java/com/wix/detox/espresso/UiControllerSpy.kt index 742af777f0..051be7931e 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/UiControllerSpy.kt +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/UiControllerSpy.kt @@ -11,11 +11,8 @@ class UiControllerSpy: MethodsSpy("uiController") { fun eventInjectionsIterator(): Iterator = historyOf("injectMotionEvent").iterator() companion object { - @JvmStatic val instance = UiControllerSpy() - @JvmStatic - @JvmOverloads fun attachThroughProxy(spy: UiControllerSpy = instance) { val eventsInjectorReflected = EventsInjectorReflected(getUiController()) diff --git a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java index bfca25cb1f..bef721c984 100644 --- a/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java +++ b/detox/android/detox/src/main/java/com/wix/detox/espresso/scroll/ScrollHelper.java @@ -1,14 +1,18 @@ package com.wix.detox.espresso.scroll; import android.content.Context; +import android.graphics.Insets; import android.graphics.Point; +import android.os.Build; import android.util.Log; import android.view.View; import android.view.ViewConfiguration; +import android.view.WindowInsets; import com.wix.detox.action.common.MotionDir; import com.wix.detox.espresso.DeviceDisplay; +import androidx.annotation.VisibleForTesting; import androidx.test.espresso.UiController; import androidx.test.platform.app.InstrumentationRegistry; @@ -42,8 +46,8 @@ private ScrollHelper() { * * @param direction Direction to scroll (see {@link MotionDir}) * @param amountInDP Density Independent Pixels - * @param startOffsetPercentX Percentage denoting where X-swipe should start, with respect to the scrollable view. Null means select automatically. - * @param startOffsetPercentY Percentage denoting where Y-swipe should start, with respect to the scrollable view. Null means select automatically. + * @param startOffsetPercentX Percentage denoting where the scroll should start from on the X-axis, with respect to the scrollable view. Null means select automatically. + * @param startOffsetPercentY Percentage denoting where the scroll should start from on the Y-axis, with respect to the scrollable view. Null means select automatically. */ public static void perform(UiController uiController, View view, @MotionDir int direction, double amountInDP, Float startOffsetPercentX, Float startOffsetPercentY) throws ScrollEdgeException { final int amountInPx = DeviceDisplay.convertDpiToPx(amountInDP); @@ -51,7 +55,7 @@ public static void perform(UiController uiController, View view, @MotionDir int final int times = amountInPx / safeScrollableRangePx; final int remainder = amountInPx % safeScrollableRangePx; - Log.d(LOG_TAG, "prescroll amountDP="+amountInDP + " amountPx="+amountInPx + " scrollableRangePx="+safeScrollableRangePx + " times="+times + " remainder="+remainder); + Log.d(LOG_TAG, "prescroll amountDP=" + amountInDP + " amountPx=" + amountInPx + " scrollableRangePx=" + safeScrollableRangePx + " times=" + times + " remainder=" + remainder); for (int i = 0; i < times; ++i) { scrollOnce(uiController, view, direction, safeScrollableRangePx, startOffsetPercentX, startOffsetPercentY); @@ -64,10 +68,12 @@ public static void perform(UiController uiController, View view, @MotionDir int * of the screen.) * * @param direction Direction to scroll (see {@link @MotionDir}) + * @param startOffsetPercentX Percentage denoting where the scroll should start from on the X-axis, with respect to the scrollable view. Null means select automatically. + * @param startOffsetPercentY Percentage denoting where the scroll should start from on the Y-axis, with respect to the scrollable view. Null means select automatically. */ - public static void performOnce(UiController uiController, View view, @MotionDir int direction) throws ScrollEdgeException { + public static void performOnce(UiController uiController, View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) throws ScrollEdgeException { final int scrollableRangePx = getViewSafeScrollableRangePix(view, direction); - scrollOnce(uiController, view, direction, scrollableRangePx, null, null); + scrollOnce(uiController, view, direction, scrollableRangePx, startOffsetPercentX, startOffsetPercentY); } private static void scrollOnce(UiController uiController, View view, @MotionDir int direction, int userAmountPx, Float startOffsetPercentX, Float startOffsetPercentY) throws ScrollEdgeException { @@ -113,25 +119,32 @@ private static void waitForFlingToFinish(View view, UiController uiController) { } } - private static int getViewSafeScrollableRangePix(View view, @MotionDir int direction) { + @VisibleForTesting + public static int getViewSafeScrollableRangePix(View view, @MotionDir int direction) { final float[] screenSize = DeviceDisplay.getScreenSizeInPX(); final int[] pos = new int[2]; view.getLocationInWindow(pos); int range; switch (direction) { - case MOTION_DIR_LEFT: range = (int) ((screenSize[0] - pos[0]) * SCROLL_RANGE_SAFE_PERCENT); break; - case MOTION_DIR_RIGHT: range = (int) ((pos[0] + view.getWidth()) * SCROLL_RANGE_SAFE_PERCENT); break; - case MOTION_DIR_UP: range = (int) ((screenSize[1] - pos[1]) * SCROLL_RANGE_SAFE_PERCENT); break; - default: range = (int) ((pos[1] + view.getHeight()) * SCROLL_RANGE_SAFE_PERCENT); break; + case MOTION_DIR_LEFT: + range = (int) ((screenSize[0] - pos[0]) * SCROLL_RANGE_SAFE_PERCENT); + break; + case MOTION_DIR_RIGHT: + range = (int) ((pos[0] + view.getWidth()) * SCROLL_RANGE_SAFE_PERCENT); + break; + case MOTION_DIR_UP: + range = (int) ((screenSize[1] - pos[1]) * SCROLL_RANGE_SAFE_PERCENT); + break; + default: + range = (int) ((pos[1] + view.getHeight()) * SCROLL_RANGE_SAFE_PERCENT); + break; } return range; } - private static Point getScrollStartPoint(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) { + private static int[] getScrollStartOffsetInView(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) { final int safetyOffset = DeviceDisplay.convertDpiToPx(1); - - Point point = getGlobalViewLocation(view); float offsetFactorX; float offsetFactorY; int safetyOffsetX; @@ -169,8 +182,87 @@ private static Point getScrollStartPoint(View view, @MotionDir int direction, Fl int offsetX = ((int) (view.getWidth() * offsetFactorX) + safetyOffsetX); int offsetY = ((int) (view.getHeight() * offsetFactorY) + safetyOffsetY); - point.offset(offsetX, offsetY); - return point; + return new int[]{offsetX, offsetY}; + } + + /** + * Calculates the scroll start point, with respect to the global screen coordinates and gesture insets. + * @param view The view to scroll. + * @param direction The scroll direction. + * @param startOffsetPercentX The scroll start offset, as a percentage of the view's width. Null means select automatically. + * @param startOffsetPercentY The scroll start offset, as a percentage of the view's height. Null means select automatically. + * @return a Point object, denoting the scroll start point. + */ + private static Point getScrollStartPoint(View view, @MotionDir int direction, Float startOffsetPercentX, Float startOffsetPercentY) { + Point result = getGlobalViewLocation(view); + + // 1. Calculate the scroll start point, with respect to the view's location. + int[] coordinates = getScrollStartOffsetInView(view, direction, startOffsetPercentX, startOffsetPercentY); + + // 2. Make sure that the start point is within the scrollable area, taking into account the system gesture insets. + coordinates = applyScreenInsets(view, direction, coordinates[0], coordinates[1]); + + result.offset(coordinates[0], coordinates[1]); + return result; + } + + /** + * Calculates the scroll start point, with respect to the system gesture insets. + * @param view + * @param direction The scroll direction. + * @param x The scroll start point, with respect to the view's location. + * @param y The scroll start point, with respect to the view's location. + * @return an array of two integers, denoting the scroll start point, with respect to the system gesture insets. + */ + private static int[] applyScreenInsets(View view, int direction, int x, int y) { + // System gesture insets are only available on Android Q (29) and above. + if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) { + return new int[]{x, y}; + } + + final float[] displaySize = DeviceDisplay.getScreenSizeInPX(); + + // Calculate the min/max scrollable area, taking into account the system gesture insets. + // By default we assume the scrollable area is the entire screen. + // 2dp is a safety offset to make sure we don't hit the system gesture area. + int gestureSafeOffset = DeviceDisplay.convertDpiToPx(2); + int minX = gestureSafeOffset; + int minY = gestureSafeOffset; + float maxX = displaySize[0] - gestureSafeOffset; + float maxY = displaySize[1] - gestureSafeOffset; + + // Try to get the root window insets, and if available, use them to calculate the scrollable area. + WindowInsets rootWindowInsets = view.getRootWindowInsets(); + if (rootWindowInsets == null) { + Log.w(LOG_TAG, "Could not get root window insets"); + } else { + Insets gestureInsets = rootWindowInsets.getSystemGestureInsets(); + minX = gestureInsets.left; + minY = gestureInsets.top; + maxX -= gestureInsets.right; + maxY -= gestureInsets.bottom; + + Log.d(LOG_TAG, + "System gesture insets: " + + gestureInsets + " minX=" + minX + " minY=" + minY + " maxX=" + maxX + " maxY=" + maxY + " currentX=" + x + " currentY=" + y); + } + + switch (direction) { + case MOTION_DIR_UP: + y = (int) Math.max(y, minY); + break; + case MOTION_DIR_DOWN: + y = (int) Math.min(y, maxY); + break; + case MOTION_DIR_LEFT: + x = (int) Math.max(x, minX); + break; + case MOTION_DIR_RIGHT: + x = (int) Math.min(x, maxX); + break; + } + + return new int[]{x, y}; } private static Point getScrollEndPoint(Point startPoint, @MotionDir int direction, int userAmountPx, Float startOffsetPercentX, Float startOffsetPercentY) { @@ -209,13 +301,18 @@ private static Point getScrollEndPoint(Point startPoint, @MotionDir int directio return point; } + /** + * Calculates the global location of the view on the screen. + * @param view The view to calculate. + * @return a Point object, denoting the global location of the view. + */ private static Point getGlobalViewLocation(View view) { int[] pos = new int[2]; view.getLocationInWindow(pos); return new Point(pos[0], pos[1]); } - private static ViewConfiguration getViewConfiguration() { + public static ViewConfiguration getViewConfiguration() { if (viewConfiguration == null) { final Context applicationContext = InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext(); viewConfiguration = ViewConfiguration.get(applicationContext); diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/ActivityLaunchHelperTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/ActivityLaunchHelperTest.kt new file mode 100644 index 0000000000..5817635353 --- /dev/null +++ b/detox/android/detox/src/testFull/java/com/wix/detox/ActivityLaunchHelperTest.kt @@ -0,0 +1,111 @@ +package com.wix.detox + +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 +import org.junit.Test +import org.mockito.ArgumentMatchers.anyBoolean +import org.mockito.ArgumentMatchers.anyString +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class ActivityLaunchHelperTest { + + private val initialURL = "detox://unit-test" + private val bundleExtraLaunchArgs = "launchArgs" + private val notificationPath = "path/to/notification.data" + + private lateinit var intent: Intent + private lateinit var launchArgsAsBundle: Bundle + private lateinit var notificationDataAsBundle: Bundle + private lateinit var testRule: ActivityTestRule + private lateinit var launchArgs: LaunchArgs + private lateinit var intentsFactory: LaunchIntentsFactory + private lateinit var notificationDataParser: NotificationDataParser + + private fun uut() = ActivityLaunchHelper(testRule, launchArgs, intentsFactory, { notificationDataParser }) + + @Before + fun setup() { + intent = Intent() + launchArgsAsBundle = mock() + notificationDataAsBundle = mock() + + testRule = mock() + launchArgs = mock() { + on { asIntentBundle() }.thenReturn(launchArgsAsBundle) + } + intentsFactory = mock() + notificationDataParser = mock() { + on { toBundle() }.thenReturn(notificationDataAsBundle) + } + } + + @Test + fun `default-activity -- should launch using test rule, with a clean intent`() { + givenCleanLaunch() + uut().launchActivityUnderTest() + verify(testRule).launchActivity(eq(intent)) + } + + @Test + fun `default-activity -- should apply launch args to intent`() { + givenCleanLaunch() + uut().launchActivityUnderTest() + assertIntentHasLaunchArgs() + } + + @Test + fun `default activity, with a url -- should launch based on the url`() { + givenLaunchWithInitialURL() + uut().launchActivityUnderTest() + verify(testRule).launchActivity(eq(intent)) + verify(intentsFactory).intentWithUrl(initialURL, true) + } + + @Test + fun `default activity, with a url -- should apply launch args to intent`() { + givenLaunchWithInitialURL() + uut().launchActivityUnderTest() + assertIntentHasLaunchArgs() + } + + @Test + fun `default activity, with notification data -- should launch with the data as bundle`() { + givenLaunchWithNotificationData() + uut().launchActivityUnderTest() + verify(testRule).launchActivity(eq(intent)) + verify(intentsFactory).intentWithNotificationData(any(), eq(notificationDataAsBundle), eq(true)) + } + + @Test + fun `default activity, with notification data -- should apply launch args to intent`() { + givenLaunchWithNotificationData() + uut().launchActivityUnderTest() + assertIntentHasLaunchArgs() + } + + private fun givenCleanLaunch() { + whenever(intentsFactory.cleanIntent()).thenReturn(intent) + } + private fun givenLaunchWithInitialURL() { + whenever(launchArgs.hasUrlOverride()).thenReturn(true) + whenever(launchArgs.urlOverride).thenReturn(initialURL) + whenever(intentsFactory.intentWithUrl(anyString(), anyBoolean())).thenReturn(intent) + } + private fun givenLaunchWithNotificationData() { + whenever(launchArgs.hasNotificationPath()).thenReturn(true) + whenever(launchArgs.notificationPath).thenReturn(notificationPath) + whenever(intentsFactory.intentWithNotificationData(any(), any(), anyBoolean())) + .thenReturn(intent) + } + private fun assertIntentHasLaunchArgs() { + assertThat(intent.hasExtra(bundleExtraLaunchArgs)).isTrue + assertThat(intent.getBundleExtra(bundleExtraLaunchArgs)).isEqualTo(launchArgsAsBundle) + } +} diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt b/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt index be74d5f69f..35943c41b7 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/adapters/server/QueryStatusActionHandlerSpec.kt @@ -3,6 +3,7 @@ package com.wix.detox.adapters.server import com.wix.detox.TestEngineFacade import com.wix.detox.inquiry.DetoxBusyResource import com.wix.detox.inquiry.DetoxBusyResourceDescription +import org.mockito.kotlin.doReturn import org.mockito.kotlin.eq import org.mockito.kotlin.mock import org.mockito.kotlin.verify @@ -42,16 +43,17 @@ object QueryStatusActionHandlerSpec : Spek({ } describe("given a busy app") { - fun aBusyResourceDescription(description: Map): DetoxBusyResourceDescription = - mock { - on { json() }.thenReturn(description) - } + fun aBusyResource(identifier: String): DetoxBusyResource { - val mockedDescription = aBusyResourceDescription(mapOf("mock" to identifier)) - return mock { - on { getDescription() }.thenReturn(mockedDescription) + + return mock { + on { getDescription() } doReturn DetoxBusyResourceDescription.Builder() + .name("mock") + .addDescription("mock", identifier) + .build() } + } it("should send a descriptive busy-status indication") { @@ -60,8 +62,8 @@ object QueryStatusActionHandlerSpec : Spek({ val expectedData = mapOf("status" to mapOf( "app_status" to "busy", "busy_resources" to listOf( - mapOf("mock" to "some-resource"), - mapOf("mock" to "yet-another-resource"), + mapOf("name" to "mock", "description" to mapOf("mock" to "some-resource")), + mapOf("name" to "mock", "description" to mapOf("mock" to "yet-another-resource")) ) )) whenever(testEngineFacade.getAllBusyResources()).thenReturn(listOf(busyResource, busyResource2)) @@ -71,4 +73,4 @@ object QueryStatusActionHandlerSpec : Spek({ } } } -}) \ No newline at end of file +}) diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt index e7159e223c..cb5d9481e6 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/action/GetAttributesActionTest.kt @@ -4,7 +4,6 @@ import android.view.View import android.widget.CheckBox import android.widget.ProgressBar import android.widget.TextView -import com.facebook.react.views.slider.ReactSlider import com.google.android.material.slider.Slider import com.wix.detox.reactnative.ui.getAccessibilityLabel import org.assertj.core.api.Assertions.assertThat @@ -190,7 +189,8 @@ class GetAttributesActionTest { assertThat(resultJson.opt("value")).isEqualTo(42) } - @Test + //FIXME: Complete the integration over RN72 or delete this test +/* @Test fun `should return RN-Slider via value attribute`() { val progressBar: ReactSlider = mock { on { max } doReturn 100 @@ -199,7 +199,7 @@ class GetAttributesActionTest { val resultJson = perform(progressBar) assertThat(resultJson.opt("value")).isEqualTo(0.5) - } + }*/ @Test fun `should return material-Slider state through value attribute`() { diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/common/ReactSliderHelperTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/common/ReactSliderHelperTest.kt index 942f4e130c..32c30247f5 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/common/ReactSliderHelperTest.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/common/ReactSliderHelperTest.kt @@ -1,6 +1,6 @@ package com.wix.detox.espresso.common -import com.facebook.react.views.slider.ReactSlider +//import com.facebook.react.views.slider.ReactSlider import org.assertj.core.api.Assertions.assertThat import org.junit.Before import org.junit.Test @@ -14,26 +14,28 @@ import org.robolectric.RobolectricTestRunner * Note: This only tests against the react *legacy* (non-community) slider in order * to avoid having to install the community slider under node_modules just for this. */ -@RunWith(RobolectricTestRunner::class) -class ReactSliderHelperTest { - lateinit var slider: ReactSlider - lateinit var uut: ReactSliderHelper - @Before - fun setup() { - slider = mock() - uut = ReactSliderHelper.create(slider) - } - - private fun givenNativeProgressTraits(current: Int, max: Int) { - whenever(slider.progress).doReturn(current) - whenever(slider.max).doReturn(max) - } - - @Test - fun `should properly calculate current progress, in percentage`() { - givenNativeProgressTraits(current = 20, max = 100) - - assertThat(uut.getCurrentProgressPct()).isEqualTo(0.2) - } -} +// FIXME: RN72 upgrade - this test is broken +//@RunWith(RobolectricTestRunner::class) +//class ReactSliderHelperTest { +// lateinit var slider: ReactSlider +// lateinit var uut: ReactSliderHelper +// +// @Before +// fun setup() { +// slider = mock() +// uut = ReactSliderHelper.create(slider) +// } +// +// private fun givenNativeProgressTraits(current: Int, max: Int) { +// whenever(slider.progress).doReturn(current) +// whenever(slider.max).doReturn(max) +// } +// +// @Test +// fun `should properly calculate current progress, in percentage`() { +// givenNativeProgressTraits(current = 20, max = 100) +// +// assertThat(uut.getCurrentProgressPct()).isEqualTo(0.2) +// } +//} diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/RegexMatcherTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/RegexMatcherTest.kt index 4f07654686..78f52cb84e 100644 --- a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/RegexMatcherTest.kt +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/matcher/RegexMatcherTest.kt @@ -3,10 +3,7 @@ package com.wix.detox.espresso.matcher import org.junit.Test import kotlin.test.assertFalse import kotlin.test.assertTrue -import org.junit.runner.RunWith -import org.robolectric.RobolectricTestRunner -@RunWith(RobolectricTestRunner::class) class RegexMatcherTest { @Test fun `should work with string matching regex`() { diff --git a/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt new file mode 100644 index 0000000000..4eeee3a823 --- /dev/null +++ b/detox/android/detox/src/testFull/java/com/wix/detox/espresso/scroll/ScrollHelperTest.kt @@ -0,0 +1,188 @@ +package com.wix.detox.espresso.scroll + +import android.graphics.Insets +import android.view.MotionEvent +import android.view.View +import android.view.WindowInsets +import androidx.test.espresso.UiController +import androidx.test.platform.app.InstrumentationRegistry +import com.wix.detox.action.common.MOTION_DIR_DOWN +import com.wix.detox.action.common.MOTION_DIR_LEFT +import com.wix.detox.action.common.MOTION_DIR_RIGHT +import com.wix.detox.action.common.MOTION_DIR_UP +import com.wix.detox.espresso.DeviceDisplay +import org.junit.Test +import org.junit.runner.RunWith +import org.mockito.kotlin.any +import org.mockito.kotlin.argumentCaptor +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever +import org.robolectric.RobolectricTestRunner +import org.robolectric.annotation.Config +import kotlin.test.assertEquals + +private const val INSETS_SIZE = 100 +private const val SCROLL_RANGE_SAFE_PERCENT = 0.9f // ScrollHelper.SCROLL_RANGE_SAFE_PERCENT + +@Config( + qualifiers = "xxxhdpi", // 1280x1880 + sdk = [33] +) +@RunWith(RobolectricTestRunner::class) +class ScrollHelperTest { + + private val display = DeviceDisplay.getScreenSizeInPX() + private val displayWidth = display[0].toInt() + private val displayHeight = display[1].toInt() + private val touchSlopPx = ScrollHelper.getViewConfiguration().scaledTouchSlop + private val safetyMarginPx = DeviceDisplay.convertDpiToPx(2.0) + + private val uiControllerMock = mock() + private val viewMock = mockViewWithGestureNavigation(displayWidth, displayHeight) + + @Test + fun `should scrolling down by 200 when gesture navigation enabled`() { + val amountInDp = 200.0 + val amountInPx = amountInDp * DeviceDisplay.getDensity() + + ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null) + + val upEvent = getUpEvent() + // Verify that the scroll started at the center of the view + assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0) + // Verify that the scroll ended at the center of the view minus the requested amount + assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx - INSETS_SIZE, upEvent.y.toDouble(), 0.0) + } + + @Test + fun `should scrolling down by 200 when gesture navigation disabled`() { + val amountInDp = 200.0 + val amountInPx = amountInDp * DeviceDisplay.getDensity() + + val viewMock = mockViewWithoutGestureNavigation(displayWidth, displayHeight) + ScrollHelper.perform(uiControllerMock, viewMock, MOTION_DIR_DOWN, amountInDp, null, null) + + val upEvent = getUpEvent() + // Verify that the scroll started at the center of the view + assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0) + // Verify that the scroll ended at the center of the view minus the requested amount + assertEquals(displayHeight - amountInPx - touchSlopPx - safetyMarginPx, upEvent.y.toDouble(), 0.0) + } + + @Test + fun `should scroll down to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_DOWN, null, null) + val upEvent = getUpEvent() + val amountInPx = displayHeight * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetY = displayHeight - amountInPx - + touchSlopPx - + safetyMarginPx - + INSETS_SIZE + + assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0) + assertEquals(targetY, upEvent.y, 0.0f) + } + + @Test + fun `should scroll left to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_LEFT, null, null) + val upEvent = getUpEvent() + val amountInPx = displayWidth * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetX = amountInPx + + touchSlopPx + + INSETS_SIZE + + assertEquals(targetX, upEvent.x, 0.0f) + assertEquals(displayHeight / 2.0, upEvent.y.toDouble(), 0.0) + } + + @Test + fun `should scroll up to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_UP,null, null) + val upEvent = getUpEvent() + val amountInPx = displayHeight * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetY = amountInPx + + touchSlopPx + + INSETS_SIZE + + assertEquals(displayWidth / 2.0, upEvent.x.toDouble(), 0.0) + assertEquals(targetY, upEvent.y, 0.0f) + } + + @Test + fun `should scroll right to edge on full screen view when gesture navigation enabled`() { + ScrollHelper.performOnce(uiControllerMock, viewMock, MOTION_DIR_RIGHT, null, null) + val upEvent = getUpEvent() + val amountInPx = displayWidth * SCROLL_RANGE_SAFE_PERCENT + + // Calculate where the scroll should end + val targetX = displayWidth - amountInPx - + touchSlopPx - + safetyMarginPx - + INSETS_SIZE + + assertEquals(targetX, upEvent.x, 0.0f) + assertEquals(displayHeight / 2.0, upEvent.y.toDouble(), 0.0) + } + + /** + * Get the performed UP event from the ui controller + */ + private fun getUpEvent(): MotionEvent { + val capture = argumentCaptor>() + // Capture the events from the ui controller + verify(uiControllerMock).injectMotionEventSequence(capture.capture()) + + val listOfCapturedEvents = capture.firstValue.toList() + // The last event is the UP event with the target coordinates. All of the rest are not interesting + return listOfCapturedEvents.last() + } + + private fun mockViewWithoutGestureNavigation(displayWidth: Int, displayHeight: Int): View { + // This is how we disable gesture navigation + val windowInsets = mock() { + whenever(it.systemGestureInsets).thenReturn( + Insets.of(0, 0, 0, 0) + ) + } + + return mockView(displayWidth, displayHeight, windowInsets) + } + + /** + * Mock a view with gesture navigation enabled + */ + private fun mockViewWithGestureNavigation(displayWidth: Int, displayHeight: Int): View { + // This is how we enable gesture navigation + val windowInsets = mock() { + whenever(it.systemGestureInsets).thenReturn( + Insets.of(INSETS_SIZE, INSETS_SIZE, INSETS_SIZE, INSETS_SIZE) + ) + } + + return mockView(displayWidth, displayHeight, windowInsets) + } + + private fun mockView( + displayWidth: Int, + displayHeight: Int, + windowInsets: WindowInsets + ): View { + val view = mock() { + whenever(it.width).thenReturn(displayWidth) + whenever(it.height).thenReturn(displayHeight) + whenever(it.canScrollVertically(any())).thenReturn(true) // We allow endless scroll + whenever(it.canScrollHorizontally(any())).thenReturn(true) // We allow endless scroll + whenever(it.context).thenReturn(InstrumentationRegistry.getInstrumentation().targetContext) + whenever(it.rootWindowInsets).thenReturn(windowInsets) + } + return view + } +} diff --git a/detox/android/gradle/wrapper/gradle-wrapper.jar b/detox/android/gradle/wrapper/gradle-wrapper.jar index 13372aef5e..7f93135c49 100644 Binary files a/detox/android/gradle/wrapper/gradle-wrapper.jar and b/detox/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/detox/android/gradle/wrapper/gradle-wrapper.properties b/detox/android/gradle/wrapper/gradle-wrapper.properties index 59e6dfebf6..ac72c34e8a 100644 --- a/detox/android/gradle/wrapper/gradle-wrapper.properties +++ b/detox/android/gradle/wrapper/gradle-wrapper.properties @@ -1,7 +1,7 @@ -#Sun Sep 15 22:36:02 IDT 2019 distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-all.zip - diff --git a/detox/android/gradlew b/detox/android/gradlew index 3447eeb0cb..0adc8e1a53 100755 --- a/detox/android/gradlew +++ b/detox/android/gradlew @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # # Copyright ยฉ 2015-2021 the original authors. @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +198,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in @@ -205,6 +214,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/detox/android/gradlew.bat b/detox/android/gradlew.bat index 8a0b282aa6..6689b85bee 100644 --- a/detox/android/gradlew.bat +++ b/detox/android/gradlew.bat @@ -1,90 +1,92 @@ -@if "%DEBUG%" == "" @echo off -@rem ########################################################################## -@rem -@rem Gradle startup script for Windows -@rem -@rem ########################################################################## - -@rem Set local scope for the variables with windows NT shell -if "%OS%"=="Windows_NT" setlocal - -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - -set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. -set APP_BASE_NAME=%~n0 -set APP_HOME=%DIRNAME% - -@rem Find java.exe -if defined JAVA_HOME goto findJavaFromJavaHome - -set JAVA_EXE=java.exe -%JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init - -echo. -echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:findJavaFromJavaHome -set JAVA_HOME=%JAVA_HOME:"=% -set JAVA_EXE=%JAVA_HOME%/bin/java.exe - -if exist "%JAVA_EXE%" goto init - -echo. -echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% -echo. -echo Please set the JAVA_HOME variable in your environment to match the -echo location of your Java installation. - -goto fail - -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - -:execute -@rem Setup the command line - -set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar - -@rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% - -:end -@rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd - -:fail -rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of -rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 - -:mainEnd -if "%OS%"=="Windows_NT" endlocal - -:omega +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if %ERRORLEVEL% equ 0 goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if %ERRORLEVEL% equ 0 goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/detox/android/rninfo.gradle b/detox/android/rninfo.gradle index 475ac5b26a..c09d2af1d2 100644 --- a/detox/android/rninfo.gradle +++ b/detox/android/rninfo.gradle @@ -1,25 +1,38 @@ import groovy.json.JsonSlurper -def rnVersion = getRNVersion(project.rootDir) -def rnMajorVer = getMajorVersion(rnVersion) -println "[$project] RNInfo: detected React Native version: $rnVersion (major=$rnMajorVer)" - -project.ext.rnInfo = [ - version: rnVersion, - majorVersion: rnMajorVer, - isRN69OrHigher: rnMajorVer >= 69, - isRN70OrHigher: rnMajorVer >= 70, - isRN71OrHigher: rnMajorVer >= 71, -] - -private static def getRNVersion(workingDir) { +def getRNVersion = { workingDir -> + println("RNInfo: workingDir=$workingDir") def jsonSlurper = new JsonSlurper() - Map packageJSON = jsonSlurper.parse(new File("$workingDir/../node_modules/react-native/package.json")) + def packageFile = "$workingDir/../node_modules/react-native/package.json" + println("RNInfo: reading $packageFile") + Map packageJSON = jsonSlurper.parse(new File(packageFile)) String rnVersion = packageJSON.get('version') return rnVersion } -private static def getMajorVersion(semanticVersion) { +def getMajorVersionInternal = { semanticVersion -> Integer rnVersionMajor = semanticVersion.split('\\.')[1].toInteger() return rnVersionMajor } + +ext.getRnMajorVersion = { workingDir -> + String rnVersion = getRNVersion(workingDir) + Integer rnVersionMajor = getMajorVersionInternal(rnVersion) + return rnVersionMajor +} + +def rnVersion = getRNVersion(rootDir) +def rnMajorVer = getMajorVersionInternal(rnVersion) +if (hasProperty('project')) { + println "[$project] RNInfo: detected React Native version: $rnVersion (major=$rnMajorVer)" + + project.ext.rnInfo = [ + version : rnVersion, + majorVersion : rnMajorVer, + isRN69OrHigher: rnMajorVer >= 69, + isRN70OrHigher: rnMajorVer >= 70, + isRN71OrHigher: rnMajorVer >= 71, + isRN72OrHigher: rnMajorVer >= 72, + isRN73OrHigher: rnMajorVer >= 73, + ] +} diff --git a/detox/android/settings.gradle b/detox/android/settings.gradle index 5ba1fee377..7a926a68c3 100644 --- a/detox/android/settings.gradle +++ b/detox/android/settings.gradle @@ -1,2 +1,14 @@ +apply from: '../android/rninfo.gradle' include ':detox' -includeBuild('../node_modules/react-native-gradle-plugin') + +println("RNInfo: rootDir=$rootDir") + +def rnMajorVer = getRnMajorVersion(rootDir) +println "[settings] RNInfo: detected React Native version: (major=$rnMajorVer)" + +if (rnMajorVer < 72) { + includeBuild('../node_modules/react-native-gradle-plugin') +} else { + includeBuild('../node_modules/@react-native/gradle-plugin') +} + diff --git a/detox/detox.d.ts b/detox/detox.d.ts new file mode 100644 index 0000000000..aba5e45540 --- /dev/null +++ b/detox/detox.d.ts @@ -0,0 +1,1836 @@ +// TypeScript definitions for Detox +// Original authors (from DefinitelyTyped): +// * Jane Smith +// * Tareq El-Masri +// * Steve Chun +// * Hammad Jutt +// * pera +// * Max Komarychev +// * Dor Ben Baruch + +import { BunyanDebugStreamOptions } from 'bunyan-debug-stream'; + +declare global { + namespace Detox { + //#region DetoxConfig + + interface DetoxConfig extends DetoxConfigurationCommon { + /** + * @example extends: './relative/detox.config' + * @example extends: '@my-org/detox-preset' + */ + extends?: string; + + apps?: Record; + devices?: Record; + selectedConfiguration?: string; + configurations: Record; + } + + type DetoxConfigurationCommon = { + artifacts?: false | DetoxArtifactsConfig; + behavior?: DetoxBehaviorConfig; + logger?: DetoxLoggerConfig; + session?: DetoxSessionConfig; + testRunner?: DetoxTestRunnerConfig; + }; + + interface DetoxArtifactsConfig { + rootDir?: string; + pathBuilder?: string; + plugins?: { + log?: 'none' | 'failing' | 'all' | DetoxLogArtifactsPluginConfig; + screenshot?: 'none' | 'manual' | 'failing' | 'all' | DetoxScreenshotArtifactsPluginConfig; + video?: 'none' | 'failing' | 'all' | DetoxVideoArtifactsPluginConfig; + instruments?: 'none' | 'all' | DetoxInstrumentsArtifactsPluginConfig; + uiHierarchy?: 'disabled' | 'enabled' | DetoxUIHierarchyArtifactsPluginConfig; + + [pluginId: string]: unknown; + }; + } + + interface DetoxBehaviorConfig { + init?: { + /** + * By default, Detox exports `device`, `expect`, `element`, `by` and `waitFor` + * as global variables. If you want to control their initialization manually, + * set this property to `false`. + * + * This is useful when during E2E tests you also need to run regular expectations + * in Node.js. Jest's `expect` for instance, will not be overridden by Detox when + * this option is used. + */ + exposeGlobals?: boolean; + /** + * By default, Detox will uninstall and install the app upon initialization. + * If you wish to reuse the existing app for a faster run, set the property to + * `false`. + */ + reinstallApp?: boolean; + /** + * When false, `detox test` command always deletes the shared lock file on start, + * assuming it had been left from the previous, already finished test session. + * The lock file contains information about busy and free devices and ensures + * no device can be used simultaneously by multiple test workers. + * + * Setting it to **true** might be useful when if you need to run multiple + * `detox test` commands in parallel, e.g. test a few configurations at once. + * + * @default false + */ + keepLockFile?: boolean; + }; + launchApp?: 'auto' | 'manual'; + cleanup?: { + shutdownDevice?: boolean; + }; + } + + type _DetoxLoggerOptions = Omit; + + interface DetoxLoggerConfig { + /** + * Log level filters the messages printed to your terminal, + * and it does not affect the logs written to the artifacts. + * + * Use `info` by default. + * Use `error` or warn when you want to make the output as silent as possible. + * Use `debug` to control what generally is happening under the hood. + * Use `trace` when troubleshooting specific issues. + * + * @default 'info' + */ + level?: DetoxLogLevel; + /** + * When enabled, hijacks all the console methods (console.log, console.warn, etc) + * so that the messages printed via them are formatted and saved as Detox logs. + * + * @default true + */ + overrideConsole?: boolean; + /** + * Since Detox is using + * {@link https://www.npmjs.com/package/bunyan-debug-stream bunyan-debug-stream} + * for printing logs, all its options are exposed for sake of simplicity + * of customization. + * + * The only exception is {@link BunyanDebugStreamOptions#out} option, + * which is always set to `process.stdout`. + * + * You can also pass a callback function to override the logger config + * programmatically, e.g. depending on the selected log level. + * + * @see {@link BunyanDebugStreamOptions} + */ + options?: _DetoxLoggerOptions | ((config: Partial) => _DetoxLoggerOptions); + } + + interface DetoxSessionConfig { + autoStart?: boolean; + debugSynchronization?: number; + server?: string; + sessionId?: string; + } + + interface DetoxTestRunnerConfig { + args?: { + /** + * The command to use for runner: 'jest', 'nyc jest', + */ + $0: string; + /** + * The positional arguments to pass to the runner. + */ + _?: string[]; + /** + * Any other properties recognized by test runner + */ + [prop: string]: unknown; + }; + + /** + * This is an add-on section used by our Jest integration code (but not Detox core itself). + * In other words, if youโ€™re implementing (or using) a custom integration with some other test runner, feel free to define a section for yourself (e.g. `testRunner.mocha`) + */ + jest?: { + /** + * Environment setup timeout + * + * As a part of the environment setup, Detox boots the device and installs the apps. + * If that takes longer than the specified value, the entire test suite will be considered as failed, e.g.: + * ```plain text + * FAIL e2e/starter.test.js + * โ— Test suite failed to run + * + * Exceeded timeout of 300000ms while setting up Detox environment + * ``` + * + * The default value is 5 minutes. + * + * @default 300000 + * @see {@link https://jestjs.io/docs/configuration/#testenvironment-string} + */ + setupTimeout?: number | undefined; + /** + * Environemnt teardown timeout + * + * If the environment teardown takes longer than the specified value, Detox will throw a timeout error. + * The default value is half a minute. + * + * @default 30000 (30 seconds) + * @see {@link https://jestjs.io/docs/configuration/#testenvironment-string} + */ + teardownTimeout?: number | undefined; + /** + * Jest provides an API to re-run individual failed tests: `jest.retryTimes(count)`. + * When Detox detects the use of this API, it suppresses its own CLI retry mechanism controlled via `detox test โ€ฆ --retries ` or {@link DetoxTestRunnerConfig#retries}. + * The motivation is simple โ€“ activating the both mechanisms is apt to increase your test duration dramatically, if your tests are flaky. + * If you wish nevertheless to use both the mechanisms simultaneously, set it to `true`. + * + * @default false + * @see {@link https://jestjs.io/docs/29.0/jest-object#jestretrytimesnumretries-options} + */ + retryAfterCircusRetries?: boolean; + /** + * By default, Jest prints the test names and their status (_passed_ or _failed_) at the very end of the test session. + * When enabled, it makes Detox to print messages like these each time the new test starts and ends: + * ```plain text + * 18:03:36.258 detox[40125] i Sanity: should have welcome screen + * 18:03:37.495 detox[40125] i Sanity: should have welcome screen [OK] + * 18:03:37.496 detox[40125] i Sanity: should show hello screen after tap + * 18:03:38.928 detox[40125] i Sanity: should show hello screen after tap [OK] + * 18:03:38.929 detox[40125] i Sanity: should show world screen after tap + * 18:03:40.351 detox[40125] i Sanity: should show world screen after tap [OK] + * ``` + * By default, it is enabled automatically in test sessions with a single worker. + * And vice versa, if multiple tests are executed concurrently, Detox turns it off to avoid confusion in the log. + * Use boolean values, `true` or `false`, to turn off the automatic choice. + * + * @default undefined + */ + reportSpecs?: boolean | undefined; + /** + * In the environment setup phase, Detox boots the device and installs the apps. + * This flag tells Detox to print messages like these every time the device gets assigned to a specific suite: + * + * ```plain text + * 18:03:29.869 detox[40125] i starter.test.js is assigned to 4EC84833-C7EA-4CA3-A6E9-5C30A29EA596 (iPhone 15) + * ``` + * + * @default true + */ + reportWorkerAssign?: boolean | undefined; + }; + /** + * Retries count. Zero means a single attempt to run tests. + */ + retries?: number; + /** + * When true, tells Detox CLI to cancel next retrying if it gets + * at least one report about a permanent test suite failure. + * Has no effect, if {@link DetoxTestRunnerConfig#retries} is + * undefined or set to zero. + * + * @default false + * @see {DetoxInternals.DetoxTestFileReport#isPermanentFailure} + */ + bail?: boolean; + /** + * When true, tells `detox test` to spawn the test runner in a detached mode. + * This is useful in CI environments, where you want to intercept SIGINT and SIGTERM signals to gracefully shut down the test runner and the device. + * Instead of passing the kill signal to the child process (the test runner), Detox will send an emergency shutdown request to all the workers, and then it will wait for them to finish. + * @default false + */ + detached?: boolean; + /** + * Custom handler to process --inspect-brk CLI flag. + * Use it when you rely on another test runner than Jest to mutate the config. + */ + inspectBrk?: (config: DetoxTestRunnerConfig) => void; + /** + * Forward environment variables to the spawned test runner + * accordingly to the given CLI argument overrides. + * + * If false, Detox CLI will be only printing a hint message on + * how to start the test runner using environment variables, + * in case when a user wants to avoid using Detox CLI. + * + * @default false + */ + forwardEnv?: boolean; + } + + type DetoxAppConfig = (DetoxBuiltInAppConfig | DetoxCustomAppConfig) & { + /** + * App name to use with device.selectApp(appName) calls. + * Can be omitted if you have a single app under the test. + * + * @see Device#selectApp + */ + name?: string; + }; + + type DetoxDeviceConfig = DetoxBuiltInDeviceConfig | DetoxCustomDriverConfig; + + interface DetoxLogArtifactsPluginConfig { + enabled?: boolean; + keepOnlyFailedTestsArtifacts?: boolean; + } + + interface DetoxScreenshotArtifactsPluginConfig { + enabled?: boolean; + keepOnlyFailedTestsArtifacts?: boolean; + shouldTakeAutomaticSnapshots?: boolean; + takeWhen?: { + testStart?: boolean; + testFailure?: boolean; + testDone?: boolean; + appNotReady?: boolean; + }; + } + + interface DetoxVideoArtifactsPluginConfig { + enabled?: boolean; + keepOnlyFailedTestsArtifacts?: boolean; + android?: Partial<{ + size: [number, number]; + bitRate: number; + timeLimit: number; + verbose: boolean; + }>; + simulator?: Partial<{ + codec: string; + }>; + } + + interface DetoxInstrumentsArtifactsPluginConfig { + enabled?: boolean; + } + + interface DetoxUIHierarchyArtifactsPluginConfig { + enabled?: boolean; + } + + type DetoxBuiltInAppConfig = (DetoxIosAppConfig | DetoxAndroidAppConfig); + + interface DetoxIosAppConfig { + type: 'ios.app'; + binaryPath: string; + bundleId?: string; + build?: string; + start?: string; + launchArgs?: Record; + } + + interface DetoxAndroidAppConfig { + type: 'android.apk'; + binaryPath: string; + bundleId?: string; + build?: string; + start?: string; + testBinaryPath?: string; + launchArgs?: Record; + /** + * TCP ports to `adb reverse` upon the installation. + * E.g. 8081 - to be able to access React Native packager in Debug mode. + * + * @example [8081] + */ + reversePorts?: number[]; + } + + interface DetoxCustomAppConfig { + type: string; + + [prop: string]: unknown; + } + + type DetoxBuiltInDeviceConfig = + | DetoxIosSimulatorDriverConfig + | DetoxAttachedAndroidDriverConfig + | DetoxAndroidEmulatorDriverConfig + | DetoxGenymotionCloudDriverConfig; + + interface DetoxIosSimulatorDriverConfig { + type: 'ios.simulator'; + device: string | Partial; + bootArgs?: string; + } + + interface DetoxSharedAndroidDriverConfig { + forceAdbInstall?: boolean; + utilBinaryPaths?: string[]; + } + + interface DetoxAttachedAndroidDriverConfig extends DetoxSharedAndroidDriverConfig { + type: 'android.attached'; + device: string | { adbName: string }; + } + + interface DetoxAndroidEmulatorDriverConfig extends DetoxSharedAndroidDriverConfig { + type: 'android.emulator'; + device: string | { avdName: string }; + bootArgs?: string; + gpuMode?: 'auto' | 'host' | 'swiftshader_indirect' | 'angle_indirect' | 'guest' | 'off'; + headless?: boolean; + /** + * @default true + */ + readonly?: boolean; + } + + interface DetoxGenymotionCloudDriverConfig extends DetoxSharedAndroidDriverConfig { + type: 'android.genycloud'; + device: string | { recipeUUID: string; } | { recipeName: string; }; + } + + interface DetoxCustomDriverConfig { + type: string; + + [prop: string]: unknown; + } + + interface IosSimulatorQuery { + id: string; + type: string; + name: string; + os: string; + } + + type DetoxConfiguration = DetoxConfigurationCommon & ( + | DetoxConfigurationSingleApp + | DetoxConfigurationMultiApps + ); + + interface DetoxConfigurationSingleApp { + device: DetoxAliasedDevice; + app: DetoxAliasedApp; + } + + interface DetoxConfigurationMultiApps { + device: DetoxAliasedDevice; + apps: DetoxAliasedApp[]; + } + + type DetoxAliasedDevice = string | DetoxDeviceConfig; + + type DetoxAliasedApp = string | DetoxAppConfig; + + //#endregion + + interface DetoxExportWrapper { + readonly device: Device; + + readonly element: ElementFacade; + + readonly waitFor: WaitForFacade; + + readonly expect: ExpectFacade; + + readonly by: ByFacade; + + readonly web: WebFacade; + + readonly DetoxConstants: { + userNotificationTriggers: { + push: 'push'; + calendar: 'calendar'; + timeInterval: 'timeInterval'; + location: 'location'; + }; + userActivityTypes: { + searchableItem: string; + browsingWeb: string; + }, + searchableItemActivityIdentifier: string; + }; + + /** + * Detox logger instance. Can be used for saving user logs to the general log file. + */ + readonly log: Logger; + + /** + * @deprecated + * + * Deprecated - use {@link Detox.Logger#trace} + * Detox tracer instance. Can be used for building timelines in Google Event Tracing format. + */ + readonly trace: { + /** @deprecated */ + readonly startSection: (name: string) => void; + /** @deprecated */ + readonly endSection: (name: string) => void; + }; + + /** + * Trace a single call, with a given name and arguments. + * + * @deprecated + * @param sectionName The name of the section to trace. + * @param promiseOrFunction Promise or a function that provides a promise. + * @param args Optional arguments to pass to the trace. + * @returns The returned value of the traced call. + * @see https://wix.github.io/Detox/docs/19.x/api/detox-object-api/#detoxtracecall + */ + readonly traceCall: (event: string, action: () => Promise, args?: Record) => Promise; + } + + interface Logger { + readonly level: DetoxLogLevel; + + readonly fatal: _LogMethod; + readonly error: _LogMethod; + readonly warn: _LogMethod; + readonly info: _LogMethod; + readonly debug: _LogMethod; + readonly trace: _LogMethod; + + child(context?: Partial): Logger; + } + + /** @internal */ + interface _LogMethod extends _LogMethodSignature { + readonly begin: _LogMethodSignature; + readonly complete: _CompleteMethodSignature; + readonly end: _LogMethodSignature; + } + + /** @internal */ + interface _LogMethodSignature { + (...args: unknown[]): void + (event: LogEvent, ...args: unknown[]): void; + } + + /** @internal */ + interface _CompleteMethodSignature { + (message: string, action: T | (() => T)): T; + (event: LogEvent, message: string, action: T | (() => T)): T; + } + + type LogEvent = { + /** Use when there's a risk of logging several parallel duration events. */ + id?: string | number; + /** Optional. Event categories (tags) to facilitate filtering. */ + cat?: string | string[]; + /** Optional. Color name (applicable in Google Chrome Trace Format) */ + cname?: string; + + /** Reserved property. Process ID. */ + pid?: never; + /** Reserved property. Thread ID. */ + tid?: never; + /** Reserved property. Timestamp. */ + ts?: never; + /** Reserved property. Event phase. */ + ph?: never; + + [customProperty: string]: unknown; + }; + + type DetoxLogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; + + type Point2D = { + x: number, + y: number, + } + + /** + * A construct allowing for the querying and modification of user arguments passed to an app upon launch by Detox. + * + * @see AppLaunchArgs#modify + * @see AppLaunchArgs#reset + * @see AppLaunchArgs#get + */ + interface AppLaunchArgs { + /** + * Shared (global) arguments that are not specific to a particular application. + * Selecting another app does not reset them, yet they still can be overridden + * by configuring app-specific launch args. + * @see Device#selectApp + * @see AppLaunchArgs + */ + readonly shared: ScopedAppLaunchArgs; + + /** + * Modify the launch-arguments via a modifier object, according to the following logic: + * - Non-nullish modifier properties would set a new value or override the previous value of + * existing properties with the same name. + * - Modifier properties set to either `undefined` or `null` would delete the corresponding property + * if it existed. + * These custom app launch arguments get erased whenever you select a different application. + * If you need to share them between all the applications, use {@link AppLaunchArgs#shared} property. + * Note: app-specific launch args have a priority over shared ones. + * + * @param modifier The modifier object. + * @example + * // With current launch arguments set to: + * // { + * // mockServerPort: 1234, + * // mockServerCredentials: 'user@test.com:12345678', + * // } + * device.appLaunchArgs.modify({ + * mockServerPort: 4321, + * mockServerCredentials: null, + * mockServerToken: 'abcdef', + * }); + * await device.launchApp(); + * // ==> launch-arguments become: + * // { + * // mockServerPort: 4321, + * // mockServerToken: 'abcdef', + * // } + */ + modify(modifier: object): this; + + /** + * Reset all app-specific launch arguments (back to an empty object). + * If you need to reset the shared launch args, use {@link AppLaunchArgs#shared}. + */ + reset(): this; + + /** + * Get all currently set launch arguments (including shared ones). + * @returns An object containing all launch-arguments. + * Note: mutating the values inside the result object is pointless, as it is immutable. + */ + get(): object; + } + + /** + * Shared (global) arguments that are not specific to a particular application. + */ + interface ScopedAppLaunchArgs { + /** @see AppLaunchArgs#modify */ + modify(modifier: object): this; + + /** @see AppLaunchArgs#reset */ + reset(): this; + + /** @see AppLaunchArgs#get */ + get(): object; + } + + type DigitWithoutZero = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; + type Digit = 0 | DigitWithoutZero; + type BatteryLevel = `${Digit}` | `${DigitWithoutZero}${Digit}` | "100"; + + interface Device { + /** + * Holds the environment-unique ID of the device, namely, the adb ID on Android (e.g. emulator-5554) and the Mac-global simulator UDID on iOS - + * as used by simctl (e.g. AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE). + */ + id: string; + /** + * Holds a descriptive name of the device. Example: emulator-5554 (Pixel_API_29) + */ + name: string; + + /** + * Select the current app (relevant only to multi-app configs) by its name. + * After execution, all app-specific device methods will target the selected app. + * + * @see DetoxAppConfig#name + * @example + * await device.selectApp('passenger'); + * await device.launchApp(); // passenger + * // ... run tests for the passenger app + * await device.uninstallApp(); // passenger + * await device.selectApp('driver'); + * await device.installApp(); // driver + * await device.launchApp(); // driver + * // ... run tests for the driver app + * await device.terminateApp(); // driver + */ + selectApp(app: string): Promise; + + // TODO: document permissions types. + /** + * Launch the app. + * + *

For info regarding launch arguments, refer to the [dedicated guide](https://wix.github.io/Detox/docs/guide/launch-args). + * + * @example + * // Terminate the app and launch it again. If set to false, the simulator will try to bring app from background, + * // if the app isn't running, it will launch a new instance. default is false + * await device.launchApp({newInstance: true}); + * @example + * // Grant or deny runtime permissions for your application. + * await device.launchApp({permissions: {calendar: 'YES'}}); + * @example + * // Mock opening the app from URL to test your app's deep link handling mechanism. + * await device.launchApp({url: url}); + * @example + * // Start the app with some custom arguments. + * await device.launchApp({ + * launchArgs: {arg1: 1, arg2: "2"}, + * }); + */ + launchApp(config?: DeviceLaunchAppConfig): Promise; + + /** + * Relaunch the app. Convenience method that calls {@link Device#launchApp} + * with { newInstance: true } override. + * + * @deprecated + * @param config + * @see Device#launchApp + */ + relaunchApp(config?: DeviceLaunchAppConfig): Promise; + + /** + * Access the user-defined launch-arguments predefined through static scopes such as the Detox configuration file and + * command-line arguments. This access allows - through dedicated methods, for both value-querying and + * modification (see {@link AppLaunchArgs}). + * Refer to the [dedicated guide](https://wix.github.io/Detox/docs/api/launch-args) for complete details. + * + * @example + * // With Detox being preconfigured statically to use these arguments in app launch: + * // { + * // mockServerPort: 1234, + * // } + * // The following code would result in these arguments eventually passed into the launched app: + * // { + * // mockServerPort: 4321, + * // mockServerToken: 'uvwxyz', + * // } + * device.appLaunchArgs.modify({ + * mockServerPort: 4321, + * mockServerToken: 'abcdef', + * }); + * await device.launchApp({ launchArgs: { mockServerToken: 'uvwxyz' } }); + * + * @see AppLaunchArgs + */ + appLaunchArgs: AppLaunchArgs; + + /** + * Terminate the app. + * + * @example + * // By default, terminateApp() with no params will terminate the app + * await device.terminateApp(); + * @example + * // To terminate another app, specify its bundle id + * await device.terminateApp('other.bundle.id'); + */ + terminateApp(bundle?: string): Promise; + + /** + * Send application to background by bringing com.apple.springboard to the foreground. + * Combining sendToHome() with launchApp({newInstance: false}) will simulate app coming back from background. + * @example + * await device.sendToHome(); + * await device.launchApp({newInstance: false}); + */ + sendToHome(): Promise; + + /** + * If this is a React Native app, reload the React Native JS bundle. This action is much faster than device.launchApp(), and can be used if you just need to reset your React Native logic. + * + * @example await device.reloadReactNative() + */ + reloadReactNative(): Promise; + + /** + * By default, installApp() with no params will install the app file defined in the current configuration. + * To install another app, specify its path + * @example await device.installApp(); + * @example await device.installApp('path/to/other/app'); + */ + installApp(path?: any): Promise; + + /** + * By default, uninstallApp() with no params will uninstall the app defined in the current configuration. + * To uninstall another app, specify its bundle id + * @example await device.installApp('other.bundle.id'); + */ + uninstallApp(bundle?: string): Promise; + + /** + * Mock opening the app from URL. sourceApp is an optional parameter to specify source application bundle id. + */ + openURL(url: { url: string; sourceApp?: string }): Promise; + + /** + * Mock handling of received user notification when app is in foreground. + */ + sendUserNotification(...params: any[]): Promise; + + /** + * Mock handling of received user activity when app is in foreground. + */ + sendUserActivity(...params: any[]): Promise; + + /** + * Takes "portrait" or "landscape" and rotates the device to the given orientation. Currently only available in the iOS Simulator. + */ + setOrientation(orientation: Orientation): Promise; + + /** + * Sets the simulator/emulator location to the given latitude and longitude. + * + *

On iOS `setLocation` is dependent on [fbsimctl](https://github.com/facebook/idb/tree/4b7929480c3c0f158f33f78a5b802c1d0e7030d2/fbsimctl) + * which [is now deprecated](https://github.com/wix/Detox/issues/1371). + * If `fbsimctl` is not installed, the command will fail, asking for it to be installed. + * + *

On Android `setLocation` will work with both Android Emulator (bundled with Android development tools) and Genymotion. + * The correct permissions must be set in your app manifest. + * + * @example await device.setLocation(32.0853, 34.7818); + */ + setLocation(lat: number, lon: number): Promise; + + /** + * (iOS only) Override simulatorโ€™s status bar. + * @platform iOS + * @param {config} config status bar configuration. + * @example + * await device.setStatusBar({ + * time: "12:34", + * // Set the date or time to a fixed value. + * // If the string is a valid ISO date string it will also set the date on relevant devices. + * dataNetwork: "wifi", + * // If specified must be one of 'hide', 'wifi', '3g', '4g', 'lte', 'lte-a', 'lte+', '5g', '5g+', '5g-uwb', or '5g-uc'. + * wifiMode: "failed", + * // If specified must be one of 'searching', 'failed', or 'active'. + * wifiBars: "2", + * // If specified must be 0-3. + * cellularMode: "searching", + * // If specified must be one of 'notSupported', 'searching', 'failed', or 'active'. + * cellularBars: "3", + * // If specified must be 0-4. + * operatorName: "A1", + * // Set the cellular operator/carrier name. Use '' for the empty string. + * batteryState: "charging", + * // If specified must be one of 'charging', 'charged', or 'discharging'. + * batteryLevel: "50", + * // If specified must be 0-100. + * }); + */ + setStatusBar(config: { + time?: string, + dataNetwork?: "hide" | "wifi" | "3g" | "4g" | "lte" | "lte-a" | "lte+" | "5g" | "5g+" | "5g-uwb" | "5g-uc", + wifiMode?: "searching" |"failed" | "active", + wifiBars?: "0" | "1" | "2" | "3", + cellularMode?: "notSupported" | "searching" | "failed" | "active", + cellularBars?: "0" | "1" | "2" | "3" | "4", + operatorName?: string; + batteryState?: "charging" | "charged" | "discharging", + batteryLevel?: BatteryLevel, + }): Promise; + + /** + * Disable network synchronization mechanism on preferred endpoints. Useful if you want to on skip over synchronizing on certain URLs. + * + * @example await device.setURLBlacklist(['.*127.0.0.1.*']); + */ + setURLBlacklist(urls: string[]): Promise; + + /** + * Temporarily disable synchronization (idle/busy monitoring) with the app - namely, stop waiting for the app to go idle before moving forward in the test execution. + * + *

This API is useful for cases where test assertions must be made in an area of your application where it is okay for it to ever remain partly *busy* (e.g. due to an + * endlessly repeating on-screen animation). However, using it inherently suggests that you are likely to resort to applying `sleep()`'s in your test code - testing + * that area, **which is not recommended and can never be 100% stable. + * **Therefore, as a rule of thumb, test code running "inside" a sync-disabled mode must be reduced to the bare minimum. + * + *

Note: Synchronization is enabled by default, and it gets **reenabled on every launch of a new instance of the app.** + * + * @example await device.disableSynchronization(); + */ + disableSynchronization(): Promise; + + /** + * Reenable synchronization (idle/busy monitoring) with the app - namely, resume waiting for the app to go idle before moving forward in the test execution, after a + * previous disabling of it through a call to `device.disableSynchronization()`. + * + *

Warning: Making this call would resume synchronization **instantly**, having its returned promise only resolve when the app becomes idle again. + * In other words, this **must only be called after you navigate back to "the safe zone", where the app should be able to eventually become idle again**, or it would + * remain suspended "forever" (i.e. until a safeguard time-out expires). + * + * @example await device.enableSynchronization(); + */ + enableSynchronization(): Promise; + + /** + * Resets the Simulator to clean state (like the Simulator > Reset Content and Settings... menu item), especially removing previously set permissions. + * + * @example await device.resetContentAndSettings(); + */ + resetContentAndSettings(): Promise; + + /** + * Returns the current device, ios or android. + * + * @example + * if (device.getPlatform() === 'ios') { + * await expect(loopSwitch).toHaveValue('1'); + * } + */ + getPlatform(): 'ios' | 'android'; + + /** + * Takes a screenshot on the device and schedules putting it in the artifacts folder upon completion of the current test. + * @param name for the screenshot artifact + * @returns a temporary path to the screenshot. + * @example + * test('Menu items should have logout', async () => { + * const tempPath = await device.takeScreenshot('tap on menu'); + * // The temporary path will remain valid until the test completion. + * // Afterwards, the screenshot will be moved, e.g.: + * // * on success, to: /โœ“ Menu items should have Logout/tap on menu.png + * // * on failure, to: /โœ— Menu items should have Logout/tap on menu.png + * }); + */ + takeScreenshot(name: string): Promise; + + /** + * (iOS only) Saves a view hierarchy snapshot (*.viewhierarchy) of the currently opened application + * to a temporary folder and schedules putting it to the artifacts folder upon the completion of + * the current test. The file can be opened later in Xcode 12.0 and above. + * @see https://developer.apple.com/documentation/xcode-release-notes/xcode-12-release-notes#:~:text=57933113 + * @param [name="capture"] optional name for the *.viewhierarchy artifact + * @returns a temporary path to the captured view hierarchy snapshot. + * @example + * test('Menu items should have logout', async () => { + * await device.captureViewHierarchy('myElements'); + * // The temporary path will remain valid until the test completion. + * // Afterwards, the artifact will be moved, e.g.: + * // * on success, to: /โœ“ Menu items should have Logout/myElements.viewhierarchy + * // * on failure, to: /โœ— Menu items should have Logout/myElements.viewhierarchy + * }); + */ + captureViewHierarchy(name?: string): Promise; + + /** + * Simulate shake (iOS Only) + */ + shake(): Promise; + + /** + * Toggles device enrollment in biometric auth (TouchID or FaceID) (iOS Only) + * @example await device.setBiometricEnrollment(true); + * @example await device.setBiometricEnrollment(false); + */ + setBiometricEnrollment(enabled: boolean): Promise; + + /** + * Simulates the success of a face match via FaceID (iOS Only) + */ + matchFace(): Promise; + + /** + * Simulates the failure of a face match via FaceID (iOS Only) + */ + unmatchFace(): Promise; + + /** + * Simulates the success of a finger match via TouchID (iOS Only) + */ + matchFinger(): Promise; + + /** + * Simulates the failure of a finger match via TouchID (iOS Only) + */ + unmatchFinger(): Promise; + + /** + * Clears the simulator keychain (iOS Only) + */ + clearKeychain(): Promise; + + /** + * Simulate press back button (Android Only) + * @example await device.pressBack(); + */ + pressBack(): Promise; + + /** + * (Android Only) + * Exposes UiAutomator's UiDevice API (https://developer.android.com/reference/android/support/test/uiautomator/UiDevice). + * This is not a part of the official Detox API, + * it may break and change whenever an update to UiDevice or UiAutomator gradle dependencies ('androidx.test.uiautomator:uiautomator') is introduced. + * UIDevice's autogenerated code reference: https://github.com/wix/Detox/blob/master/detox/src/android/espressoapi/UIDevice.js + */ + getUiDevice(): Promise; + + /** + * (Android Only) + * Runs `adb reverse tcp:PORT tcp:PORT` for the current device + * to enable network requests forwarding on localhost:PORT (computer<->device). + * For more information, see {@link https://www.reddit.com/r/reactnative/comments/5etpqw/what_do_you_call_what_adb_reverse_is_doing|here}. + * This is a no-op when running on iOS. + */ + reverseTcpPort(port: number): Promise; + + /** + * (Android Only) + * Runs `adb reverse --remove tcp:PORT tcp:PORT` for the current device + * to disable network requests forwarding on localhost:PORT (computer<->device). + * For more information, see {@link https://www.reddit.com/r/reactnative/comments/5etpqw/what_do_you_call_what_adb_reverse_is_doing|here}. + * This is a no-op when running on iOS. + */ + unreverseTcpPort(port: number): Promise; + } + + /** + * @deprecated + */ + type DetoxAny = NativeElement & WaitFor; + + 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).tap(); + */ + atIndex(index: number): NativeElement; + } + + interface NativeElement extends NativeElementActions { + } + + interface ByFacade { + /** + * by.id will match an id that is given to the view via testID prop. + * @example + * // In a React Native component add testID like so: + * + * // Then match with by.id: + * await element(by.id('tap_me')); + * await element(by.id(/^tap_[a-z]+$/)); + */ + id(id: string | RegExp): NativeMatcher; + + /** + * Find an element by text, useful for text fields, buttons. + * @example + * await element(by.text('Tap Me')); + * await element(by.text(/^Tap .*$/)); + */ + text(text: string | RegExp): NativeMatcher; + + /** + * Find an element by accessibilityLabel on iOS, or by contentDescription on Android. + * @example + * await element(by.label('Welcome')); + * await element(by.label(/[a-z]+/i)); + */ + label(label: string | RegExp): NativeMatcher; + + /** + * Find an element by native view type. + * @example await element(by.type('RCTImageView')); + */ + type(nativeViewType: string): NativeMatcher; + + /** + * Find an element with an accessibility trait. (iOS only) + * @example await element(by.traits(['button'])); + */ + traits(traits: string[]): NativeMatcher; + + /** + * Collection of web matchers + */ + readonly web: ByWebFacade; + } + + interface ByWebFacade { + /** + * Find an element on the DOM tree by its id + * @param id + * @example + * web.element(by.web.id('testingh1')) + */ + id(id: string): WebMatcher; + + /** + * Find an element on the DOM tree by its CSS class + * @param className + * @example + * web.element(by.web.className('a')) + */ + className(className: string): WebMatcher; + + /** + * Find an element on the DOM tree matching the given CSS selector + * @param cssSelector + * @example + * web.element(by.web.cssSelector('#cssSelector')) + */ + cssSelector(cssSelector: string): WebMatcher; + + /** + * Find an element on the DOM tree by its "name" attribute + * @param name + * @example + * web.element(by.web.name('sec_input')) + */ + name(name: string): WebMatcher; + + /** + * Find an element on the DOM tree by its XPath + * @param xpath + * @example + * web.element(by.web.xpath('//*[@id="testingh1-1"]')) + */ + xpath(xpath: string): WebMatcher; + + /** + * Find an element on the DOM tree by its link text (href content) + * @param linkText + * @example + * web.element(by.web.href('disney.com')) + */ + href(linkText: string): WebMatcher; + + /** + * Find an element on the DOM tree by its partial link text (href content) + * @param linkTextFragment + * @example + * web.element(by.web.hrefContains('disney')) + */ + hrefContains(linkTextFragment: string): WebMatcher; + + /** + * Find an element on the DOM tree by its tag name + * @param tag + * @example + * web.element(by.web.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: NativeMatcher): NativeMatcher; + + /** + * Find an element by a matcher with a child matcher + * @example await element(by.id('Son883').withDescendant(by.id('Grandson883'))); + */ + 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 { + /** + * 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. + */ + (matcher?: NativeMatcher): WebViewElement; + } + + interface Expect> { + + /** + * Expect the view to be at least N% visible. If no number is provided then defaults to 75%. Negating this + * expectation with a `not` expects the view's visible area to be smaller than N%. + * @param pct optional integer ranging from 1 to 100, indicating how much percent of the view should be + * visible to the user to be accepted. + * @example await expect(element(by.id('mainTitle'))).toBeVisible(35); + */ + toBeVisible(pct?: number): R; + + /** + * Negate the expectation. + * @example await expect(element(by.id('cancelButton'))).not.toBeVisible(); + */ + not: this; + + /** + * Expect the view to not be visible. + * @example await expect(element(by.id('cancelButton'))).toBeNotVisible(); + * @deprecated Use `.not.toBeVisible()` instead. + */ + toBeNotVisible(): R; + + /** + * Expect the view to exist in the UI hierarchy. + * @example await expect(element(by.id('okButton'))).toExist(); + */ + toExist(): R; + + /** + * Expect the view to not exist in the UI hierarchy. + * @example await expect(element(by.id('cancelButton'))).toNotExist(); + * @deprecated Use `.not.toExist()` instead. + */ + toNotExist(): R; + + /** + * Expect the view to be focused. + * @example await expect(element(by.id('emailInput'))).toBeFocused(); + */ + toBeFocused(): R; + + /** + * Expect the view not to be focused. + * @example await expect(element(by.id('passwordInput'))).toBeNotFocused(); + * @deprecated Use `.not.toBeFocused()` instead. + */ + toBeNotFocused(): R; + + /** + * In React Native apps, expect UI component of type to have text. + * In native iOS apps, expect UI elements of type UIButton, UILabel, UITextField or UITextViewIn to have inputText with text. + * @example await expect(element(by.id('mainTitle'))).toHaveText('Welcome back!); + */ + toHaveText(text: string): R; + + /** + * Expects a specific accessibilityLabel, as specified via the `accessibilityLabel` prop in React Native. + * On the native side (in both React Native and pure-native apps), that is equivalent to `accessibilityLabel` + * on iOS and contentDescription on Android. Refer to Detox's documentation in order to learn about caveats + * with accessibility-labels in React Native apps. + * @example await expect(element(by.id('submitButton'))).toHaveLabel('Submit'); + */ + toHaveLabel(label: string): R; + + /** + * In React Native apps, expect UI component to have testID with that id. + * In native iOS apps, expect UI element to have accessibilityIdentifier with that id. + * @example await expect(element(by.text('Submit'))).toHaveId('submitButton'); + */ + toHaveId(id: string): R; + + /** + * Expects a toggle-able element (e.g. a Switch or a Check-Box) to be on/checked or off/unchecked. + * As a reference, in react-native, this is the equivalent switch component. + * @example await expect(element(by.id('switch'))).toHaveToggleValue(true); + */ + toHaveToggleValue(value: boolean): R; + + /** + * Expect components like a Switch to have a value ('0' for off, '1' for on). + * @example await expect(element(by.id('temperatureDial'))).toHaveValue('25'); + */ + toHaveValue(value: any): R; + + /** + * Expect Slider to have a position (0 - 1). + * Can have an optional tolerance to take into account rounding issues on ios + * @example await expect(element(by.id('SliderId'))).toHavePosition(0.75); + * @example await expect(element(by.id('SliderId'))).toHavePosition(0.74, 0.1); + */ + toHaveSliderPosition(position: number, tolerance?: number): Promise; + } + + 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('bigButton'))).toExist().withTimeout(2000); + */ + (element: NativeElement): Expect; + } + + interface WaitFor { + /** + * Waits for the condition to be met until the specified time (millis) have elapsed. + * @example await waitFor(element(by.id('bigButton'))).toExist().withTimeout(2000); + */ + withTimeout(millis: number): Promise; + + /** + * Performs the action repeatedly on the element until an expectation is met + * @example await waitFor(element(by.text('Item #5'))).toBeVisible().whileElement(by.id('itemsList')).scroll(50, 'down'); + */ + whileElement(by: NativeMatcher): NativeElement & WaitFor; + + // TODO: not sure about & WaitFor - check if we can chain whileElement multiple times + } + + interface NativeElementActions { + /** + * Simulate tap on an element + * @param point relative coordinates to the matched element (the element size could changes on different devices or even when changing the device font size) + * @example await element(by.id('tappable')).tap(); + * @example await element(by.id('tappable')).tap({ x:5, y:10 }); + */ + tap(point?: Point2D): Promise; + + /** + * Simulate long press on an element + * @param duration (iOS only) custom press duration time, in milliseconds. Optional (default is 1000ms). + * @example await element(by.id('tappable')).longPress(); + */ + longPress(duration?: number): Promise; + + /** + * 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: NativeElement, + normalizedTargetPositionX: number, normalizedTargetPositionY: number, speed: Speed, holdDuration: number): Promise; + + /** + * 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; + + /** + * 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 }); + * @deprecated Use `.tap()` instead. + */ + tapAtPoint(point: Point2D): Promise; + + /** + * Use the builtin keyboard to type text into a text field. + * @example await element(by.id('textField')).typeText('passcode'); + */ + typeText(text: string): Promise; + + /** + * Paste text into a text field. + * @example await element(by.id('textField')).replaceText('passcode again'); + */ + replaceText(text: string): Promise; + + /** + * Clear text from a text field. + * @example await element(by.id('textField')).clearText(); + */ + clearText(): Promise; + + /** + * Taps the backspace key on the built-in keyboard. + * @example await element(by.id('textField')).tapBackspaceKey(); + */ + tapBackspaceKey(): Promise; + + /** + * Taps the return key on the built-in keyboard. + * @example await element(by.id('textField')).tapReturnKey(); + */ + tapReturnKey(): Promise; + + /** + * Scrolls a given amount of pixels in the provided direction, starting from the provided start positions. + * @param pixels - independent device pixels + * @param direction - left/right/up/down + * @param startPositionX - the X starting scroll position, in percentage; valid input: `[0.0, 1.0]`, `NaN`; default: `NaN`โ€”choose the best value automatically + * @param startPositionY - the Y starting scroll position, in percentage; valid input: `[0.0, 1.0]`, `NaN`; default: `NaN`โ€”choose the best value automatically + * @example await element(by.id('scrollView')).scroll(100, 'down', NaN, 0.85); + * @example await element(by.id('scrollView')).scroll(100, 'up'); + */ + scroll( + pixels: number, + direction: Direction, + startPositionX?: number, + startPositionY?: number + ): Promise; + + /** + * Scroll to index. + * @example await element(by.id('scrollView')).scrollToIndex(10); + */ + scrollToIndex( + index: Number + ): Promise; + + /** + * Scroll to edge. + * @param edge - left|right|top|bottom + * @param startPositionX - the X starting scroll position, in percentage; valid input: `[0.0, 1.0]`, `NaN`; default: `NaN`โ€”choose the best value automatically + * @param startPositionY - the Y starting scroll position, in percentage; valid input: `[0.0, 1.0]`, `NaN`; default: `NaN`โ€”choose the best value automatically + * @example await element(by.id('scrollView')).scrollTo('bottom', NaN, 0.85); + * @example await element(by.id('scrollView')).scrollTo('top'); + */ + scrollTo(edge: Direction, startPositionX?: number, startPositionY?: number): Promise; + + /** + * Adjust slider to position. + * @example await element(by.id('slider')).adjustSliderToPosition(0.75); + */ + adjustSliderToPosition(newPosition: number): Promise; + + /** + * Swipes in the provided direction at the provided speed, started from percentage. + * @param speed default: `fast` + * @param percentage screen percentage to swipe; valid input: `[0.0, 1.0]` + * @param optional normalizedStartingPointX X coordinate of swipe starting point, relative to the view width; valid input: `[0.0, 1.0]` + * @param normalizedStartingPointY Y coordinate of swipe starting point, relative to the view height; valid input: `[0.0, 1.0]` + * @example await element(by.id('scrollView')).swipe('down'); + * @example await element(by.id('scrollView')).swipe('down', 'fast'); + * @example await element(by.id('scrollView')).swipe('down', 'fast', 0.5); + * @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; + + /** + * Sets a picker viewโ€™s column to the given value. This function supports both date pickers and general picker views. (iOS Only) + * Note: When working with date pickers, you should always set an explicit locale when launching your app in order to prevent flakiness from different date and time styles. + * See [here](https://wix.github.io/Detox/docs/api/device-object-api#9-launch-with-a-specific-language-ios-only) for more information. + * + * @param column number of datepicker column (starts from 0) + * @param value string value in set column (must be correct) + * @example + * await expect(element(by.type('UIPickerView'))).toBeVisible(); + * await element(by.type('UIPickerView')).setColumnToValue(1,"6"); + * await element(by.type('UIPickerView')).setColumnToValue(2,"34"); + */ + setColumnToValue(column: number, value: string): Promise; + + /** + * Sets the date of a date-picker according to the specified date-string and format. + * @param dateString Textual representation of a date (e.g. '2023/01/01'). Should be in coherence with the format specified by `dateFormat`. + * @param dateFormat Format of `dateString`: Generally either 'ISO8601' or an explicitly specified format (e.g. 'yyyy/MM/dd'); It should + * follow the rules of NSDateFormatter for iOS and DateTimeFormatter for Android. + * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString + * @example + * await element(by.id('datePicker')).setDatePickerDate('2023-01-01T00:00:00Z', 'ISO8601'); + * await element(by.id('datePicker')).setDatePickerDate(new Date().toISOString(), 'ISO8601'); + * await element(by.id('datePicker')).setDatePickerDate('2023/01/01', 'yyyy/MM/dd'); + */ + setDatePickerDate(dateString: string, dateFormat: string): Promise; + + /** + * Triggers a given [accessibility action]{@link https://reactnative.dev/docs/accessibility#accessibility-actions}. + * @param actionName - name of the accessibility action + * @example await element(by.id('view')).performAccessibilityAction('activate'); + */ + performAccessibilityAction(actionName: string): Promise + + /** + * Pinches in the given direction with speed and angle. (iOS only) + * @param angle value in radiant, default is `0` + * @example + * await expect(element(by.id('PinchableScrollView'))).toBeVisible(); + * await element(by.id('PinchableScrollView')).pinchWithAngle('outward', 'slow', 0); + * @deprecated Use `.pinch()` instead. + */ + pinchWithAngle(direction: PinchDirection, speed: Speed, angle: number): Promise; + + /** + * Pinches with the given scale, speed, and angle. (iOS only) + * @param speed default is `fast` + * @param angle value in radiant, default is `0` + * @example + * await element(by.id('PinchableScrollView')).pinch(1.1); + * await element(by.id('PinchableScrollView')).pinch(2.0); + * await element(by.id('PinchableScrollView')).pinch(0.001); + */ + pinch(scale: number, speed?: Speed, angle?: number): Promise; + + /** + * Takes a screenshot of the element and schedules putting it in the artifacts folder upon completion of the current test. + * For more information, see {@link https://wix.github.io/Detox/docs/api/screenshots#element-level-screenshots} + * @param {string} name for the screenshot artifact + * @returns {Promise} a temporary path to the screenshot. + * @example + * test('Menu items should have logout', async () => { + * const imagePath = await element(by.id('menuRoot')).takeScreenshot('tap on menu'); + * // The temporary path will remain valid until the test completion. + * // Afterwards, the screenshot will be moved, e.g.: + * // * on success, to: /โœ“ Menu items should have Logout/tap on menu.png + * // * on failure, to: /โœ— Menu items should have Logout/tap on menu.png + * }); + */ + takeScreenshot(name: string): Promise; + + /** + * Retrieves the OS-dependent attributes of an element. + * If there are multiple matches, it returns an array of attributes for all matched elements. + * For detailed information, refer to {@link https://wix.github.io/Detox/docs/api/actions-on-element/#getattributes} + * + * @example + * test('Get the attributes for my text element', async () => { + * const attributes = await element(by.id('myText')).getAttributes() + * const jestExpect = require('expect'); + * // 'visible' attribute available on both iOS and Android + * jestExpect(attributes.visible).toBe(true); + * // 'activationPoint' attribute available on iOS only + * jestExpect(attributes.activationPoint.x).toHaveValue(50); + * // 'width' attribute available on Android only + * jestExpect(attributes.width).toHaveValue(100); + * }) + */ + getAttributes(): Promise; + } + + interface WebExpect> { + /** + * Negate the expectation. + * @example await expect(web.element(by.web.id('sessionTimeout'))).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(web.element(by.web.id('checkoutButton'))).toHaveText('Proceed to check out'); + */ + toHaveText(text: string): R; + + /** + * Expect the view to exist in the webview DOM tree. + * @example await expect(web.element(by.web.id('submitButton'))).toExist(); + */ + toExist(): R; + } + + interface IndexableWebElement extends WebElement { + /** + * Choose from multiple elements matching the same matcher using index + * @example await web.element(by.web.hrefContains('Details')).atIndex(2).tap(); + */ + atIndex(index: number): WebElement; + } + + interface WebElement extends WebElementActions { + } + + interface WebElementActions { + tap(): Promise; + + /** + * @param text to type + * @param isContentEditable whether its a ContentEditable element, default is false. + */ + typeText(text: string, isContentEditable: boolean): Promise; + + /** + * At the moment not working on content-editable + * @param text to replace with the old content. + */ + replaceText(text: string): Promise; + + /** + * At the moment not working on content-editable + */ + clearText(): Promise; + + /** + * scrolling to the view, the element top position will be at the top of the screen. + */ + scrollToView(): Promise; + + /** + * Gets the input content + */ + getText(): Promise; + + /** + * Calls the focus function on the element + */ + focus(): Promise; + + /** + * Selects all the input content, works on ContentEditable at the moment. + */ + selectAllText(): Promise; + + /** + * Moves the input cursor / caret to the end of the content, works on ContentEditable at the moment. + */ + moveCursorToEnd(): Promise; + + /** + * Running a JavaScript function on the element. + * The first argument to the function will be the element itself. + * The rest of the arguments will be forwarded to the JavaScript function as is. + * + * @param script a callback function in stringified form, or a plain function reference + * without closures, bindings etc. that will be converted to a string. + * @param args optional args to pass to the script + * + * @example + * await webElement.runScript('(el) => el.click()'); + * await webElement.runScript(function setText(element, text) { + * element.textContent = text; + * }, ['Custom Title']); + */ + runScript(script: string, args?: unknown[]): Promise; + runScript(script: (...args: any[]) => F, args?: unknown[]): Promise; + + /** + * Gets the current page url + */ + getCurrentUrl(): Promise; + + /** + * Gets the current page title + */ + getTitle(): Promise; + } + + type Direction = 'left' | 'right' | 'top' | 'bottom' | 'up' | 'down'; + + type PinchDirection = 'outward' | 'inward' + + type Orientation = 'portrait' | 'landscape'; + + type Speed = 'fast' | 'slow'; + + interface LanguageAndLocale { + language?: string; + locale?: string; + } + + /** + * Source for string definitions is https://github.com/wix/AppleSimulatorUtils + */ + interface DevicePermissions { + location?: LocationPermission; + notifications?: NotificationsPermission; + calendar?: CalendarPermission; + camera?: CameraPermission; + contacts?: ContactsPermission; + health?: HealthPermission; + homekit?: HomekitPermission; + medialibrary?: MediaLibraryPermission; + microphone?: MicrophonePermission; + motion?: MotionPermission; + photos?: PhotosPermission; + reminders?: RemindersPermission; + siri?: SiriPermission; + speech?: SpeechPermission; + faceid?: FaceIDPermission; + userTracking?: UserTrackingPermission; + } + + type BasicPermissionState = 'YES' | 'NO' | 'unset'; + type ExtendedPermissionState = 'YES' | 'NO' | 'unset' | 'limited'; + type LocationPermission = 'always' | 'inuse' | 'never' | 'unset'; + + type CameraPermission = BasicPermissionState; + type ContactsPermission = ExtendedPermissionState; + type CalendarPermission = BasicPermissionState; + type HealthPermission = BasicPermissionState; + type HomekitPermission = BasicPermissionState; + type MediaLibraryPermission = BasicPermissionState; + type MicrophonePermission = BasicPermissionState; + type MotionPermission = BasicPermissionState; + type PhotosPermission = ExtendedPermissionState; + type RemindersPermission = BasicPermissionState; + type SiriPermission = BasicPermissionState; + type SpeechPermission = BasicPermissionState; + type NotificationsPermission = BasicPermissionState; + type FaceIDPermission = BasicPermissionState; + type UserTrackingPermission = BasicPermissionState; + + interface DeviceLaunchAppConfig { + /** + * Restart the app + * Terminate the app and launch it again. If set to false, the simulator will try to bring app from background, if the app isn't running, it will launch a new instance. default is false + */ + newInstance?: boolean; + /** + * Set runtime permissions + * Grant or deny runtime permissions for your application. + */ + permissions?: DevicePermissions; + /** + * Launch from URL + * Mock opening the app from URL to test your app's deep link handling mechanism. + */ + url?: any; + /** + * Launch with user notifications + */ + userNotification?: any; + /** + * Launch with user activity + */ + userActivity?: any; + /** + * Launch into a fresh installation + * A flag that enables relaunching into a fresh installation of the app (it will uninstall and install the binary again), default is false. + */ + delete?: boolean; + /** + * Arguments to pass-through into the app. + * Refer to the [dedicated guide](https://wix.github.io/Detox/docs/api/launch-args) for complete details. + */ + launchArgs?: Record; + /** + * Launch config for specifying the native language and locale + */ + languageAndLocale?: LanguageAndLocale; + } + + // Element Attributes Shared Among iOS and Android + interface ElementAttributes { + /** + * Whether or not the element is enabled for user interaction. + */ + enabled: boolean; + /** + * The identifier of the element. Matches accessibilityIdentifier on iOS, and the main view tag, on Android - both commonly holding the component's test ID in React Native apps. + */ + identifier: string; + /** + * Whether the element is visible. On iOS, visibility is calculated for the activation point. On Android, the attribute directly holds the value returned by View.getLocalVisibleRect()). + */ + visible: boolean; + /** + * The text value of any textual element. + */ + text?: string; + /** + * The label of the element. Largely matches accessibilityLabel for ios, and contentDescription for android. + * Refer to Detox's documentation (`toHaveLabel()` subsection) in order to learn about caveats associated with + * this property in React Native apps. + */ + label?: string; + /** + * The placeholder text value of the element. Matches hint on android. + */ + placeholder?: string; + /** + * The value of the element, where applicable. + * Matches accessibilityValue, on iOS. + * For example: the position of a slider, or whether a checkbox has been marked (Android). + */ + value?: unknown; + } + + interface IosElementAttributeFrame { + y: number; + x: number; + width: number; + height: number; + } + + interface IosElementAttributeInsets { + right: number; + top: number; + left: number; + bottom: number; + } + + // iOS Specific Attributes + interface IosElementAttributes extends ElementAttributes { + /** + * The [activation point]{@link https://developer.apple.com/documentation/objectivec/nsobject/1615179-accessibilityactivationpoint} of the element, in element coordinate space. + */ + activationPoint: Point2D; + /** + * The activation point of the element, in normalized percentage ([0.0, 1.0]). + */ + normalizedActivationPoint: Point2D; + /** + * Whether the element is hittable at the activation point. + */ + hittable: boolean; + /** + * The frame of the element, in screen coordinate space. + */ + frame: IosElementAttributeFrame; + /** + * The frame of the element, in container coordinate space. + */ + elementFrame: IosElementAttributeFrame; + /** + * The bounds of the element, in element coordinate space. + */ + elementBounds: IosElementAttributeFrame; + /** + * The safe area insets of the element, in element coordinate space. + */ + safeAreaInsets: IosElementAttributeInsets; + /** + * The safe area bounds of the element, in element coordinate space. + */ + elementSafeBounds: IosElementAttributeFrame; + /** + * The date of the element (if it is a date picker). + */ + date?: string; + /** + * The normalized slider position (if it is a slider). + */ + normalizedSliderPosition?: number; + /** + * The content offset (if it is a scroll view). + */ + contentOffset?: Point2D; + /** + * The content inset (if it is a scroll view). + */ + contentInset?: IosElementAttributeInsets; + /** + * The adjusted content inset (if it is a scroll view). + */ + adjustedContentInset?: IosElementAttributeInsets; + /** + * @example "" + */ + layer: string; + } + + // Android Specific Attributes + interface AndroidElementAttributes extends ElementAttributes { + /** + * The OS visibility type associated with the element: visible, invisible or gone. + */ + visibility: 'visible' | 'invisible' | 'gone'; + /** + * Width of the element, in pixels. + */ + width: number; + /** + * Height of the element, in pixels. + */ + height: number; + /** + * Elevation of the element. + */ + elevation: number; + /** + * Alpha value for the element. + */ + alpha: number; + /** + * Whether the element is the one currently in focus. + */ + focused: boolean; + /** + * The text size for the text element. + */ + textSize?: number; + /** + * The length of the text element (character count). + */ + length?: number; + } + } +} + +export = Detox; diff --git a/detox/globals.d.ts b/detox/globals.d.ts new file mode 100644 index 0000000000..f58f993fbb --- /dev/null +++ b/detox/globals.d.ts @@ -0,0 +1,23 @@ +import Detox = require('./detox'); + +declare global { + const detox: Detox.DetoxExportWrapper; + const device: Detox.DetoxExportWrapper['device']; + const element: Detox.DetoxExportWrapper['element']; + const waitFor: Detox.DetoxExportWrapper['waitFor']; + const expect: Detox.DetoxExportWrapper['expect']; + const by: Detox.DetoxExportWrapper['by']; + const web: Detox.DetoxExportWrapper['web']; + + namespace NodeJS { + interface Global { + detox: Detox.DetoxExportWrapper; + device: Detox.DetoxExportWrapper['device']; + element: Detox.DetoxExportWrapper['element']; + waitFor: Detox.DetoxExportWrapper['waitFor']; + expect: Detox.DetoxExportWrapper['expect']; + by: Detox.DetoxExportWrapper['by']; + web: Detox.DetoxExportWrapper['web']; + } + } +} diff --git a/detox/index.d.ts b/detox/index.d.ts index 68b5705899..9137e82552 100644 --- a/detox/index.d.ts +++ b/detox/index.d.ts @@ -1,1844 +1,5 @@ -// TypeScript definitions for Detox -// Original authors (from DefinitelyTyped): -// * Jane Smith -// * Tareq El-Masri -// * Steve Chun -// * Hammad Jutt -// * pera -// * Max Komarychev -// * Dor Ben Baruch - -import { BunyanDebugStreamOptions } from 'bunyan-debug-stream'; - -declare global { - const detox: Detox.DetoxExportWrapper; - const device: Detox.DetoxExportWrapper['device']; - const element: Detox.DetoxExportWrapper['element']; - const waitFor: Detox.DetoxExportWrapper['waitFor']; - const expect: Detox.DetoxExportWrapper['expect']; - const by: Detox.DetoxExportWrapper['by']; - const web: Detox.DetoxExportWrapper['web']; - - namespace NodeJS { - interface Global { - detox: Detox.DetoxExportWrapper; - device: Detox.DetoxExportWrapper['device']; - element: Detox.DetoxExportWrapper['element']; - waitFor: Detox.DetoxExportWrapper['waitFor']; - expect: Detox.DetoxExportWrapper['expect']; - by: Detox.DetoxExportWrapper['by']; - web: Detox.DetoxExportWrapper['web']; - } - } - - namespace Detox { - //#region DetoxConfig - - interface DetoxConfig extends DetoxConfigurationCommon { - /** - * @example extends: './relative/detox.config' - * @example extends: '@my-org/detox-preset' - */ - extends?: string; - - apps?: Record; - devices?: Record; - selectedConfiguration?: string; - configurations: Record; - } - - type DetoxConfigurationCommon = { - artifacts?: false | DetoxArtifactsConfig; - behavior?: DetoxBehaviorConfig; - logger?: DetoxLoggerConfig; - session?: DetoxSessionConfig; - testRunner?: DetoxTestRunnerConfig; - }; - - interface DetoxArtifactsConfig { - rootDir?: string; - pathBuilder?: string; - plugins?: { - log?: 'none' | 'failing' | 'all' | DetoxLogArtifactsPluginConfig; - screenshot?: 'none' | 'manual' | 'failing' | 'all' | DetoxScreenshotArtifactsPluginConfig; - video?: 'none' | 'failing' | 'all' | DetoxVideoArtifactsPluginConfig; - instruments?: 'none' | 'all' | DetoxInstrumentsArtifactsPluginConfig; - uiHierarchy?: 'disabled' | 'enabled' | DetoxUIHierarchyArtifactsPluginConfig; - - [pluginId: string]: unknown; - }; - } - - interface DetoxBehaviorConfig { - init?: { - /** - * By default, Detox exports `device`, `expect`, `element`, `by` and `waitFor` - * as global variables. If you want to control their initialization manually, - * set this property to `false`. - * - * This is useful when during E2E tests you also need to run regular expectations - * in Node.js. Jest's `expect` for instance, will not be overridden by Detox when - * this option is used. - */ - exposeGlobals?: boolean; - /** - * By default, Detox will uninstall and install the app upon initialization. - * If you wish to reuse the existing app for a faster run, set the property to - * `false`. - */ - reinstallApp?: boolean; - /** - * When false, `detox test` command always deletes the shared lock file on start, - * assuming it had been left from the previous, already finished test session. - * The lock file contains information about busy and free devices and ensures - * no device can be used simultaneously by multiple test workers. - * - * Setting it to **true** might be useful when if you need to run multiple - * `detox test` commands in parallel, e.g. test a few configurations at once. - * - * @default false - */ - keepLockFile?: boolean; - }; - launchApp?: 'auto' | 'manual'; - cleanup?: { - shutdownDevice?: boolean; - }; - } - - type _DetoxLoggerOptions = Omit; - - interface DetoxLoggerConfig { - /** - * Log level filters the messages printed to your terminal, - * and it does not affect the logs written to the artifacts. - * - * Use `info` by default. - * Use `error` or warn when you want to make the output as silent as possible. - * Use `debug` to control what generally is happening under the hood. - * Use `trace` when troubleshooting specific issues. - * - * @default 'info' - */ - level?: DetoxLogLevel; - /** - * When enabled, hijacks all the console methods (console.log, console.warn, etc) - * so that the messages printed via them are formatted and saved as Detox logs. - * - * @default true - */ - overrideConsole?: boolean; - /** - * Since Detox is using - * {@link https://www.npmjs.com/package/bunyan-debug-stream bunyan-debug-stream} - * for printing logs, all its options are exposed for sake of simplicity - * of customization. - * - * The only exception is {@link BunyanDebugStreamOptions#out} option, - * which is always set to `process.stdout`. - * - * You can also pass a callback function to override the logger config - * programmatically, e.g. depending on the selected log level. - * - * @see {@link BunyanDebugStreamOptions} - */ - options?: _DetoxLoggerOptions | ((config: Partial) => _DetoxLoggerOptions); - } - - interface DetoxSessionConfig { - autoStart?: boolean; - debugSynchronization?: number; - server?: string; - sessionId?: string; - } - - interface DetoxTestRunnerConfig { - args?: { - /** - * The command to use for runner: 'jest', 'nyc jest', - */ - $0: string; - /** - * The positional arguments to pass to the runner. - */ - _?: string[]; - /** - * Any other properties recognized by test runner - */ - [prop: string]: unknown; - }; - - /** - * This is an add-on section used by our Jest integration code (but not Detox core itself). - * In other words, if youโ€™re implementing (or using) a custom integration with some other test runner, feel free to define a section for yourself (e.g. `testRunner.mocha`) - */ - jest?: { - /** - * Environment setup timeout - * - * As a part of the environment setup, Detox boots the device and installs the apps. - * If that takes longer than the specified value, the entire test suite will be considered as failed, e.g.: - * ```plain text - * FAIL e2e/starter.test.js - * โ— Test suite failed to run - * - * Exceeded timeout of 300000ms while setting up Detox environment - * ``` - * - * The default value is 5 minutes. - * - * @default 300000 - * @see {@link https://jestjs.io/docs/configuration/#testenvironment-string} - */ - setupTimeout?: number | undefined; - /** - * Environemnt teardown timeout - * - * If the environment teardown takes longer than the specified value, Detox will throw a timeout error. - * The default value is half a minute. - * - * @default 30000 (30 seconds) - * @see {@link https://jestjs.io/docs/configuration/#testenvironment-string} - */ - teardownTimeout?: number | undefined; - /** - * Jest provides an API to re-run individual failed tests: `jest.retryTimes(count)`. - * When Detox detects the use of this API, it suppresses its own CLI retry mechanism controlled via `detox test โ€ฆ --retries ` or {@link DetoxTestRunnerConfig#retries}. - * The motivation is simple โ€“ activating the both mechanisms is apt to increase your test duration dramatically, if your tests are flaky. - * If you wish nevertheless to use both the mechanisms simultaneously, set it to `true`. - * - * @default false - * @see {@link https://jestjs.io/docs/29.0/jest-object#jestretrytimesnumretries-options} - */ - retryAfterCircusRetries?: boolean; - /** - * By default, Jest prints the test names and their status (_passed_ or _failed_) at the very end of the test session. - * When enabled, it makes Detox to print messages like these each time the new test starts and ends: - * ```plain text - * 18:03:36.258 detox[40125] i Sanity: should have welcome screen - * 18:03:37.495 detox[40125] i Sanity: should have welcome screen [OK] - * 18:03:37.496 detox[40125] i Sanity: should show hello screen after tap - * 18:03:38.928 detox[40125] i Sanity: should show hello screen after tap [OK] - * 18:03:38.929 detox[40125] i Sanity: should show world screen after tap - * 18:03:40.351 detox[40125] i Sanity: should show world screen after tap [OK] - * ``` - * By default, it is enabled automatically in test sessions with a single worker. - * And vice versa, if multiple tests are executed concurrently, Detox turns it off to avoid confusion in the log. - * Use boolean values, `true` or `false`, to turn off the automatic choice. - * - * @default undefined - */ - reportSpecs?: boolean | undefined; - /** - * In the environment setup phase, Detox boots the device and installs the apps. - * This flag tells Detox to print messages like these every time the device gets assigned to a specific suite: - * - * ```plain text - * 18:03:29.869 detox[40125] i starter.test.js is assigned to 4EC84833-C7EA-4CA3-A6E9-5C30A29EA596 (iPhone 12 Pro Max) - * ``` - * - * @default true - */ - reportWorkerAssign?: boolean | undefined; - }; - /** - * Retries count. Zero means a single attempt to run tests. - */ - retries?: number; - /** - * When true, tells Detox CLI to cancel next retrying if it gets - * at least one report about a permanent test suite failure. - * Has no effect, if {@link DetoxTestRunnerConfig#retries} is - * undefined or set to zero. - * - * @default false - * @see {DetoxInternals.DetoxTestFileReport#isPermanentFailure} - */ - bail?: boolean; - /** - * Custom handler to process --inspect-brk CLI flag. - * Use it when you rely on another test runner than Jest to mutate the config. - */ - inspectBrk?: (config: DetoxTestRunnerConfig) => void; - /** - * Forward environment variables to the spawned test runner - * accordingly to the given CLI argument overrides. - * - * If false, Detox CLI will be only printing a hint message on - * how to start the test runner using environment variables, - * in case when a user wants to avoid using Detox CLI. - * - * @default false - */ - forwardEnv?: boolean; - } - - type DetoxAppConfig = (DetoxBuiltInAppConfig | DetoxCustomAppConfig) & { - /** - * App name to use with device.selectApp(appName) calls. - * Can be omitted if you have a single app under the test. - * - * @see Device#selectApp - */ - name?: string; - }; - - type DetoxDeviceConfig = DetoxBuiltInDeviceConfig | DetoxCustomDriverConfig; - - interface DetoxLogArtifactsPluginConfig { - enabled?: boolean; - keepOnlyFailedTestsArtifacts?: boolean; - } - - interface DetoxScreenshotArtifactsPluginConfig { - enabled?: boolean; - keepOnlyFailedTestsArtifacts?: boolean; - shouldTakeAutomaticSnapshots?: boolean; - takeWhen?: { - testStart?: boolean; - testFailure?: boolean; - testDone?: boolean; - appNotReady?: boolean; - }; - } - - interface DetoxVideoArtifactsPluginConfig { - enabled?: boolean; - keepOnlyFailedTestsArtifacts?: boolean; - android?: Partial<{ - size: [number, number]; - bitRate: number; - timeLimit: number; - verbose: boolean; - }>; - simulator?: Partial<{ - codec: string; - }>; - } - - interface DetoxInstrumentsArtifactsPluginConfig { - enabled?: boolean; - } - - interface DetoxUIHierarchyArtifactsPluginConfig { - enabled?: boolean; - } - - type DetoxBuiltInAppConfig = (DetoxIosAppConfig | DetoxAndroidAppConfig); - - interface DetoxIosAppConfig { - type: 'ios.app'; - binaryPath: string; - bundleId?: string; - build?: string; - start?: string; - launchArgs?: Record; - } - - interface DetoxAndroidAppConfig { - type: 'android.apk'; - binaryPath: string; - bundleId?: string; - build?: string; - start?: string; - testBinaryPath?: string; - launchArgs?: Record; - /** - * TCP ports to `adb reverse` upon the installation. - * E.g. 8081 - to be able to access React Native packager in Debug mode. - * - * @example [8081] - */ - reversePorts?: number[]; - } - - interface DetoxCustomAppConfig { - type: string; - - [prop: string]: unknown; - } - - type DetoxBuiltInDeviceConfig = - | DetoxIosSimulatorDriverConfig - | DetoxAttachedAndroidDriverConfig - | DetoxAndroidEmulatorDriverConfig - | DetoxGenymotionCloudDriverConfig; - - interface DetoxIosSimulatorDriverConfig { - type: 'ios.simulator'; - device: string | Partial; - bootArgs?: string; - } - - interface DetoxSharedAndroidDriverConfig { - forceAdbInstall?: boolean; - utilBinaryPaths?: string[]; - } - - interface DetoxAttachedAndroidDriverConfig extends DetoxSharedAndroidDriverConfig { - type: 'android.attached'; - device: string | { adbName: string }; - } - - interface DetoxAndroidEmulatorDriverConfig extends DetoxSharedAndroidDriverConfig { - type: 'android.emulator'; - device: string | { avdName: string }; - bootArgs?: string; - gpuMode?: 'auto' | 'host' | 'swiftshader_indirect' | 'angle_indirect' | 'guest' | 'off'; - headless?: boolean; - /** - * @default true - */ - readonly?: boolean; - } - - interface DetoxGenymotionCloudDriverConfig extends DetoxSharedAndroidDriverConfig { - type: 'android.genycloud'; - device: string | { recipeUUID: string; } | { recipeName: string; }; - } - - interface DetoxCustomDriverConfig { - type: string; - - [prop: string]: unknown; - } - - interface IosSimulatorQuery { - id: string; - type: string; - name: string; - os: string; - } - - type DetoxConfiguration = DetoxConfigurationCommon & ( - | DetoxConfigurationSingleApp - | DetoxConfigurationMultiApps - ); - - interface DetoxConfigurationSingleApp { - device: DetoxAliasedDevice; - app: DetoxAliasedApp; - } - - interface DetoxConfigurationMultiApps { - device: DetoxAliasedDevice; - apps: DetoxAliasedApp[]; - } - - type DetoxAliasedDevice = string | DetoxDeviceConfig; - - type DetoxAliasedApp = string | DetoxAppConfig; - - //#endregion - - interface DetoxExportWrapper { - readonly device: Device; - - readonly element: ElementFacade; - - readonly waitFor: WaitForFacade; - - readonly expect: ExpectFacade; - - readonly by: ByFacade; - - readonly web: WebFacade; - - readonly DetoxConstants: { - userNotificationTriggers: { - push: 'push'; - calendar: 'calendar'; - timeInterval: 'timeInterval'; - location: 'location'; - }; - userActivityTypes: { - searchableItem: string; - browsingWeb: string; - }, - searchableItemActivityIdentifier: string; - }; - - /** - * Detox logger instance. Can be used for saving user logs to the general log file. - */ - readonly log: Logger; - - /** - * @deprecated - * - * Deprecated - use {@link Detox.Logger#trace} - * Detox tracer instance. Can be used for building timelines in Google Event Tracing format. - */ - readonly trace: { - /** @deprecated */ - readonly startSection: (name: string) => void; - /** @deprecated */ - readonly endSection: (name: string) => void; - }; - - /** - * Trace a single call, with a given name and arguments. - * - * @deprecated - * @param sectionName The name of the section to trace. - * @param promiseOrFunction Promise or a function that provides a promise. - * @param args Optional arguments to pass to the trace. - * @returns The returned value of the traced call. - * @see https://wix.github.io/Detox/docs/19.x/api/detox-object-api/#detoxtracecall - */ - readonly traceCall: (event: string, action: () => Promise, args?: Record) => Promise; - } - - interface Logger { - readonly level: DetoxLogLevel; - - readonly fatal: _LogMethod; - readonly error: _LogMethod; - readonly warn: _LogMethod; - readonly info: _LogMethod; - readonly debug: _LogMethod; - readonly trace: _LogMethod; - - child(context?: Partial): Logger; - } - - /** @internal */ - interface _LogMethod extends _LogMethodSignature { - readonly begin: _LogMethodSignature; - readonly complete: _CompleteMethodSignature; - readonly end: _LogMethodSignature; - } - - /** @internal */ - interface _LogMethodSignature { - (...args: unknown[]): void - (event: LogEvent, ...args: unknown[]): void; - } - - /** @internal */ - interface _CompleteMethodSignature { - (message: string, action: T | (() => T)): T; - (event: LogEvent, message: string, action: T | (() => T)): T; - } - - type LogEvent = { - /** Use when there's a risk of logging several parallel duration events. */ - id?: string | number; - /** Optional. Event categories (tags) to facilitate filtering. */ - cat?: string | string[]; - /** Optional. Color name (applicable in Google Chrome Trace Format) */ - cname?: string; - - /** Reserved property. Process ID. */ - pid?: never; - /** Reserved property. Thread ID. */ - tid?: never; - /** Reserved property. Timestamp. */ - ts?: never; - /** Reserved property. Event phase. */ - ph?: never; - - [customProperty: string]: unknown; - }; - - type DetoxLogLevel = 'fatal' | 'error' | 'warn' | 'info' | 'debug' | 'trace'; - - type Point2D = { - x: number, - y: number, - } - - /** - * A construct allowing for the querying and modification of user arguments passed to an app upon launch by Detox. - * - * @see AppLaunchArgs#modify - * @see AppLaunchArgs#reset - * @see AppLaunchArgs#get - */ - interface AppLaunchArgs { - /** - * Shared (global) arguments that are not specific to a particular application. - * Selecting another app does not reset them, yet they still can be overridden - * by configuring app-specific launch args. - * @see Device#selectApp - * @see AppLaunchArgs - */ - readonly shared: ScopedAppLaunchArgs; - - /** - * Modify the launch-arguments via a modifier object, according to the following logic: - * - Non-nullish modifier properties would set a new value or override the previous value of - * existing properties with the same name. - * - Modifier properties set to either `undefined` or `null` would delete the corresponding property - * if it existed. - * These custom app launch arguments get erased whenever you select a different application. - * If you need to share them between all the applications, use {@link AppLaunchArgs#shared} property. - * Note: app-specific launch args have a priority over shared ones. - * - * @param modifier The modifier object. - * @example - * // With current launch arguments set to: - * // { - * // mockServerPort: 1234, - * // mockServerCredentials: 'user@test.com:12345678', - * // } - * device.appLaunchArgs.modify({ - * mockServerPort: 4321, - * mockServerCredentials: null, - * mockServerToken: 'abcdef', - * }); - * await device.launchApp(); - * // ==> launch-arguments become: - * // { - * // mockServerPort: 4321, - * // mockServerToken: 'abcdef', - * // } - */ - modify(modifier: object): this; - - /** - * Reset all app-specific launch arguments (back to an empty object). - * If you need to reset the shared launch args, use {@link AppLaunchArgs#shared}. - */ - reset(): this; - - /** - * Get all currently set launch arguments (including shared ones). - * @returns An object containing all launch-arguments. - * Note: mutating the values inside the result object is pointless, as it is immutable. - */ - get(): object; - } - - /** - * Shared (global) arguments that are not specific to a particular application. - */ - interface ScopedAppLaunchArgs { - /** @see AppLaunchArgs#modify */ - modify(modifier: object): this; - - /** @see AppLaunchArgs#reset */ - reset(): this; - - /** @see AppLaunchArgs#get */ - get(): object; - } - - type DigitWithoutZero = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9; - type Digit = 0 | DigitWithoutZero; - type BatteryLevel = `${Digit}` | `${DigitWithoutZero}${Digit}` | "100"; - - interface Device { - /** - * Holds the environment-unique ID of the device, namely, the adb ID on Android (e.g. emulator-5554) and the Mac-global simulator UDID on iOS - - * as used by simctl (e.g. AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE). - */ - id: string; - /** - * Holds a descriptive name of the device. Example: emulator-5554 (Pixel_API_29) - */ - name: string; - - /** - * Select the current app (relevant only to multi-app configs) by its name. - * After execution, all app-specific device methods will target the selected app. - * - * @see DetoxAppConfig#name - * @example - * await device.selectApp('passenger'); - * await device.launchApp(); // passenger - * // ... run tests for the passenger app - * await device.uninstallApp(); // passenger - * await device.selectApp('driver'); - * await device.installApp(); // driver - * await device.launchApp(); // driver - * // ... run tests for the driver app - * await device.terminateApp(); // driver - */ - selectApp(app: string): Promise; - - /** - * Launch the app. - * - *

For info regarding launch arguments, refer to the [dedicated guide](https://wix.github.io/Detox/docs/api/launch-args). - * - * @example - * // Terminate the app and launch it again. If set to false, the simulator will try to bring app from background, - * // if the app isn't running, it will launch a new instance. default is false - * await device.launchApp({newInstance: true}); - * @example - * // Grant or deny runtime permissions for your application. - * await device.launchApp({permissions: {calendar: 'YES'}}); - * @example - * // Mock opening the app from URL to test your app's deep link handling mechanism. - * await device.launchApp({url: url}); - * @example - * // Start the app with some custom arguments. - * await device.launchApp({ - * launchArgs: {arg1: 1, arg2: "2"}, - * }); - */ - launchApp(config?: DeviceLaunchAppConfig): Promise; - - /** - * Relaunch the app. Convenience method that calls {@link Device#launchApp} - * with { newInstance: true } override. - * - * @deprecated - * @param config - * @see Device#launchApp - */ - relaunchApp(config?: DeviceLaunchAppConfig): Promise; - - /** - * Access the user-defined launch-arguments predefined through static scopes such as the Detox configuration file and - * command-line arguments. This access allows - through dedicated methods, for both value-querying and - * modification (see {@link AppLaunchArgs}). - * Refer to the [dedicated guide](https://wix.github.io/Detox/docs/api/launch-args) for complete details. - * - * @example - * // With Detox being preconfigured statically to use these arguments in app launch: - * // { - * // mockServerPort: 1234, - * // } - * // The following code would result in these arguments eventually passed into the launched app: - * // { - * // mockServerPort: 4321, - * // mockServerToken: 'uvwxyz', - * // } - * device.appLaunchArgs.modify({ - * mockServerPort: 4321, - * mockServerToken: 'abcdef', - * }); - * await device.launchApp({ launchArgs: { mockServerToken: 'uvwxyz' } }}; - * - * @see AppLaunchArgs - */ - appLaunchArgs: AppLaunchArgs; - - /** - * Terminate the app. - * - * @example - * // By default, terminateApp() with no params will terminate the app - * await device.terminateApp(); - * @example - * // To terminate another app, specify its bundle id - * await device.terminateApp('other.bundle.id'); - */ - terminateApp(bundle?: string): Promise; - - /** - * Send application to background by bringing com.apple.springboard to the foreground. - * Combining sendToHome() with launchApp({newInstance: false}) will simulate app coming back from background. - * @example - * await device.sendToHome(); - * await device.launchApp({newInstance: false}); - */ - sendToHome(): Promise; - - /** - * If this is a React Native app, reload the React Native JS bundle. This action is much faster than device.launchApp(), and can be used if you just need to reset your React Native logic. - * - * @example await device.reloadReactNative() - */ - reloadReactNative(): Promise; - - /** - * By default, installApp() with no params will install the app file defined in the current configuration. - * To install another app, specify its path - * @example await device.installApp(); - * @example await device.installApp('path/to/other/app'); - */ - installApp(path?: any): Promise; - - /** - * By default, uninstallApp() with no params will uninstall the app defined in the current configuration. - * To uninstall another app, specify its bundle id - * @example await device.installApp('other.bundle.id'); - */ - uninstallApp(bundle?: string): Promise; - - /** - * Mock opening the app from URL. sourceApp is an optional parameter to specify source application bundle id. - */ - openURL(url: { url: string; sourceApp?: string }): Promise; - - /** - * Mock handling of received user notification when app is in foreground. - */ - sendUserNotification(...params: any[]): Promise; - - /** - * Mock handling of received user activity when app is in foreground. - */ - sendUserActivity(...params: any[]): Promise; - - /** - * Takes "portrait" or "landscape" and rotates the device to the given orientation. Currently only available in the iOS Simulator. - */ - setOrientation(orientation: Orientation): Promise; - - /** - * Sets the simulator/emulator location to the given latitude and longitude. - * - *

On iOS `setLocation` is dependent on [fbsimctl](https://github.com/facebook/idb/tree/4b7929480c3c0f158f33f78a5b802c1d0e7030d2/fbsimctl) - * which [is now deprecated](https://github.com/wix/Detox/issues/1371). - * If `fbsimctl` is not installed, the command will fail, asking for it to be installed. - * - *

On Android `setLocation` will work with both Android Emulator (bundled with Android development tools) and Genymotion. - * The correct permissions must be set in your app manifest. - * - * @example await device.setLocation(32.0853, 34.7818); - */ - setLocation(lat: number, lon: number): Promise; - - /** - * (iOS only) Override simulatorโ€™s status bar. - * @platform iOS - * @param {config} config status bar configuration. - * @example - * await device.setStatusBar({ - * time: "12:34", - * // Set the date or time to a fixed value. - * // If the string is a valid ISO date string it will also set the date on relevant devices. - * dataNetwork: "wifi", - * // If specified must be one of 'hide', 'wifi', '3g', '4g', 'lte', 'lte-a', 'lte+', '5g', '5g+', '5g-uwb', or '5g-uc'. - * wifiMode: "failed", - * // If specified must be one of 'searching', 'failed', or 'active'. - * wifiBars: "2", - * // If specified must be 0-3. - * cellularMode: "searching", - * // If specified must be one of 'notSupported', 'searching', 'failed', or 'active'. - * cellularBars: "3", - * // If specified must be 0-4. - * operatorName: "A1", - * // Set the cellular operator/carrier name. Use '' for the empty string. - * batteryState: "charging", - * // If specified must be one of 'charging', 'charged', or 'discharging'. - * batteryLevel: "50", - * // If specified must be 0-100. - * }); - */ - setStatusBar(config: { - time?: string, - dataNetwork?: "hide" | "wifi" | "3g" | "4g" | "lte" | "lte-a" | "lte+" | "5g" | "5g+" | "5g-uwb" | "5g-uc", - wifiMode?: "searching" |"failed" | "active", - wifiBars?: "0" | "1" | "2" | "3", - cellularMode?: "notSupported" | "searching" | "failed" | "active", - cellularBars?: "0" | "1" | "2" | "3" | "4", - operatorName?: string; - batteryState?: "charging" | "charged" | "discharging", - batteryLevel?: BatteryLevel, - }): Promise; - - /** - * Disable network synchronization mechanism on preferred endpoints. Useful if you want to on skip over synchronizing on certain URLs. - * - * @example await device.setURLBlacklist(['.*127.0.0.1.*']); - */ - setURLBlacklist(urls: string[]): Promise; - - /** - * Temporarily disable synchronization (idle/busy monitoring) with the app - namely, stop waiting for the app to go idle before moving forward in the test execution. - * - *

This API is useful for cases where test assertions must be made in an area of your application where it is okay for it to ever remain partly *busy* (e.g. due to an - * endlessly repeating on-screen animation). However, using it inherently suggests that you are likely to resort to applying `sleep()`'s in your test code - testing - * that area, **which is not recommended and can never be 100% stable. - * **Therefore, as a rule of thumb, test code running "inside" a sync-disabled mode must be reduced to the bare minimum. - * - *

Note: Synchronization is enabled by default, and it gets **reenabled on every launch of a new instance of the app.** - * - * @example await device.disableSynchronization(); - */ - disableSynchronization(): Promise; - - /** - * Reenable synchronization (idle/busy monitoring) with the app - namely, resume waiting for the app to go idle before moving forward in the test execution, after a - * previous disabling of it through a call to `device.disableSynchronization()`. - * - *

Warning: Making this call would resume synchronization **instantly**, having its returned promise only resolve when the app becomes idle again. - * In other words, this **must only be called after you navigate back to "the safe zone", where the app should be able to eventually become idle again**, or it would - * remain suspended "forever" (i.e. until a safeguard time-out expires). - * - * @example await device.enableSynchronization(); - */ - enableSynchronization(): Promise; - - /** - * Resets the Simulator to clean state (like the Simulator > Reset Content and Settings... menu item), especially removing previously set permissions. - * - * @example await device.resetContentAndSettings(); - */ - resetContentAndSettings(): Promise; - - /** - * Returns the current device, ios or android. - * - * @example - * if (device.getPlatform() === 'ios') { - * await expect(loopSwitch).toHaveValue('1'); - * } - */ - getPlatform(): 'ios' | 'android'; - - /** - * Takes a screenshot on the device and schedules putting it in the artifacts folder upon completion of the current test. - * @param name for the screenshot artifact - * @returns a temporary path to the screenshot. - * @example - * test('Menu items should have logout', async () => { - * const tempPath = await device.takeScreenshot('tap on menu'); - * // The temporary path will remain valid until the test completion. - * // Afterwards, the screenshot will be moved, e.g.: - * // * on success, to: /โœ“ Menu items should have Logout/tap on menu.png - * // * on failure, to: /โœ— Menu items should have Logout/tap on menu.png - * }); - */ - takeScreenshot(name: string): Promise; - - /** - * (iOS only) Saves a view hierarchy snapshot (*.viewhierarchy) of the currently opened application - * to a temporary folder and schedules putting it to the artifacts folder upon the completion of - * the current test. The file can be opened later in Xcode 12.0 and above. - * @see https://developer.apple.com/documentation/xcode-release-notes/xcode-12-release-notes#:~:text=57933113 - * @param [name="capture"] optional name for the *.viewhierarchy artifact - * @returns a temporary path to the captured view hierarchy snapshot. - * @example - * test('Menu items should have logout', async () => { - * await device.captureViewHierarchy('myElements'); - * // The temporary path will remain valid until the test completion. - * // Afterwards, the artifact will be moved, e.g.: - * // * on success, to: /โœ“ Menu items should have Logout/myElements.viewhierarchy - * // * on failure, to: /โœ— Menu items should have Logout/myElements.viewhierarchy - * }); - */ - captureViewHierarchy(name?: string): Promise; - - /** - * Simulate shake (iOS Only) - */ - shake(): Promise; - - /** - * Toggles device enrollment in biometric auth (TouchID or FaceID) (iOS Only) - * @example await device.setBiometricEnrollment(true); - * @example await device.setBiometricEnrollment(false); - */ - setBiometricEnrollment(enabled: boolean): Promise; - - /** - * Simulates the success of a face match via FaceID (iOS Only) - */ - matchFace(): Promise; - - /** - * Simulates the failure of a face match via FaceID (iOS Only) - */ - unmatchFace(): Promise; - - /** - * Simulates the success of a finger match via TouchID (iOS Only) - */ - matchFinger(): Promise; - - /** - * Simulates the failure of a finger match via TouchID (iOS Only) - */ - unmatchFinger(): Promise; - - /** - * Clears the simulator keychain (iOS Only) - */ - clearKeychain(): Promise; - - /** - * Simulate press back button (Android Only) - * @example await device.pressBack(); - */ - pressBack(): Promise; - - /** - * (Android Only) - * Exposes UiAutomator's UiDevice API (https://developer.android.com/reference/android/support/test/uiautomator/UiDevice). - * This is not a part of the official Detox API, - * it may break and change whenever an update to UiDevice or UiAutomator gradle dependencies ('androidx.test.uiautomator:uiautomator') is introduced. - * UIDevice's autogenerated code reference: https://github.com/wix/Detox/blob/master/detox/src/android/espressoapi/UIDevice.js - */ - getUiDevice(): Promise; - - /** - * (Android Only) - * Runs `adb reverse tcp:PORT tcp:PORT` for the current device - * to enable network requests forwarding on localhost:PORT (computer<->device). - * For more information, see {@link https://www.reddit.com/r/reactnative/comments/5etpqw/what_do_you_call_what_adb_reverse_is_doing|here}. - * This is a no-op when running on iOS. - */ - reverseTcpPort(port: number): Promise; - - /** - * (Android Only) - * Runs `adb reverse --remove tcp:PORT tcp:PORT` for the current device - * to disable network requests forwarding on localhost:PORT (computer<->device). - * For more information, see {@link https://www.reddit.com/r/reactnative/comments/5etpqw/what_do_you_call_what_adb_reverse_is_doing|here}. - * This is a no-op when running on iOS. - */ - unreverseTcpPort(port: number): Promise; - } - - /** - * @deprecated - */ - type DetoxAny = NativeElement & WaitFor; - - 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).tap(); - */ - atIndex(index: number): NativeElement; - } - - interface NativeElement extends NativeElementActions { - } - - interface ByFacade { - /** - * by.id will match an id that is given to the view via testID prop. - * @example - * // In a React Native component add testID like so: - * - * // Then match with by.id: - * await element(by.id('tap_me')); - * await element(by.id(/^tap_[a-z]+$/)); - */ - id(id: string | RegExp): NativeMatcher; - - /** - * Find an element by text, useful for text fields, buttons. - * @example - * await element(by.text('Tap Me')); - * await element(by.text(/^Tap .*$/)); - */ - text(text: string | RegExp): NativeMatcher; - - /** - * Find an element by accessibilityLabel on iOS, or by contentDescription on Android. - * @example - * await element(by.label('Welcome')); - * await element(by.label(/[a-z]+/i)); - */ - label(label: string | RegExp): NativeMatcher; - - /** - * Find an element by native view type. - * @example await element(by.type('RCTImageView')); - */ - type(nativeViewType: string): NativeMatcher; - - /** - * Find an element with an accessibility trait. (iOS only) - * @example await element(by.traits(['button'])); - */ - traits(traits: string[]): NativeMatcher; - - /** - * Collection of web matchers - */ - readonly web: ByWebFacade; - } - - interface ByWebFacade { - /** - * Find an element on the DOM tree by its id - * @param id - * @example - * web.element(by.web.id('testingh1')) - */ - id(id: string): WebMatcher; - - /** - * Find an element on the DOM tree by its CSS class - * @param className - * @example - * web.element(by.web.className('a')) - */ - className(className: string): WebMatcher; - - /** - * Find an element on the DOM tree matching the given CSS selector - * @param cssSelector - * @example - * web.element(by.web.cssSelector('#cssSelector')) - */ - cssSelector(cssSelector: string): WebMatcher; - - /** - * Find an element on the DOM tree by its "name" attribute - * @param name - * @example - * web.element(by.web.name('sec_input')) - */ - name(name: string): WebMatcher; - - /** - * Find an element on the DOM tree by its XPath - * @param xpath - * @example - * web.element(by.web.xpath('//*[@id="testingh1-1"]')) - */ - xpath(xpath: string): WebMatcher; - - /** - * Find an element on the DOM tree by its link text (href content) - * @param linkText - * @example - * web.element(by.web.href('disney.com')) - */ - href(linkText: string): WebMatcher; - - /** - * Find an element on the DOM tree by its partial link text (href content) - * @param linkTextFragment - * @example - * web.element(by.web.hrefContains('disney')) - */ - hrefContains(linkTextFragment: string): WebMatcher; - - /** - * Find an element on the DOM tree by its tag name - * @param tag - * @example - * web.element(by.web.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: NativeMatcher): NativeMatcher; - - /** - * Find an element by a matcher with a child matcher - * @example await element(by.id('Son883').withDescendant(by.id('Grandson883'))); - */ - 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 { - /** - * 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. - */ - (matcher?: NativeMatcher): WebViewElement; - } - - interface Expect> { - - /** - * Expect the view to be at least N% visible. If no number is provided then defaults to 75%. Negating this - * expectation with a `not` expects the view's visible area to be smaller than N%. - * @param pct optional integer ranging from 1 to 100, indicating how much percent of the view should be - * visible to the user to be accepted. - * @example await expect(element(by.id('mainTitle'))).toBeVisible(35); - */ - toBeVisible(pct?: number): R; - - /** - * Negate the expectation. - * @example await expect(element(by.id('cancelButton'))).not.toBeVisible(); - */ - not: this; - - /** - * Expect the view to not be visible. - * @example await expect(element(by.id('cancelButton'))).toBeNotVisible(); - * @deprecated Use `.not.toBeVisible()` instead. - */ - toBeNotVisible(): R; - - /** - * Expect the view to exist in the UI hierarchy. - * @example await expect(element(by.id('okButton'))).toExist(); - */ - toExist(): R; - - /** - * Expect the view to not exist in the UI hierarchy. - * @example await expect(element(by.id('cancelButton'))).toNotExist(); - * @deprecated Use `.not.toExist()` instead. - */ - toNotExist(): R; - - /** - * Expect the view to be focused. - * @example await expect(element(by.id('emailInput'))).toBeFocused(); - */ - toBeFocused(): R; - - /** - * Expect the view not to be focused. - * @example await expect(element(by.id('passwordInput'))).toBeNotFocused(); - * @deprecated Use `.not.toBeFocused()` instead. - */ - toBeNotFocused(): R; - - /** - * In React Native apps, expect UI component of type to have text. - * In native iOS apps, expect UI elements of type UIButton, UILabel, UITextField or UITextViewIn to have inputText with text. - * @example await expect(element(by.id('mainTitle'))).toHaveText('Welcome back!); - */ - toHaveText(text: string): R; - - /** - * Expects a specific accessibilityLabel, as specified via the `accessibilityLabel` prop in React Native. - * On the native side (in both React Native and pure-native apps), that is equivalent to `accessibilityLabel` - * on iOS and contentDescription on Android. Refer to Detox's documentation in order to learn about caveats - * with accessibility-labels in React Native apps. - * @example await expect(element(by.id('submitButton'))).toHaveLabel('Submit'); - */ - toHaveLabel(label: string): R; - - /** - * In React Native apps, expect UI component to have testID with that id. - * In native iOS apps, expect UI element to have accessibilityIdentifier with that id. - * @example await expect(element(by.text('Submit'))).toHaveId('submitButton'); - */ - toHaveId(id: string): R; - - /** - * Expects a toggle-able element (e.g. a Switch or a Check-Box) to be on/checked or off/unchecked. - * As a reference, in react-native, this is the equivalent switch component. - * @example await expect(element(by.id('switch'))).toHaveToggleValue(true); - */ - toHaveToggleValue(value: boolean): R; - - /** - * Expect components like a Switch to have a value ('0' for off, '1' for on). - * @example await expect(element(by.id('temperatureDial'))).toHaveValue('25'); - */ - toHaveValue(value: any): R; - - /** - * Expect Slider to have a position (0 - 1). - * Can have an optional tolerance to take into account rounding issues on ios - * @example await expect(element(by.id('SliderId'))).toHavePosition(0.75); - * @example await expect(element(by.id('SliderId'))).toHavePosition(0.74, 0.1); - */ - toHaveSliderPosition(position: number, tolerance?: number): Promise; - } - - 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('bigButton'))).toExist().withTimeout(2000); - */ - (element: NativeElement): Expect; - } - - interface WaitFor { - /** - * Waits for the condition to be met until the specified time (millis) have elapsed. - * @example await waitFor(element(by.id('bigButton'))).toExist().withTimeout(2000); - */ - withTimeout(millis: number): Promise; - - /** - * Performs the action repeatedly on the element until an expectation is met - * @example await waitFor(element(by.text('Item #5'))).toBeVisible().whileElement(by.id('itemsList')).scroll(50, 'down'); - */ - whileElement(by: NativeMatcher): NativeElement & WaitFor; - - // TODO: not sure about & WaitFor - check if we can chain whileElement multiple times - } - - interface NativeElementActions { - /** - * Simulate tap on an element - * @param point relative coordinates to the matched element (the element size could changes on different devices or even when changing the device font size) - * @example await element(by.id('tappable')).tap(); - * @example await element(by.id('tappable')).tap({ x:5, y:10 }); - */ - tap(point?: Point2D): Promise; - - /** - * Simulate long press on an element - * @param duration (iOS only) custom press duration time, in milliseconds. Optional (default is 1000ms). - * @example await element(by.id('tappable')).longPress(); - */ - longPress(duration?: number): Promise; - - /** - * 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: NativeElement, - normalizedTargetPositionX: number, normalizedTargetPositionY: number, speed: Speed, holdDuration: number): Promise; - - /** - * 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; - - /** - * 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 }); - * @deprecated Use `.tap()` instead. - */ - tapAtPoint(point: Point2D): Promise; - - /** - * Use the builtin keyboard to type text into a text field. - * @example await element(by.id('textField')).typeText('passcode'); - */ - typeText(text: string): Promise; - - /** - * Paste text into a text field. - * @example await element(by.id('textField')).replaceText('passcode again'); - */ - replaceText(text: string): Promise; - - /** - * Clear text from a text field. - * @example await element(by.id('textField')).clearText(); - */ - clearText(): Promise; - - /** - * Taps the backspace key on the built-in keyboard. - * @example await element(by.id('textField')).tapBackspaceKey(); - */ - tapBackspaceKey(): Promise; - - /** - * Taps the return key on the built-in keyboard. - * @example await element(by.id('textField')).tapReturnKey(); - */ - tapReturnKey(): Promise; - - /** - * Scrolls a given amount of pixels in the provided direction, starting from the provided start positions. - * @param pixels - independent device pixels - * @param direction - left/right/up/down - * @param startPositionX - the X starting scroll position, in percentage; valid input: `[0.0, 1.0]`, `NaN`; default: `NaN`โ€”choose the best value automatically - * @param startPositionY - the Y starting scroll position, in percentage; valid input: `[0.0, 1.0]`, `NaN`; default: `NaN`โ€”choose the best value automatically - * @example await element(by.id('scrollView')).scroll(100, 'down', NaN, 0.85); - * @example await element(by.id('scrollView')).scroll(100, 'up'); - */ - scroll( - pixels: number, - direction: Direction, - startPositionX?: number, - startPositionY?: number - ): Promise; - - /** - * Scroll to index. - * @example await element(by.id('scrollView')).scrollToIndex(10); - */ - scrollToIndex( - index: Number - ): Promise; - - /** - * Scroll to edge. - * @example await element(by.id('scrollView')).scrollTo('bottom'); - * @example await element(by.id('scrollView')).scrollTo('top'); - */ - scrollTo(edge: Direction): Promise; - - /** - * Adjust slider to position. - * @example await element(by.id('slider')).adjustSliderToPosition(0.75); - */ - adjustSliderToPosition(newPosition: number): Promise; - - /** - * Swipes in the provided direction at the provided speed, started from percentage. - * @param speed default: `fast` - * @param percentage screen percentage to swipe; valid input: `[0.0, 1.0]` - * @param optional normalizedStartingPointX X coordinate of swipe starting point, relative to the view width; valid input: `[0.0, 1.0]` - * @param normalizedStartingPointY Y coordinate of swipe starting point, relative to the view height; valid input: `[0.0, 1.0]` - * @example await element(by.id('scrollView')).swipe('down'); - * @example await element(by.id('scrollView')).swipe('down', 'fast'); - * @example await element(by.id('scrollView')).swipe('down', 'fast', 0.5); - * @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; - - /** - * Sets a picker viewโ€™s column to the given value. This function supports both date pickers and general picker views. (iOS Only) - * Note: When working with date pickers, you should always set an explicit locale when launching your app in order to prevent flakiness from different date and time styles. - * See [here](https://wix.github.io/Detox/docs/api/device-object-api#9-launch-with-a-specific-language-ios-only) for more information. - * - * @param column number of datepicker column (starts from 0) - * @param value string value in set column (must be correct) - * @example - * await expect(element(by.type('UIPickerView'))).toBeVisible(); - * await element(by.type('UIPickerView')).setColumnToValue(1,"6"); - * await element(by.type('UIPickerView')).setColumnToValue(2,"34"); - */ - setColumnToValue(column: number, value: string): Promise; - - /** - * Sets the date of a date-picker according to the specified date-string and format. - * @param dateString Textual representation of a date (e.g. '2023/01/01'). Should be in coherence with the format specified by `dateFormat`. - * @param dateFormat Format of `dateString`: Generally either 'ISO8601' or an explicitly specified format (e.g. 'yyyy/MM/dd'); It should - * follow the rules of NSDateFormatter for iOS and DateTimeFormatter for Android. - * @see https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Date/toISOString - * @example - * await element(by.id('datePicker')).setDatePickerDate('2023-01-01T00:00:00Z', 'ISO8601'); - * await element(by.id('datePicker')).setDatePickerDate(new Date().toISOString(), 'ISO8601'); - * await element(by.id('datePicker')).setDatePickerDate('2023/01/01', 'yyyy/MM/dd'); - */ - setDatePickerDate(dateString: string, dateFormat: string): Promise; - - /** - * Triggers a given [accessibility action]{@link https://reactnative.dev/docs/accessibility#accessibility-actions}. - * @param actionName - name of the accessibility action - * @example await element(by.id('view')).performAccessibilityAction('activate'); - */ - performAccessibilityAction(actionName: string): Promise - - /** - * Pinches in the given direction with speed and angle. (iOS only) - * @param angle value in radiant, default is `0` - * @example - * await expect(element(by.id('PinchableScrollView'))).toBeVisible(); - * await element(by.id('PinchableScrollView')).pinchWithAngle('outward', 'slow', 0); - * @deprecated Use `.pinch()` instead. - */ - pinchWithAngle(direction: PinchDirection, speed: Speed, angle: number): Promise; - - /** - * Pinches with the given scale, speed, and angle. (iOS only) - * @param speed default is `fast` - * @param angle value in radiant, default is `0` - * @example - * await element(by.id('PinchableScrollView')).pinch(1.1); - * await element(by.id('PinchableScrollView')).pinch(2.0); - * await element(by.id('PinchableScrollView')).pinch(0.001); - */ - pinch(scale: number, speed?: Speed, angle?: number): Promise; - - /** - * Takes a screenshot of the element and schedules putting it in the artifacts folder upon completion of the current test. - * For more information, see {@link https://wix.github.io/Detox/docs/api/screenshots#element-level-screenshots} - * @param {string} name for the screenshot artifact - * @returns {Promise} a temporary path to the screenshot. - * @example - * test('Menu items should have logout', async () => { - * const imagePath = await element(by.id('menuRoot')).takeScreenshot('tap on menu'); - * // The temporary path will remain valid until the test completion. - * // Afterwards, the screenshot will be moved, e.g.: - * // * on success, to: /โœ“ Menu items should have Logout/tap on menu.png - * // * on failure, to: /โœ— Menu items should have Logout/tap on menu.png - * }); - */ - takeScreenshot(name: string): Promise; - - /** - * Retrieves the OS-dependent attributes of an element. - * If there are multiple matches, it returns an array of attributes for all matched elements. - * For detailed information, refer to {@link https://wix.github.io/Detox/docs/api/actions-on-element/#getattributes} - * - * @example - * test('Get the attributes for my text element', async () => { - * const attributes = await element(by.id('myText')).getAttributes() - * const jestExpect = require('expect'); - * // 'visible' attribute available on both iOS and Android - * jestExpect(attributes.visible).toBe(true); - * // 'activationPoint' attribute available on iOS only - * jestExpect(attributes.activationPoint.x).toHaveValue(50); - * // 'width' attribute available on Android only - * jestExpect(attributes.width).toHaveValue(100); - * }) - */ - getAttributes(): Promise; - } - - interface WebExpect> { - /** - * Negate the expectation. - * @example await expect(web.element(by.web.id('sessionTimeout'))).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(web.element(by.web.id('checkoutButton'))).toHaveText('Proceed to check out'); - */ - toHaveText(text: string): R; - - /** - * Expect the view to exist in the webview DOM tree. - * @example await expect(web.element(by.web.id('submitButton'))).toExist(); - */ - toExist(): R; - } - - interface IndexableWebElement extends WebElement { - /** - * Choose from multiple elements matching the same matcher using index - * @example await web.element(by.web.hrefContains('Details')).atIndex(2).tap(); - */ - atIndex(index: number): WebElement; - } - - interface WebElement extends WebElementActions { - } - - interface WebElementActions { - tap(): Promise; - - /** - * @param text to type - * @param isContentEditable whether its a ContentEditable element, default is false. - */ - typeText(text: string, isContentEditable: boolean): Promise; - - /** - * At the moment not working on content-editable - * @param text to replace with the old content. - */ - replaceText(text: string): Promise; - - /** - * At the moment not working on content-editable - */ - clearText(): Promise; - - /** - * scrolling to the view, the element top position will be at the top of the screen. - */ - scrollToView(): Promise; - - /** - * Gets the input content - */ - getText(): Promise; - - /** - * Calls the focus function on the element - */ - focus(): Promise; - - /** - * Selects all the input content, works on ContentEditable at the moment. - */ - selectAllText(): Promise; - - /** - * Moves the input cursor / caret to the end of the content, works on ContentEditable at the moment. - */ - moveCursorToEnd(): Promise; - - /** - * Running a JavaScript function on the element. - * The first argument to the function will be the element itself. - * The rest of the arguments will be forwarded to the JavaScript function as is. - * - * @param script a callback function in stringified form, or a plain function reference - * without closures, bindings etc. that will be converted to a string. - * @param args optional args to pass to the script - * - * @example - * await webElement.runScript('(el) => el.click()'); - * await webElement.runScript(function setText(element, text) { - * element.textContent = text; - * }, ['Custom Title']); - */ - runScript(script: string, args?: unknown[]): Promise; - runScript(script: (...args: any[]) => F, args?: unknown[]): Promise; - - /** - * Gets the current page url - */ - getCurrentUrl(): Promise; - - /** - * Gets the current page title - */ - getTitle(): Promise; - } - - type Direction = 'left' | 'right' | 'top' | 'bottom' | 'up' | 'down'; - - type PinchDirection = 'outward' | 'inward' - - type Orientation = 'portrait' | 'landscape'; - - type Speed = 'fast' | 'slow'; - - interface LanguageAndLocale { - language?: string; - locale?: string; - } - - /** - * Source for string definitions is https://github.com/wix/AppleSimulatorUtils - */ - interface DevicePermissions { - location?: LocationPermission; - notifications?: NotificationsPermission; - calendar?: CalendarPermission; - camera?: CameraPermission; - contacts?: ContactsPermission; - health?: HealthPermission; - homekit?: HomekitPermission; - medialibrary?: MediaLibraryPermission; - microphone?: MicrophonePermission; - motion?: MotionPermission; - photos?: PhotosPermission; - reminders?: RemindersPermission; - siri?: SiriPermission; - speech?: SpeechPermission; - faceid?: FaceIDPermission; - userTracking?: UserTrackingPermission; - } - - type LocationPermission = 'always' | 'inuse' | 'never' | 'unset'; - type PermissionState = 'YES' | 'NO' | 'unset'; - type CameraPermission = PermissionState; - type ContactsPermission = PermissionState; - type CalendarPermission = PermissionState; - type HealthPermission = PermissionState; - type HomekitPermission = PermissionState; - type MediaLibraryPermission = PermissionState; - type MicrophonePermission = PermissionState; - type MotionPermission = PermissionState; - type PhotosPermission = PermissionState; - type RemindersPermission = PermissionState; - type SiriPermission = PermissionState; - type SpeechPermission = PermissionState; - type NotificationsPermission = PermissionState; - type FaceIDPermission = PermissionState; - type UserTrackingPermission = PermissionState; - - interface DeviceLaunchAppConfig { - /** - * Restart the app - * Terminate the app and launch it again. If set to false, the simulator will try to bring app from background, if the app isn't running, it will launch a new instance. default is false - */ - newInstance?: boolean; - /** - * Set runtime permissions - * Grant or deny runtime permissions for your application. - */ - permissions?: DevicePermissions; - /** - * Launch from URL - * Mock opening the app from URL to test your app's deep link handling mechanism. - */ - url?: any; - /** - * Launch with user notifications - */ - userNotification?: any; - /** - * Launch with user activity - */ - userActivity?: any; - /** - * Launch into a fresh installation - * A flag that enables relaunching into a fresh installation of the app (it will uninstall and install the binary again), default is false. - */ - delete?: boolean; - /** - * Arguments to pass-through into the app. - * Refer to the [dedicated guide](https://wix.github.io/Detox/docs/api/launch-args) for complete details. - */ - launchArgs?: Record; - /** - * Launch config for specifying the native language and locale - */ - languageAndLocale?: LanguageAndLocale; - } - - // Element Attributes Shared Among iOS and Android - interface ElementAttributes { - /** - * Whether or not the element is enabled for user interaction. - */ - enabled: boolean; - /** - * The identifier of the element. Matches accessibilityIdentifier on iOS, and the main view tag, on Android - both commonly holding the component's test ID in React Native apps. - */ - identifier: string; - /** - * Whether the element is visible. On iOS, visibility is calculated for the activation point. On Android, the attribute directly holds the value returned by View.getLocalVisibleRect()). - */ - visible: boolean; - /** - * The text value of any textual element. - */ - text?: string; - /** - * The label of the element. Largely matches accessibilityLabel for ios, and contentDescription for android. - * Refer to Detox's documentation (`toHaveLabel()` subsection) in order to learn about caveats associated with - * this property in React Native apps. - */ - label?: string; - /** - * The placeholder text value of the element. Matches hint on android. - */ - placeholder?: string; - /** - * The value of the element, where applicable. - * Matches accessibilityValue, on iOS. - * For example: the position of a slider, or whether a checkbox has been marked (Android). - */ - value?: unknown; - } - - interface IosElementAttributeFrame { - y: number; - x: number; - width: number; - height: number; - } - - interface IosElementAttributeInsets { - right: number; - top: number; - left: number; - bottom: number; - } - - // iOS Specific Attributes - interface IosElementAttributes extends ElementAttributes { - /** - * The [activation point]{@link https://developer.apple.com/documentation/objectivec/nsobject/1615179-accessibilityactivationpoint} of the element, in element coordinate space. - */ - activationPoint: Point2D; - /** - * The activation point of the element, in normalized percentage ([0.0, 1.0]). - */ - normalizedActivationPoint: Point2D; - /** - * Whether the element is hittable at the activation point. - */ - hittable: boolean; - /** - * The frame of the element, in screen coordinate space. - */ - frame: IosElementAttributeFrame; - /** - * The frame of the element, in container coordinate space. - */ - elementFrame: IosElementAttributeFrame; - /** - * The bounds of the element, in element coordinate space. - */ - elementBounds: IosElementAttributeFrame; - /** - * The safe area insets of the element, in element coordinate space. - */ - safeAreaInsets: IosElementAttributeInsets; - /** - * The safe area bounds of the element, in element coordinate space. - */ - elementSafeBounds: IosElementAttributeFrame; - /** - * The date of the element (if it is a date picker). - */ - date?: string; - /** - * The normalized slider position (if it is a slider). - */ - normalizedSliderPosition?: number; - /** - * The content offset (if it is a scroll view). - */ - contentOffset?: Point2D; - /** - * The content inset (if it is a scroll view). - */ - contentInset?: IosElementAttributeInsets; - /** - * The adjusted content inset (if it is a scroll view). - */ - adjustedContentInset?: IosElementAttributeInsets; - /** - * @example "" - */ - layer: string; - } - - // Android Specific Attributes - interface AndroidElementAttributes extends ElementAttributes { - /** - * The OS visibility type associated with the element: visible, invisible or gone. - */ - visibility: 'visible' | 'invisible' | 'gone'; - /** - * Width of the element, in pixels. - */ - width: number; - /** - * Height of the element, in pixels. - */ - height: number; - /** - * Elevation of the element. - */ - elevation: number; - /** - * Alpha value for the element. - */ - alpha: number; - /** - * Whether the element is the one currently in focus. - */ - focused: boolean; - /** - * The text size for the text element. - */ - textSize?: number; - /** - * The length of the text element (character count). - */ - length?: number; - } - } -} +/// +/// declare const detox: Detox.DetoxExportWrapper; export = detox; diff --git a/detox/internals.d.ts b/detox/internals.d.ts index c0a2fecad8..9496b5ec9e 100644 --- a/detox/internals.d.ts +++ b/detox/internals.d.ts @@ -116,8 +116,9 @@ declare global { /** * Workaround for Jest exiting abruptly in --bail mode. * Makes sure that all workers and their test environments are properly torn down. + * @param [permanent] - forbids further retries */ - unsafe_conductEarlyTeardown(): Promise; + unsafe_conductEarlyTeardown(permanent?: boolean): Promise; /** * Reports to Detox CLI about passed and failed test files. * The failed test files might be re-run again if diff --git a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h index c2c0638fd0..5719a0ca57 100644 --- a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h +++ b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.h @@ -13,6 +13,8 @@ NS_ASSUME_NONNULL_BEGIN @interface UIScrollView (DetoxActions) - (void)dtx_scrollToEdge:(UIRectEdge)edge NS_SWIFT_NAME(dtx_scroll(to:)); +- (void)dtx_scrollToEdge:(UIRectEdge)edge + normalizedStartingPoint:(CGPoint)normalizedStartingPoint; - (void)dtx_scrollWithOffset:(CGPoint)offset; - (void)dtx_scrollWithOffset:(CGPoint)offset normalizedStartingPoint:(CGPoint)normalizedStartingPoint NS_SWIFT_NAME(dtx_scroll(withOffset:normalizedStartingPoint:)); diff --git a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m index 5bcd147473..d78c470e48 100644 --- a/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m +++ b/detox/ios/Detox/Actions/UIScrollView+DetoxActions.m @@ -83,7 +83,7 @@ @implementation UIScrollView (DetoxActions) [self setContentOffset:pointMakeMacro(target) animated:YES]; \ [NSRunLoop.currentRunLoop runUntilDate:[NSDate dateWithTimeIntervalSinceNow:[[self valueForKeyPath:@"animation.duration"] doubleValue] + 0.05]]; -- (void)dtx_scrollToEdge:(UIRectEdge)edge +- (CGPoint)_edgeToNormalizedEdge:(UIRectEdge)edge { CGPoint normalizedEdge; switch (edge) { @@ -100,10 +100,19 @@ - (void)dtx_scrollToEdge:(UIRectEdge)edge normalizedEdge = CGPointMake(1, 0); break; default: + normalizedEdge= CGPointMake(0, 0); DTXAssert(NO, @"Incorect edge provided."); - return; } - + return normalizedEdge; +} + + +- (void)dtx_scrollToEdge:(UIRectEdge)edge +{ + CGPoint normalizedEdge = [self _edgeToNormalizedEdge:edge]; + if(normalizedEdge.x == 0 && normalizedEdge.y == 0) + return; + [self _dtx_scrollToNormalizedEdge:normalizedEdge]; } @@ -121,6 +130,23 @@ - (void)_dtx_scrollToNormalizedEdge:(CGPoint)edge [self _dtx_scrollWithOffset:CGPointMake(- edge.x * CGFLOAT_MAX, - edge.y * CGFLOAT_MAX) normalizedStartingPoint:CGPointMake(NAN, NAN) strict:NO]; } +- (void)dtx_scrollToEdge:(UIRectEdge)edge + normalizedStartingPoint:(CGPoint)normalizedStartingPoint +{ + CGPoint normalizedEdge = [self _edgeToNormalizedEdge:edge]; + if(normalizedEdge.x == 0 && normalizedEdge.y == 0) + return; + + [self _dtx_scrollToNormalizedEdge:normalizedEdge normalizedStartingPoint: normalizedStartingPoint ]; +} + +- (void)_dtx_scrollToNormalizedEdge:(CGPoint)edge + normalizedStartingPoint:(CGPoint)normalizedStartingPoint +{ + [self _dtx_scrollWithOffset:CGPointMake(- edge.x * CGFLOAT_MAX, - edge.y * CGFLOAT_MAX) normalizedStartingPoint:normalizedStartingPoint strict:NO]; +} + + DTX_ALWAYS_INLINE static NSString* _DTXScrollDirectionDescriptionWithOffset(CGPoint offset) { diff --git a/detox/ios/Detox/Invocation/Action.swift b/detox/ios/Detox/Invocation/Action.swift index a4ac09db30..1a4470d741 100644 --- a/detox/ios/Detox/Invocation/Action.swift +++ b/detox/ios/Detox/Invocation/Action.swift @@ -129,6 +129,15 @@ class Action : CustomStringConvertible { } } + func startPosition(forIndex index: Int, in params: [Any]?) -> Double { + guard params?.count ?? 0 > index, + let param = params?[index] as? Double, + param.isNaN == false else { + return Double.nan + } + return param + } + var description: String { let paramsDescription: String if let params = params { @@ -434,9 +443,13 @@ class ScrollToEdgeAction : Action { fatalError("Unknown scroll direction") break; } - - element.scroll(to: targetEdge) - + + let startPositionX = startPosition(forIndex: 1, in: params) + let startPositionY = startPosition(forIndex: 2, in: params) + let normalizedStartingPoint = CGPoint(x: startPositionX, y: startPositionY) + + element.scroll(to: targetEdge, normalizedStartingPoint: normalizedStartingPoint) + return nil } } @@ -487,18 +500,9 @@ class SwipeAction : Action { targetNormalizedOffset.x *= CGFloat(appliedPercentage) targetNormalizedOffset.y *= CGFloat(appliedPercentage) - let startPositionX : Double - if params?.count ?? 0 > 3, let param2 = params?[3] as? Double, param2.isNaN == false { - startPositionX = param2 - } else { - startPositionX = Double.nan - } - let startPositionY : Double - if params?.count ?? 0 > 4, let param3 = params?[4] as? Double, param3.isNaN == false { - startPositionY = param3 - } else { - startPositionY = Double.nan - } + + let startPositionX = startPosition(forIndex: 3, in: params) + let startPositionY = startPosition(forIndex: 4, in: params) let normalizedStartingPoint = CGPoint(x: startPositionX, y: startPositionY) element.swipe(normalizedOffset: targetNormalizedOffset, velocity: velocity, normalizedStartingPoint: normalizedStartingPoint) diff --git a/detox/ios/Detox/Invocation/Element.swift b/detox/ios/Detox/Invocation/Element.swift index 5570ab5ba5..378608b8af 100644 --- a/detox/ios/Detox/Invocation/Element.swift +++ b/detox/ios/Detox/Invocation/Element.swift @@ -140,10 +140,13 @@ class Element : NSObject { view.dtx_pinch(withScale: scale, velocity: velocity, angle: angle) } - func scroll(to edge: UIRectEdge) { + func scroll(to edge: UIRectEdge, normalizedStartingPoint: CGPoint? = nil) { let scrollView = extractScrollView() - - scrollView.dtx_scroll(to: edge) + if let normalizedStartingPoint = normalizedStartingPoint { + scrollView.dtx_scroll(to: edge, normalizedStarting: normalizedStartingPoint) + } else { + scrollView.dtx_scroll(to: edge) + } } func scroll(withOffset offset: CGPoint, normalizedStartingPoint: CGPoint? = nil) { diff --git a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m index 578a9cb21f..8bafa95688 100644 --- a/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m +++ b/detox/ios/Detox/Utilities/UIWindow+DetoxUtils.m @@ -224,37 +224,37 @@ - (NSString *)dtx_shortDescription } + (nullable UIWindow *)dtx_topMostWindowAtPoint:(CGPoint)point { - NSArray *windows = UIApplication.sharedApplication.windows; - - NSArray *visibleWindowsAtPoint = [windows - filteredArrayUsingPredicate:[NSPredicate - predicateWithBlock:^BOOL(UIWindow *window, NSDictionary * _Nullable __unused bindings) { - if (!CGRectContainsPoint(window.frame, point)) { - return NO; - } + NSArray *windows = [self dtx_allWindows]; + + NSArray *visibleWindowsAtPoint = [windows filteredArrayUsingPredicate: + [NSPredicate predicateWithBlock:^BOOL( + UIWindow *window, + NSDictionary * _Nullable __unused bindings + ) { + if (!CGRectContainsPoint(window.frame, point)) { + return NO; + } - if (![window isVisibleAroundPoint:point]) { - return NO; - } + if (![window isVisibleAroundPoint:point]) { + return NO; + } - UIView * _Nullable hit = [window hitTest:point withEvent:nil]; - if (!hit) { - // The point lies completely outside the windos's hierarchy. - return NO; - } + if (![window hitTest:point withEvent:nil]) { + // The point lies completely outside the window's hierarchy. + return NO; + } - return YES; - }]]; + return YES; + }]]; - if (!visibleWindowsAtPoint) { - return nil; + if (!visibleWindowsAtPoint) { + return nil; } return [[visibleWindowsAtPoint - sortedArrayUsingComparator:^NSComparisonResult(UIWindow *window1, UIWindow *window2) { - return window1.windowLevel - window2.windowLevel; - }] - lastObject]; + sortedArrayUsingComparator:^NSComparisonResult(UIWindow *window1, UIWindow *window2) { + return window1.windowLevel - window2.windowLevel; + }] lastObject]; } @end diff --git a/detox/ios/DetoxSync b/detox/ios/DetoxSync index 526f2507e2..5b26331610 160000 --- a/detox/ios/DetoxSync +++ b/detox/ios/DetoxSync @@ -1 +1 @@ -Subproject commit 526f2507e2e41c744e8286a83fe9325e2b4bda8d +Subproject commit 5b26331610dc358fc48894023b5d172c238e54b8 diff --git a/detox/jest.config.js b/detox/jest.config.js index 0901108b36..6ca2c4a514 100644 --- a/detox/jest.config.js +++ b/detox/jest.config.js @@ -74,7 +74,7 @@ module.exports = { 'runners/jest/testEnvironment', 'src/DetoxWorker.js', 'src/logger/utils/streamUtils.js', - 'src/realms' + 'src/realms', ], resetMocks: true, resetModules: true, diff --git a/detox/local-cli/init.js b/detox/local-cli/init.js index fe86589eb9..daee19d8d0 100644 --- a/detox/local-cli/init.js +++ b/detox/local-cli/init.js @@ -106,7 +106,7 @@ function createDefaultConfigurations() { simulator: { type: 'ios.simulator', device: { - type: 'iPhone 12', + type: 'iPhone 15', }, }, attached: { diff --git a/detox/local-cli/test.test.js b/detox/local-cli/test.test.js index c29ce59959..8ea5ac19fe 100644 --- a/detox/local-cli/test.test.js +++ b/detox/local-cli/test.test.js @@ -1,10 +1,11 @@ // @ts-nocheck if (process.platform === 'win32') { - jest.retryTimes(1); // TODO: investigate why it gets stuck for the 1st time on Windows + jest.retryTimes(1); // TODO [2024-12-01]: investigate why it gets stuck for the 1st time on Windows } jest.mock('../src/logger/DetoxLogger'); jest.mock('./utils/jestInternals'); +jest.mock('./utils/interruptListeners'); const cp = require('child_process'); const cpSpawn = cp.spawn; @@ -18,6 +19,8 @@ const { buildMockCommand, callCli } = require('../__tests__/helpers'); const { DEVICE_LAUNCH_ARGS_DEPRECATION } = require('./testCommand/warnings'); +const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)); + describe('CLI', () => { let _env; let logger; @@ -143,6 +146,38 @@ describe('CLI', () => { }); }); + describe('detached runner', () => { + beforeEach(() => { + detoxConfig.testRunner.detached = true; + }); + + test('should be able to run as you would normally expect', async () => { + await run(); + expect(_.last(cliCall().argv)).toEqual('e2e/config.json'); + }); + + test('should intercept SIGINT and SIGTERM', async () => { + const { subscribe, unsubscribe } = jest.requireMock('./utils/interruptListeners'); + const simulateSIGINT = () => subscribe.mock.calls[0][0](); + + mockExitCode(1); + mockLongRun(2000); + + await Promise.all([ + run('--retries 2').catch(_.noop), + sleep(1000).then(() => { + simulateSIGINT(); + simulateSIGINT(); + expect(unsubscribe).not.toHaveBeenCalled(); + }), + ]); + + expect(unsubscribe).toHaveBeenCalled(); + expect(cliCall(0)).not.toBe(null); + expect(cliCall(1)).toBe(null); + }); + }); + test('should use testRunner.args._ as default specs', async () => { detoxConfig.testRunner.args._ = ['e2e/sanity']; await run(); @@ -620,4 +655,9 @@ describe('CLI', () => { mockExecutable.options.exitCode = code; detoxConfig.testRunner.args.$0 = mockExecutable.cmd; } + + function mockLongRun(ms) { + mockExecutable.options.sleep = ms; + detoxConfig.testRunner.args.$0 = mockExecutable.cmd; + } }); diff --git a/detox/local-cli/testCommand/TestRunnerCommand.js b/detox/local-cli/testCommand/TestRunnerCommand.js index fcf4534732..e24fb64dbe 100644 --- a/detox/local-cli/testCommand/TestRunnerCommand.js +++ b/detox/local-cli/testCommand/TestRunnerCommand.js @@ -12,6 +12,7 @@ const { escapeSpaces, useForwardSlashes } = require('../../src/utils/shellUtils' const sleep = require('../../src/utils/sleep'); const AppStartCommand = require('../startCommand/AppStartCommand'); const { markErrorAsLogged } = require('../utils/cliErrorHandling'); +const interruptListeners = require('../utils/interruptListeners'); const TestRunnerError = require('./TestRunnerError'); @@ -28,10 +29,12 @@ class TestRunnerCommand { const appsConfig = opts.config.apps; this._argv = runnerConfig.args; + this._detached = runnerConfig.detached; this._retries = runnerConfig.retries; this._envHint = this._buildEnvHint(opts.env); this._startCommands = this._prepareStartCommands(appsConfig, cliConfig); this._envFwd = {}; + this._terminating = false; if (runnerConfig.forwardEnv) { this._envFwd = this._buildEnvOverride(cliConfig, deviceConfig); @@ -59,16 +62,20 @@ class TestRunnerCommand { } catch (e) { launchError = e; + if (this._terminating) { + runsLeft = 0; + } + const failedTestFiles = detox.session.testResults.filter(r => !r.success); const { bail } = detox.config.testRunner; if (bail && failedTestFiles.some(r => r.isPermanentFailure)) { - throw e; + runsLeft = 0; } const testFilesToRetry = failedTestFiles.filter(r => !r.isPermanentFailure).map(r => r.testFilePath); - if (_.isEmpty(testFilesToRetry)) { - throw e; + if (testFilesToRetry.length === 0) { + runsLeft = 0; } if (--runsLeft > 0) { @@ -143,6 +150,15 @@ class TestRunnerCommand { }, _.isUndefined); } + _onTerminate = () => { + if (this._terminating) { + return; + } + + this._terminating = true; + return detox.unsafe_conductEarlyTeardown(true); + }; + async _spawnTestRunner() { const fullCommand = this._buildSpawnArguments().map(escapeSpaces); const fullCommandWithHint = printEnvironmentVariables(this._envHint) + fullCommand.join(' '); @@ -153,6 +169,7 @@ class TestRunnerCommand { cp.spawn(fullCommand[0], fullCommand.slice(1), { shell: true, stdio: 'inherit', + detached: this._detached, env: _({}) .assign(process.env) .assign(this._envFwd) @@ -162,6 +179,8 @@ class TestRunnerCommand { }) .on('error', /* istanbul ignore next */ (err) => reject(err)) .on('exit', (code, signal) => { + interruptListeners.unsubscribe(this._onTerminate); + if (code === 0) { log.trace.end({ success: true }); resolve(); @@ -175,6 +194,10 @@ class TestRunnerCommand { reject(markErrorAsLogged(error)); } }); + + if (this._detached) { + interruptListeners.subscribe(this._onTerminate); + } }); } diff --git a/detox/local-cli/utils/interruptListeners.js b/detox/local-cli/utils/interruptListeners.js new file mode 100644 index 0000000000..d59ad52b46 --- /dev/null +++ b/detox/local-cli/utils/interruptListeners.js @@ -0,0 +1,15 @@ +function subscribe(listener) { + process.on('SIGINT', listener); + process.on('SIGTERM', listener); +} + +function unsubscribe(listener) { + process.removeListener('SIGINT', listener); + process.removeListener('SIGTERM', listener); +} + +module.exports = { + subscribe, + unsubscribe, +}; + diff --git a/detox/package.json b/detox/package.json index c8102f4a6b..9a9293b5f4 100644 --- a/detox/package.json +++ b/detox/package.json @@ -1,7 +1,7 @@ { "name": "detox", "description": "E2E tests and automation for mobile", - "version": "20.13.1", + "version": "20.18.1", "bin": { "detox": "local-cli/cli.js" }, @@ -34,29 +34,34 @@ "postinstall": "node scripts/postinstall.js" }, "devDependencies": { + "@react-native/babel-preset": "0.73.19", + "@react-native/eslint-config": "^0.73.2", + "@react-native/metro-config": "^0.73.3", + "@react-native/typescript-config": "0.73.1", + "@tsconfig/react-native": "^3.0.0", "@types/bunyan": "^1.8.8", "@types/child-process-promise": "^2.2.1", - "@types/fs-extra": "^9.0.13", - "@types/jest": "^28.1.8", + "@types/fs-extra": "^11.0.4", + "@types/jest": "^29.0.0", "@types/node": "^14.18.33", "@types/node-ipc": "^9.2.0", "@types/ws": "^7.4.0", - "@typescript-eslint/eslint-plugin": "^5.59.8", - "@typescript-eslint/parser": "^5.59.8", + "@typescript-eslint/eslint-plugin": "^6.16.0", + "@typescript-eslint/parser": "^6.16.0", "cross-env": "^7.0.3", - "eslint": "^8.41.0", - "eslint-plugin-ecmascript-compat": "^3.0.0", - "eslint-plugin-import": "^2.27.5", + "eslint": "^8.56.0", + "eslint-plugin-ecmascript-compat": "^3.1.0", + "eslint-plugin-import": "^2.29.1", "eslint-plugin-no-only-tests": "^3.1.0", "eslint-plugin-node": "^11.1.0", - "eslint-plugin-unicorn": "^47.0.0", - "jest": "^28.1.3", - "jest-allure2-reporter": "2.0.0-alpha.6", - "mockdate": "^2.0.1", - "prettier": "^2.4.1", - "react-native": "0.71.10", + "eslint-plugin-unicorn": "^50.0.1", + "jest": "^29.6.3", + "jest-allure2-reporter": "^2.0.0-beta.9", + "metro-react-native-babel-preset": "0.76.8", + "prettier": "^3.1.1", + "react-native": "0.73.2", "react-native-codegen": "^0.0.8", - "typescript": "^4.5.2", + "typescript": "^5.3.3", "wtfnode": "^0.9.1" }, "dependencies": { @@ -72,6 +77,7 @@ "funpermaproxy": "^1.1.0", "glob": "^8.0.3", "ini": "^1.3.4", + "jest-environment-emit": "^1.0.5", "json-cycle": "^1.3.0", "lodash": "^4.17.11", "multi-sort-stream": "^1.0.3", @@ -104,9 +110,9 @@ } }, "engines": { - "node": ">=14.5.0" + "node": ">=18" }, "browserslist": [ - "node 14" + "node 18" ] } diff --git a/detox/runners/jest/testEnvironment/index.js b/detox/runners/jest/testEnvironment/index.js index cddafe5e85..1c85efffdd 100644 --- a/detox/runners/jest/testEnvironment/index.js +++ b/detox/runners/jest/testEnvironment/index.js @@ -1,5 +1,6 @@ const path = require('path'); +const WithEmitter = require('jest-environment-emit').default; const resolveFrom = require('resolve-from'); const maybeNodeEnvironment = require(resolveFrom(process.cwd(), 'jest-environment-node')); /** @type {typeof import('@jest/environment').JestEnvironment} */ @@ -31,7 +32,7 @@ const log = detox.log.child({ cat: 'lifecycle,jest-environment' }); /** * @see https://www.npmjs.com/package/jest-circus#overview */ -class DetoxCircusEnvironment extends NodeEnvironment { +class DetoxCircusEnvironment extends WithEmitter(NodeEnvironment) { constructor(config, context) { super(assertJestCircus27(config), assertExistingContext(context)); @@ -62,6 +63,9 @@ class DetoxCircusEnvironment extends NodeEnvironment { SpecReporter, WorkerAssignReporter, }); + + // Artifacts flushing should be delayed to avoid conflicts with third-party reporters + this.testEvents.on('*', this._onTestEvent.bind(this), 1e6); } /** @override */ @@ -72,16 +76,13 @@ class DetoxCircusEnvironment extends NodeEnvironment { // @ts-expect-error TS2425 async handleTestEvent(event, state) { - if (detox.session.unsafe_earlyTeardown) { - throw new Error('Detox halted test execution due to an early teardown request'); - } - - this._timer.schedule(state.testTimeout != null ? state.testTimeout : this.setupTimeout); + // @ts-expect-error TS2855 + await super.handleTestEvent(event, state); - if (SYNC_CIRCUS_EVENTS.has(event.name)) { - this._handleTestEventSync(event, state); - } else { - await this._handleTestEventAsync(event, state); + if (detox.session.unsafe_earlyTeardown) { + if (event.name === 'test_fn_start' || event.name === 'hook_start') { + throw new Error('Detox halted test execution due to an early teardown request'); + } } } @@ -107,6 +108,10 @@ class DetoxCircusEnvironment extends NodeEnvironment { * @protected */ async initDetox() { + if (detox.session.unsafe_earlyTeardown) { + throw new Error('Detox halted test execution due to an early teardown request'); + } + const opts = { global: this.global, workerId: `w${process.env.JEST_WORKER_ID}`, @@ -141,6 +146,23 @@ class DetoxCircusEnvironment extends NodeEnvironment { } } + /** @private */ + _onTestEvent({ type, event, state }) { + const timeout = state && state.testTimeout != null ? state.testTimeout : this.setupTimeout; + + this._timer.schedule(timeout); + + if (event) { + if (SYNC_CIRCUS_EVENTS.has(event.name)) { + this._handleTestEventSync(event, state); + } else { + return this._handleTestEventAsync(event, state); + } + } else { + return this._handleTestEventAsync({ name: type }, null); + } + } + /** @private */ async _handleTestEventAsync(event, state = null) { const description = `handling ${state ? 'jest-circus' : 'jest-environment'} "${event.name}" event`; diff --git a/detox/scripts/build_framework.ios.sh b/detox/scripts/build_framework.ios.sh index 77cdcca5e4..b7f6e8c0bf 100755 --- a/detox/scripts/build_framework.ios.sh +++ b/detox/scripts/build_framework.ios.sh @@ -8,11 +8,13 @@ detoxVersion=`node -p "require('${detoxRootPath}/package.json').version"` sha1=`(echo "${detoxVersion}" && xcodebuild -version) | shasum | awk '{print $1}' #"${2}"` detoxFrameworkDirPath="$HOME/Library/Detox/ios/${sha1}" +mkdir -p "${detoxFrameworkDirPath}" +rm -r "${detoxFrameworkDirPath}" detoxFrameworkPath="${detoxFrameworkDirPath}/Detox.framework" function prepareAndBuildFramework () { - if [ -d "$detoxRootPath"/ios ]; then + if [ -d "$detoxRootPath"/iosz ]; then detoxSourcePath="${detoxRootPath}"/ios echo "Dev mode, building from ${detoxSourcePath}" buildFramework "${detoxSourcePath}" diff --git a/detox/scripts/postinstall.js b/detox/scripts/postinstall.js index e448f0ce4a..83e1b65872 100755 --- a/detox/scripts/postinstall.js +++ b/detox/scripts/postinstall.js @@ -1,5 +1,8 @@ +const { setGradleVersionByRNVersion } = require('./updateGradle'); if (process.platform === 'darwin' && !process.env.DETOX_DISABLE_POSTINSTALL) { require('child_process').execFileSync(`${__dirname}/build_framework.ios.sh`, { stdio: 'inherit' }); + } +setGradleVersionByRNVersion(); diff --git a/detox/scripts/updateGradle.js b/detox/scripts/updateGradle.js new file mode 100644 index 0000000000..43542fc0e1 --- /dev/null +++ b/detox/scripts/updateGradle.js @@ -0,0 +1,47 @@ +const fs = require('fs'); +const path = require('path'); + +const rnMinor = require('../src/utils/rn-consts/rn-consts').rnVersion.minor; + +function getGradleVersionByRNVersion() { + switch (rnMinor) { + default: + return '8.3'; + case '72': + return '8.0'; + case '71': + return '7.6.1'; + } +} + +/** + * Update the Gradle wrapper to the version that matches the React Native version. + */ +function setGradleVersionByRNVersion() { + const gradleVersion = getGradleVersionByRNVersion(); + updateGradleWrapperSync(gradleVersion); +} + +/** + * Update the Gradle wrapper to the specified version. + * + * @param {string} newVersion - the new Gradle wrapper version + */ +function updateGradleWrapperSync(newVersion) { + const gradleWrapperPath = path.join(process.cwd(), 'android', 'gradle', 'wrapper', 'gradle-wrapper.properties'); + console.log(`Updating Gradle wrapper to version${newVersion}. File: ${gradleWrapperPath}`); + + try { + let data = fs.readFileSync(gradleWrapperPath, 'utf8'); + let updatedData = data.replace(/distributionUrl=.+\n/, `distributionUrl=https\\://services.gradle.org/distributions/gradle-${newVersion}-bin.zip\n`); + + fs.writeFileSync(gradleWrapperPath, updatedData, 'utf8'); + console.log(`Gradle wrapper updated successfully to version ${newVersion}.`); + } catch (err) { + console.error('Error:', err); + } +} + +module.exports = { + setGradleVersionByRNVersion +}; diff --git a/detox/src/DetoxWorker.js b/detox/src/DetoxWorker.js index 237ef37dae..7d3f69a32f 100644 --- a/detox/src/DetoxWorker.js +++ b/detox/src/DetoxWorker.js @@ -132,7 +132,7 @@ class DetoxWorker { }; this._artifactsManager = artifactsManagerFactory.createArtifactsManager(this._artifactsConfig, commonDeps); - this._deviceCookie = yield this._context[symbols.allocateDevice](); + this._deviceCookie = yield this._context[symbols.allocateDevice](this._deviceConfig); this.device = runtimeDeviceFactory.createRuntimeDevice( this._deviceCookie, diff --git a/detox/src/DetoxWorker.test.js b/detox/src/DetoxWorker.test.js index 4fe220fac4..9946e7d1a9 100644 --- a/detox/src/DetoxWorker.test.js +++ b/detox/src/DetoxWorker.test.js @@ -133,7 +133,9 @@ describe('DetoxWorker', () => { expect(envValidator.validate).toHaveBeenCalled()); it('should allocate a device', () => { - expect(detoxContext[symbols.allocateDevice]).toHaveBeenCalledWith(); + expect(detoxContext[symbols.allocateDevice]).toHaveBeenCalledWith(expect.objectContaining({ + type: 'fake.device', + })); }); it('should create a runtime-device based on the allocation result (cookie)', () => diff --git a/detox/src/android/AndroidExpect.test.js b/detox/src/android/AndroidExpect.test.js index 8e986dd9bf..667a931179 100644 --- a/detox/src/android/AndroidExpect.test.js +++ b/detox/src/android/AndroidExpect.test.js @@ -531,7 +531,7 @@ describe('AndroidExpect', () => { const script = 'function named(el) { return el.textContent; }'; await e.web.element(e.by.web.id('id')).runScript(function () {}); await e.web.element(e.by.web.className('className')).runScript((el) => el.textContent); - await e.web.element(e.by.web.cssSelector('cssSelector')).runScript(function named(...args) {}); + await e.web.element(e.by.web.cssSelector('cssSelector')).runScript(function named(..._args) {}); await e.web.element(e.by.web.name('name')).runScript(script); await e.web.element(e.by.web.xpath('xpath')).runScript(script); await e.web.element(e.by.web.href('linkText')).runScript(script); diff --git a/detox/src/android/actions/native.js b/detox/src/android/actions/native.js index 601bef562d..7b28efbed5 100644 --- a/detox/src/android/actions/native.js +++ b/detox/src/android/actions/native.js @@ -81,10 +81,10 @@ class ScrollAmountStopAtEdgeAction extends Action { } class ScrollEdgeAction extends Action { - constructor(edge) { + constructor(edge, startPositionX = -1, startPositionY = -1) { super(); - this._call = invoke.callDirectly(DetoxActionApi.scrollToEdge(edge)); + this._call = invoke.callDirectly(DetoxActionApi.scrollToEdge(edge, startPositionX, startPositionY)); } } diff --git a/detox/src/android/core/NativeElement.js b/detox/src/android/core/NativeElement.js index bcca79df83..d493a83e8c 100644 --- a/detox/src/android/core/NativeElement.js +++ b/detox/src/android/core/NativeElement.js @@ -93,12 +93,12 @@ class NativeElement { return await new ActionInteraction(this._invocationManager, this._matcher, action, traceDescription).execute(); } - async scrollTo(edge) { + async scrollTo(edge, startPositionX, startPositionY) { // override the user's element selection with an extended matcher that looks for UIScrollView children this._matcher = this._matcher._extendToDescendantScrollViews(); - const action = new actions.ScrollEdgeAction(edge); - const traceDescription = actionDescription.scrollTo(edge); + const action = new actions.ScrollEdgeAction(edge, startPositionX, startPositionY); + const traceDescription = actionDescription.scrollTo(edge, startPositionX, startPositionY); return await new ActionInteraction(this._invocationManager, this._matcher, action, traceDescription).execute(); } @@ -140,7 +140,6 @@ class NativeElement { } async takeScreenshot(screenshotName) { - // TODO this should be moved to a lower-layer handler of this use-case const action = new actions.TakeElementScreenshot(); const traceDescription = actionDescription.takeScreenshot(screenshotName); const resultBase64 = await new ActionInteraction(this._invocationManager, this._matcher, action, traceDescription).execute(); diff --git a/detox/src/android/core/WebElement.js b/detox/src/android/core/WebElement.js index deed912c3a..fd2d00bdf3 100644 --- a/detox/src/android/core/WebElement.js +++ b/detox/src/android/core/WebElement.js @@ -120,7 +120,7 @@ class WebViewElement { }); } - throw new DetoxRuntimeError(`element() argument is invalid, expected a web matcher, but got ${typeof element}`); + throw new DetoxRuntimeError(`element() argument is invalid, expected a web matcher, but got ${typeof webMatcher}`); } } diff --git a/detox/src/android/espressoapi/Detox.js b/detox/src/android/espressoapi/Detox.js index bbba04e3a2..dfa11192fa 100644 --- a/detox/src/android/espressoapi/Detox.js +++ b/detox/src/android/espressoapi/Detox.js @@ -58,17 +58,6 @@ class Detox { }; } - static extractInitialIntent() { - return { - target: { - type: "Class", - value: "com.wix.detox.Detox" - }, - method: "extractInitialIntent", - args: [] - }; - } - static getAppContext() { return { target: { diff --git a/detox/src/android/espressoapi/DetoxAction.js b/detox/src/android/espressoapi/DetoxAction.js index 6ed2d213f8..162a091640 100644 --- a/detox/src/android/espressoapi/DetoxAction.js +++ b/detox/src/android/espressoapi/DetoxAction.js @@ -68,8 +68,10 @@ class DetoxAction { }; } - static scrollToEdge(edge) { + static scrollToEdge(edge, startOffsetPercentX, startOffsetPercentY) { if (typeof edge !== "string") throw new Error("edge should be a string, but got " + (edge + (" (" + (typeof edge + ")")))); + if (typeof startOffsetPercentX !== "number") throw new Error("startOffsetPercentX should be a number, but got " + (startOffsetPercentX + (" (" + (typeof startOffsetPercentX + ")")))); + if (typeof startOffsetPercentY !== "number") throw new Error("startOffsetPercentY should be a number, but got " + (startOffsetPercentY + (" (" + (typeof startOffsetPercentY + ")")))); return { target: { type: "Class", @@ -79,6 +81,12 @@ class DetoxAction { args: [{ type: "Integer", value: sanitize_android_edge(edge) + }, { + type: "Double", + value: startOffsetPercentX + }, { + type: "Double", + value: startOffsetPercentY }] }; } @@ -208,7 +216,6 @@ class DetoxAction { } static adjustSliderToPosition(newPosition) { - if (typeof newPosition !== "number") throw new Error("newPosition should be a number, but got " + (newPosition + (" (" + (typeof newPosition + ")")))); return { target: { type: "Class", @@ -216,7 +223,7 @@ class DetoxAction { }, method: "adjustSliderToPosition", args: [{ - type: "Double", + type: "Float", value: newPosition }] }; diff --git a/detox/src/android/interactions/native.js b/detox/src/android/interactions/native.js index a7863c526b..d93e2b5530 100644 --- a/detox/src/android/interactions/native.js +++ b/detox/src/android/interactions/native.js @@ -28,7 +28,7 @@ class ActionInteraction extends Interaction { constructor(invocationManager, matcher, action, traceDescription) { super(invocationManager, traceDescription); this._call = EspressoDetoxApi.perform(matcher, action._call); - // TODO: move this.execute() here from the caller + // TODO [2024-12-01]: move this.execute() here from the caller } } @@ -39,7 +39,7 @@ class MatcherAssertionInteraction extends Interaction { matcher = notCondition ? matcher.not : matcher; this._call = DetoxAssertionApi.assertMatcher(call(element._call), matcher._call.value); - // TODO: move this.execute() here from the caller + // TODO [2024-12-01]: move this.execute() here from the caller } } diff --git a/detox/src/android/matchers/native.js b/detox/src/android/matchers/native.js index 2a3bef214b..d7beadc975 100644 --- a/detox/src/android/matchers/native.js +++ b/detox/src/android/matchers/native.js @@ -76,7 +76,7 @@ class ToggleMatcher extends NativeMatcher { } } -// TODO: Please be aware, that this is just a dummy matcher +// NOTE: Please be aware, that this is just a dummy matcher class TraitsMatcher extends NativeMatcher { constructor(value) { super(); diff --git a/detox/src/artifacts/providers/index.js b/detox/src/artifacts/providers/index.js index 3a8febcebc..98561e4874 100644 --- a/detox/src/artifacts/providers/index.js +++ b/detox/src/artifacts/providers/index.js @@ -1,5 +1,5 @@ class ArtifactPluginsProvider { - declareArtifactPlugins({ client }) {} // eslint-disable-line no-unused-vars + declareArtifactPlugins({ client }) {} // eslint-disable-line no-unused-vars,@typescript-eslint/no-unused-vars } class AndroidArtifactPluginsProvider extends ArtifactPluginsProvider { diff --git a/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js b/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js index 31f121c6ac..7770e21c5f 100644 --- a/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js +++ b/detox/src/artifacts/screenshot/SimulatorScreenshotPlugin.js @@ -1,7 +1,6 @@ const path = require('path'); const fs = require('../../utils/fsext'); -const log = require('../../utils/logger').child({ cat: 'artifacts-plugin,artifacts' }); const FileArtifact = require('../templates/artifact/FileArtifact'); const temporaryPath = require('../utils/temporaryPath'); diff --git a/detox/src/artifacts/templates/artifact/Artifact.js b/detox/src/artifacts/templates/artifact/Artifact.js index 2613bf6853..434e7f7b72 100644 --- a/detox/src/artifacts/templates/artifact/Artifact.js +++ b/detox/src/artifacts/templates/artifact/Artifact.js @@ -107,7 +107,7 @@ class Artifact { async doStop() {} - async doSave(artifactPath) {} // eslint-disable-line no-unused-vars + async doSave(_artifactPath) {} async doDiscard() {} } diff --git a/detox/src/artifacts/templates/artifact/Artifact.test.js b/detox/src/artifacts/templates/artifact/Artifact.test.js index dd5428bf10..2e425ff8f1 100644 --- a/detox/src/artifacts/templates/artifact/Artifact.test.js +++ b/detox/src/artifacts/templates/artifact/Artifact.test.js @@ -110,7 +110,6 @@ describe('Artifact', () => { it('should wait till .save() ends and .start() again', async () => { await artifact.start(1, 2, 3, 4); expect(artifact.doStart).toHaveBeenCalledTimes(2); - // TODO: assert the correct execution order }); }); @@ -137,7 +136,6 @@ describe('Artifact', () => { it('should wait till .discard() ends and .start() again', async () => { await artifact.start(1, 2, 3, 4); expect(artifact.doStart).toHaveBeenCalledTimes(2); - // TODO: assert the correct execution order }); }); diff --git a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js index f20d456016..031270984f 100644 --- a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js +++ b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.js @@ -51,6 +51,7 @@ class IosUIHierarchyPlugin extends ArtifactPlugin { if (this.enabled) { const scope = this.context.testSummary ? 'perTest' : 'perSession'; setUniqueProperty(this._artifacts[scope], name, artifact); + this.api.trackArtifact(artifact); } else { this._pendingDeletions.push(artifact.discard()); } @@ -97,6 +98,7 @@ class IosUIHierarchyPlugin extends ArtifactPlugin { .map(async ([key, artifact]) => { const destination = await this.api.preparePathForArtifact(`${key}.viewhierarchy`, testSummary); await artifact.save(destination); + this.api.untrackArtifact(artifact); }) .value(); diff --git a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js index 340422bb7a..06dcf0b4ea 100644 --- a/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js +++ b/detox/src/artifacts/uiHierarchy/IosUIHierarchyPlugin.test.js @@ -32,6 +32,8 @@ describe('IosUIHierarchyPlugin', () => { requestIdleCallback: jest.fn(async (callback) => { await callback(); }), + trackArtifact: jest.fn(), + untrackArtifact: jest.fn(), userConfig: { enabled: true, keepOnlyFailedTestsArtifacts: false, @@ -65,6 +67,8 @@ describe('IosUIHierarchyPlugin', () => { expect(session2.save).toHaveBeenCalledWith('artifacts/ui2.viewhierarchy'); expect(test1.save).toHaveBeenCalledWith('artifacts/test/ui.viewhierarchy'); expect(test2.save).toHaveBeenCalledWith('artifacts/test/ui2.viewhierarchy'); + expect(api.trackArtifact).toHaveBeenCalledTimes(4); + expect(api.untrackArtifact).toHaveBeenCalledTimes(4); }); it('should relocate existing artifacts before the app gets uninstalled', async () => { diff --git a/detox/src/client/AsyncWebSocket.js b/detox/src/client/AsyncWebSocket.js index d2d1a117b4..cc816a2fb3 100644 --- a/detox/src/client/AsyncWebSocket.js +++ b/detox/src/client/AsyncWebSocket.js @@ -134,7 +134,7 @@ class AsyncWebSocket { } } - // TODO: handle this leaked abstraction some day + // TODO [2024-12-01]: handle this leaked abstraction some day hasPendingActions() { return _.some(this.inFlightPromises, p => p.message.type !== 'currentStatus'); } @@ -168,7 +168,7 @@ class AsyncWebSocket { case WebSocket.CONNECTING: return 'opening'; case WebSocket.OPEN: return 'open'; /* istanbul ignore next */ - default: // TODO: [2021-12-01] throw new DetoxInternalError('...'); instead + default: return undefined; } } diff --git a/detox/src/client/Client.js b/detox/src/client/Client.js index c472544927..4fcc31139d 100644 --- a/detox/src/client/Client.js +++ b/detox/src/client/Client.js @@ -187,14 +187,14 @@ class Client { this._whenAppIsReady = new Deferred(); await this._whenAppIsConnected.promise; - // TODO: optimize traffic (!) - we can just listen for 'ready' event + // TODO [2024-12-01]: optimize traffic (!) - we can just listen for 'ready' event // if app always sends it upon load completion. On iOS it works, // but not on Android. Afterwards, this will suffice: // // await this._whenAppIsReady.promise; } - // TODO: move to else branch after the optimization + // TODO [2024-12-01]: move to else branch after the optimization โ†‘โ†‘ if (!this._whenAppIsReady.isResolved()) { this._whenAppIsReady = new Deferred(); await this.sendAction(new actions.Ready()); diff --git a/detox/src/client/__snapshots__/AsyncWebSocket.test.js.snap b/detox/src/client/__snapshots__/AsyncWebSocket.test.js.snap index 55939c975b..6c068d41c4 100644 --- a/detox/src/client/__snapshots__/AsyncWebSocket.test.js.snap +++ b/detox/src/client/__snapshots__/AsyncWebSocket.test.js.snap @@ -62,7 +62,7 @@ https://github.com/wix/Detox/issues `; exports[`AsyncWebSocket .send() when opened should fail if the message timeout has expired 1`] = ` -"The pending request #0 (\\"invoke\\") has been rejected due to the following error: +"The pending request #0 ("invoke") has been rejected due to the following error: The tester has not received a response within 5000ms timeout to the message: @@ -88,7 +88,7 @@ The payload was: `; exports[`AsyncWebSocket .send() when opened should reject all messages in the flight if there's an error 1`] = ` -"The pending request #0 (\\"invoke\\") has been rejected due to the following error: +"The pending request #0 ("invoke") has been rejected due to the following error: Failed to deliver the message to the Detox server: diff --git a/detox/src/client/__snapshots__/Client.test.js.snap b/detox/src/client/__snapshots__/Client.test.js.snap index 2a22a9b83c..5fca80866d 100644 --- a/detox/src/client/__snapshots__/Client.test.js.snap +++ b/detox/src/client/__snapshots__/Client.test.js.snap @@ -39,7 +39,7 @@ HINT: To print view hierarchy on failed actions/matches, use log-level verbose o exports[`Client .execute() should throw even if a non-error object is thrown 1`] = `"non-error"`; exports[`Client .execute() should throw on an unsupported result 1`] = ` -"Tried to invoke an action on app, got an unsupported response: {\\"type\\":\\"unsupportedResult\\",\\"params\\":{\\"foo\\":\\"bar\\"}} +"Tried to invoke an action on app, got an unsupported response: {"type":"unsupportedResult","params":{"foo":"bar"}} Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues" `; diff --git a/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap b/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap index 241ec7b1e1..4eb40e243f 100644 --- a/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap +++ b/detox/src/client/actions/formatters/__snapshots__/SyncStatusFormatter.test.js.snap @@ -1,92 +1,92 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Sync Status Formatter assertions should throw error when \`app_status\` is \`busy\` but \`busy_resources\` is empty 1`] = ` -"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {\\"app_status\\":\\"busy\\",\\"busy_resources\\":[]}. +"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {"app_status":"busy","busy_resources":[]}. With reasons: -โ€ข must NOT have additional properties in path \\"#/oneOf/0/additionalProperties\\" with params: {\\"additionalProperty\\":\\"busy_resources\\"} -โ€ข must NOT have fewer than 1 items in path \\"#/oneOf/1/properties/busy_resources/minItems\\" with params: {\\"limit\\":1} -โ€ข must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null} +โ€ข must NOT have additional properties in path "#/oneOf/0/additionalProperties" with params: {"additionalProperty":"busy_resources"} +โ€ข must NOT have fewer than 1 items in path "#/oneOf/1/properties/busy_resources/minItems" with params: {"limit":1} +โ€ข must match exactly one schema in oneOf in path "#/oneOf" with params: {"passingSchemas":null} Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues" `; exports[`Sync Status Formatter assertions should throw error when \`app_status\` is \`busy\` but \`busy_resources\` is missing 1`] = ` -"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {\\"app_status\\":\\"busy\\"}. +"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {"app_status":"busy"}. With reasons: -โ€ข must be equal to constant in path \\"#/oneOf/0/properties/app_status/const\\" with params: {\\"allowedValue\\":\\"idle\\"} -โ€ข must have required property 'busy_resources' in path \\"#/oneOf/1/required\\" with params: {\\"missingProperty\\":\\"busy_resources\\"} -โ€ข must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null} +โ€ข must be equal to constant in path "#/oneOf/0/properties/app_status/const" with params: {"allowedValue":"idle"} +โ€ข must have required property 'busy_resources' in path "#/oneOf/1/required" with params: {"missingProperty":"busy_resources"} +โ€ข must match exactly one schema in oneOf in path "#/oneOf" with params: {"passingSchemas":null} Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues" `; exports[`Sync Status Formatter assertions should throw error when \`app_status\` is invalid 1`] = ` -"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {\\"app_status\\":\\"foo\\"}. +"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {"app_status":"foo"}. With reasons: -โ€ข must be equal to constant in path \\"#/oneOf/0/properties/app_status/const\\" with params: {\\"allowedValue\\":\\"idle\\"} -โ€ข must have required property 'busy_resources' in path \\"#/oneOf/1/required\\" with params: {\\"missingProperty\\":\\"busy_resources\\"} -โ€ข must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null} +โ€ข must be equal to constant in path "#/oneOf/0/properties/app_status/const" with params: {"allowedValue":"idle"} +โ€ข must have required property 'busy_resources' in path "#/oneOf/1/required" with params: {"missingProperty":"busy_resources"} +โ€ข must match exactly one schema in oneOf in path "#/oneOf" with params: {"passingSchemas":null} Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues" `; exports[`Sync Status Formatter assertions should throw error when \`app_status\` is missing 1`] = ` -"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {\\"busy_resource\\":[]}. +"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {"busy_resource":[]}. With reasons: -โ€ข must have required property 'app_status' in path \\"#/oneOf/0/required\\" with params: {\\"missingProperty\\":\\"app_status\\"} -โ€ข must have required property 'app_status' in path \\"#/oneOf/1/required\\" with params: {\\"missingProperty\\":\\"app_status\\"} -โ€ข must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null} +โ€ข must have required property 'app_status' in path "#/oneOf/0/required" with params: {"missingProperty":"app_status"} +โ€ข must have required property 'app_status' in path "#/oneOf/1/required" with params: {"missingProperty":"app_status"} +โ€ข must match exactly one schema in oneOf in path "#/oneOf" with params: {"passingSchemas":null} Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues" `; exports[`Sync Status Formatter assertions should throw error when a busy resource is invalid 1`] = ` -"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {\\"app_status\\":\\"busy\\",\\"busy_resources\\":[{\\"name\\":\\"foo\\"}]}. +"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {"app_status":"busy","busy_resources":[{"name":"foo"}]}. With reasons: -โ€ข must NOT have additional properties in path \\"#/oneOf/0/additionalProperties\\" with params: {\\"additionalProperty\\":\\"busy_resources\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/0/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/1/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/2/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/3/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must be equal to constant in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/4/properties/name/const\\" with params: {\\"allowedValue\\":\\"timers\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/5/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/6/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/7/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/8/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/9/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must be equal to constant in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/10/properties/name/const\\" with params: {\\"allowedValue\\":\\"io\\"} -โ€ข must be equal to constant in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/11/properties/name/const\\" with params: {\\"allowedValue\\":\\"bridge\\"} -โ€ข must have required property 'description' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/12/required\\" with params: {\\"missingProperty\\":\\"description\\"} -โ€ข must match a schema in anyOf in path \\"#/oneOf/1/properties/busy_resources/items/anyOf\\" with params: {} -โ€ข must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null} +โ€ข must NOT have additional properties in path "#/oneOf/0/additionalProperties" with params: {"additionalProperty":"busy_resources"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/0/required" with params: {"missingProperty":"description"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/1/required" with params: {"missingProperty":"description"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/2/required" with params: {"missingProperty":"description"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/3/required" with params: {"missingProperty":"description"} +โ€ข must be equal to constant in path "#/oneOf/1/properties/busy_resources/items/anyOf/4/properties/name/const" with params: {"allowedValue":"timers"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/5/required" with params: {"missingProperty":"description"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/6/required" with params: {"missingProperty":"description"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/7/required" with params: {"missingProperty":"description"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/8/required" with params: {"missingProperty":"description"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/9/required" with params: {"missingProperty":"description"} +โ€ข must be equal to constant in path "#/oneOf/1/properties/busy_resources/items/anyOf/10/properties/name/const" with params: {"allowedValue":"io"} +โ€ข must be equal to constant in path "#/oneOf/1/properties/busy_resources/items/anyOf/11/properties/name/const" with params: {"allowedValue":"bridge"} +โ€ข must have required property 'description' in path "#/oneOf/1/properties/busy_resources/items/anyOf/12/required" with params: {"missingProperty":"description"} +โ€ข must match a schema in anyOf in path "#/oneOf/1/properties/busy_resources/items/anyOf" with params: {} +โ€ข must match exactly one schema in oneOf in path "#/oneOf" with params: {"passingSchemas":null} Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues" `; exports[`Sync Status Formatter assertions should throw error when resource \`name\` is missing 1`] = ` -"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {\\"app_status\\":\\"busy\\",\\"busy_resources\\":[{\\"description\\":{\\"foo\\":\\"bar\\"}}]}. +"Given sync status is not compatible with the status schema (\`SyncStatusSchema.js\`), given status: {"app_status":"busy","busy_resources":[{"description":{"foo":"bar"}}]}. With reasons: -โ€ข must NOT have additional properties in path \\"#/oneOf/0/additionalProperties\\" with params: {\\"additionalProperty\\":\\"busy_resources\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/0/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/1/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/2/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/3/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/4/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/5/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/6/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/7/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/8/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/9/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/10/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/11/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must have required property 'name' in path \\"#/oneOf/1/properties/busy_resources/items/anyOf/12/required\\" with params: {\\"missingProperty\\":\\"name\\"} -โ€ข must match a schema in anyOf in path \\"#/oneOf/1/properties/busy_resources/items/anyOf\\" with params: {} -โ€ข must match exactly one schema in oneOf in path \\"#/oneOf\\" with params: {\\"passingSchemas\\":null} +โ€ข must NOT have additional properties in path "#/oneOf/0/additionalProperties" with params: {"additionalProperty":"busy_resources"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/0/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/1/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/2/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/3/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/4/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/5/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/6/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/7/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/8/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/9/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/10/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/11/required" with params: {"missingProperty":"name"} +โ€ข must have required property 'name' in path "#/oneOf/1/properties/busy_resources/items/anyOf/12/required" with params: {"missingProperty":"name"} +โ€ข must match a schema in anyOf in path "#/oneOf/1/properties/busy_resources/items/anyOf" with params: {} +โ€ข must match exactly one schema in oneOf in path "#/oneOf" with params: {"passingSchemas":null} Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues" @@ -109,7 +109,7 @@ exports[`Sync Status Formatter busy status should format "delayed_perform_select exports[`Sync Status Formatter busy status should format "dispatch_queue" correctly 1`] = ` "The app is busy with the following tasks: -โ€ข There are 123 work items pending on the dispatch queue: \\"foo\\"." +โ€ข There are 123 work items pending on the dispatch queue: "foo"." `; exports[`Sync Status Formatter busy status should format "io" correctly 1`] = ` @@ -149,17 +149,17 @@ exports[`Sync Status Formatter busy status should format "network" correctly 1`] exports[`Sync Status Formatter busy status should format "one_time_events" correctly 1`] = ` "The app is busy with the following tasks: -โ€ข The event \\"foo\\" is taking place with object: \\"bar\\"." +โ€ข The event "foo" is taking place with object: "bar"." `; exports[`Sync Status Formatter busy status should format "one_time_events" correctly when there is no object 1`] = ` "The app is busy with the following tasks: -โ€ข The event \\"foo\\" is taking place." +โ€ข The event "foo" is taking place." `; exports[`Sync Status Formatter busy status should format "run_loop" correctly 1`] = ` "The app is busy with the following tasks: -โ€ข Run loop \\"foo\\" is awake." +โ€ข Run loop "foo" is awake." `; exports[`Sync Status Formatter busy status should format "timers" correctly when there are timers in description 1`] = ` @@ -209,7 +209,7 @@ exports[`Sync Status Formatter busy status should format "ui" correctly #3 1`] = exports[`Sync Status Formatter busy status should format "unknown" correctly 1`] = ` "The app is busy with the following tasks: -โ€ข Resource \\"foo.bar#baz\\" is busy." +โ€ข Resource "foo.bar#baz" is busy." `; exports[`Sync Status Formatter should format idle status correctly 1`] = `"The app seems to be idle"`; diff --git a/detox/src/configuration/__mocks__/configuration/cjs/.detoxrc.cjs b/detox/src/configuration/__mocks__/configuration/cjs/.detoxrc.cjs new file mode 100644 index 0000000000..0580f07c04 --- /dev/null +++ b/detox/src/configuration/__mocks__/configuration/cjs/.detoxrc.cjs @@ -0,0 +1,11 @@ +module.exports = { + configurations: { + simple: { + device: { + type: "android.attached", + device: "Hello from .detoxrc", + }, + apps: [], + }, + }, +}; diff --git a/detox/src/configuration/composeRunnerConfig.js b/detox/src/configuration/composeRunnerConfig.js index a367467521..2c3394e81f 100644 --- a/detox/src/configuration/composeRunnerConfig.js +++ b/detox/src/configuration/composeRunnerConfig.js @@ -32,6 +32,7 @@ function composeRunnerConfig(opts) { retries: 0, inspectBrk: inspectBrkHookDefault, forwardEnv: false, + detached: false, bail: false, jest: { setupTimeout: 300000, @@ -56,8 +57,9 @@ function composeRunnerConfig(opts) { if (typeof merged.inspectBrk === 'function') { if (cliConfig.inspectBrk) { - merged.retries = 0; + merged.detached = false; merged.forwardEnv = true; + merged.retries = 0; merged.inspectBrk(merged); } diff --git a/detox/src/configuration/composeRunnerConfig.test.js b/detox/src/configuration/composeRunnerConfig.test.js index 958fe04aa8..84bfc54503 100644 --- a/detox/src/configuration/composeRunnerConfig.test.js +++ b/detox/src/configuration/composeRunnerConfig.test.js @@ -46,6 +46,7 @@ describe('composeRunnerConfig', () => { }, retries: 0, bail: false, + detached: false, forwardEnv: false, }); }); @@ -60,6 +61,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }; @@ -77,6 +79,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }); }); @@ -92,6 +95,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }; @@ -109,6 +113,7 @@ describe('composeRunnerConfig', () => { }, bail: true, retries: 1, + detached: true, forwardEnv: true, }); }); @@ -222,6 +227,7 @@ describe('composeRunnerConfig', () => { reportSpecs: true, }, bail: true, + detached: true, retries: 1, }; @@ -236,6 +242,7 @@ describe('composeRunnerConfig', () => { reportSpecs: false, }, bail: false, + detached: false, retries: 3, }; @@ -256,6 +263,7 @@ describe('composeRunnerConfig', () => { reportWorkerAssign: true, }, bail: false, + detached: false, retries: 3, forwardEnv: false, }); diff --git a/detox/src/configuration/loadExternalConfig.js b/detox/src/configuration/loadExternalConfig.js index ab0fdc0405..ccf0bc9656 100644 --- a/detox/src/configuration/loadExternalConfig.js +++ b/detox/src/configuration/loadExternalConfig.js @@ -10,9 +10,11 @@ const log = require('../utils/logger').child({ cat: 'config' }); async function locateExternalConfig(cwd) { return findUp([ + '.detoxrc.cjs', '.detoxrc.js', '.detoxrc.json', '.detoxrc', + 'detox.config.cjs', 'detox.config.js', 'detox.config.json', 'package.json', @@ -20,7 +22,7 @@ async function locateExternalConfig(cwd) { } async function loadConfig(configPath) { - let config = path.extname(configPath) === '.js' + let config = isJS(path.extname(configPath)) ? require(configPath) : JSON.parse(await fs.readFile(configPath, 'utf8')); @@ -34,6 +36,10 @@ async function loadConfig(configPath) { }; } +function isJS(ext) { + return ext === '.js' || ext === '.cjs'; +} + async function resolveConfigPath(configPath, cwd) { if (!configPath) { return locateExternalConfig(cwd); diff --git a/detox/src/configuration/loadExternalConfig.test.js b/detox/src/configuration/loadExternalConfig.test.js index b7a7a81622..2b31f4800b 100644 --- a/detox/src/configuration/loadExternalConfig.test.js +++ b/detox/src/configuration/loadExternalConfig.test.js @@ -2,6 +2,7 @@ const os = require('os'); const path = require('path'); describe('loadExternalConfig', () => { + const DIR_CJS = path.join(__dirname, '__mocks__/configuration/cjs'); const DIR_PACKAGEJSON = path.join(__dirname, '__mocks__/configuration/packagejson'); const DIR_PRIORITY = path.join(__dirname, '__mocks__/configuration/priority'); const DIR_EXTENDS = path.join(__dirname, '__mocks__/configuration/extends'); @@ -35,6 +36,13 @@ describe('loadExternalConfig', () => { expect(logger.warn).not.toHaveBeenCalled(); }); + it('should implicitly use .detoxrc.cjs', async () => { + const { filepath, config } = await loadExternalConfig({ cwd: DIR_CJS }); + expect(filepath).toBe(path.join(DIR_CJS, '.detoxrc.cjs')); + expect(config).toMatchObject({ configurations: expect.anything() }); + expect(logger.warn).not.toHaveBeenCalled(); + }); + it('should implicitly use package.json, even if there is no .detoxrc', async () => { const { filepath, config } = await loadExternalConfig({ cwd: DIR_PACKAGEJSON }); diff --git a/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js b/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js index 552eb2a5f1..466f91ceee 100644 --- a/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/attached/AttachedAndroidAllocDriver.js @@ -41,7 +41,6 @@ class AttachedAndroidAllocDriver { async postAllocate(deviceCookie) { const { adbName } = deviceCookie; - // TODO Also disable native animations? await this._adb.apiLevel(adbName); await this._adb.unlockScreen(adbName); } diff --git a/detox/src/devices/allocation/drivers/android/emulator/AVDValidator.test.js b/detox/src/devices/allocation/drivers/android/emulator/AVDValidator.test.js index 127bce7d66..d8f07946c9 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/AVDValidator.test.js +++ b/detox/src/devices/allocation/drivers/android/emulator/AVDValidator.test.js @@ -54,7 +54,7 @@ describe('AVD validator', () => { try { await uut.validate(); - fail('expected to throw'); + throw 'expected to throw'; } catch (err) { const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError'); expect(err).toBeInstanceOf(DetoxRuntimeError); @@ -68,7 +68,7 @@ describe('AVD validator', () => { try { await uut.validate('mock-avd-name'); - fail('expected to throw'); + throw 'expected to throw'; } catch (err) { const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError'); expect(err).toBeInstanceOf(DetoxRuntimeError); diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js index 4c215d99cc..2d657d966b 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorAllocDriver.js @@ -5,7 +5,6 @@ const _ = require('lodash'); -const Deferred = require('../../../../../utils/Deferred'); const log = require('../../../../../utils/logger').child({ cat: 'device,device-allocation' }); const { patchAvdSkinConfig } = require('./patchAvdSkinConfig'); diff --git a/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js b/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js index 9bc9335ac3..8d06cc2cc3 100644 --- a/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js +++ b/detox/src/devices/allocation/drivers/android/emulator/EmulatorVersionResolver.js @@ -7,7 +7,7 @@ class EmulatorVersionResolver { this.version = undefined; } - async resolve(isHeadless = false) { // TODO Make isHeadless a config arg (i.e. through c'tor)? + async resolve(isHeadless = false) { if (!this.version) { this.version = await this._resolve(isHeadless); } diff --git a/detox/src/devices/allocation/drivers/android/genycloud/GenyInstanceLauncher.test.js b/detox/src/devices/allocation/drivers/android/genycloud/GenyInstanceLauncher.test.js index 7248045c6f..62942400aa 100644 --- a/detox/src/devices/allocation/drivers/android/genycloud/GenyInstanceLauncher.test.js +++ b/detox/src/devices/allocation/drivers/android/genycloud/GenyInstanceLauncher.test.js @@ -33,11 +33,9 @@ describe('Genymotion-Cloud instance launcher', () => { let instanceLifecycleService; let uut; let retry; - let logger; beforeEach(() => { jest.mock('../../../../../utils/logger'); - logger = jest.requireMock('../../../../../utils/logger'); jest.mock('../../../../../utils/retry'); retry = jest.requireMock('../../../../../utils/retry'); diff --git a/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js b/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js index 7d7626cfb2..8af89b600d 100644 --- a/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js +++ b/detox/src/devices/allocation/drivers/ios/SimulatorAllocDriver.js @@ -39,7 +39,6 @@ class SimulatorAllocDriver { async allocate(deviceConfig) { const deviceQuery = new SimulatorQuery(deviceConfig.device); - // TODO Delegate this onto a well tested allocator class const udid = await this._deviceRegistry.registerDevice(async () => { return await this._findOrCreateDevice(deviceQuery); }); diff --git a/detox/src/devices/allocation/factories/android.js b/detox/src/devices/allocation/factories/android.js index 3eefb21ae8..d352bd1f45 100644 --- a/detox/src/devices/allocation/factories/android.js +++ b/detox/src/devices/allocation/factories/android.js @@ -42,7 +42,7 @@ class AndroidEmulator extends DeviceAllocatorFactory { } class AndroidAttached extends DeviceAllocatorFactory { - _createDriver({ detoxSession, detoxConfig }) { + _createDriver({ detoxSession }) { const serviceLocator = require('../../servicelocator/android'); const adb = serviceLocator.adb; const DeviceRegistry = require('../../allocation/DeviceRegistry'); diff --git a/detox/src/devices/allocation/factories/ios.js b/detox/src/devices/allocation/factories/ios.js index f41257c8dc..af141fc339 100644 --- a/detox/src/devices/allocation/factories/ios.js +++ b/detox/src/devices/allocation/factories/ios.js @@ -2,7 +2,7 @@ const DeviceAllocatorFactory = require('./base'); class IosSimulator extends DeviceAllocatorFactory { - _createDriver({ detoxConfig, detoxSession, eventEmitter }) { + _createDriver({ detoxConfig, detoxSession }) { const AppleSimUtils = require('../../../devices/common/drivers/ios/tools/AppleSimUtils'); const applesimutils = new AppleSimUtils(); diff --git a/detox/src/devices/common/drivers/android/exec/ADB.js b/detox/src/devices/common/drivers/android/exec/ADB.js index 445c054107..3600972fac 100644 --- a/detox/src/devices/common/drivers/android/exec/ADB.js +++ b/detox/src/devices/common/drivers/android/exec/ADB.js @@ -8,7 +8,7 @@ const { escape } = require('../../../../../utils/pipeCommands'); const DeviceHandle = require('../tools/DeviceHandle'); const EmulatorHandle = require('../tools/EmulatorHandle'); -const INSTALL_TIMEOUT = 45000; // TODO Double check 45s makes sense +const INSTALL_TIMEOUT = 45000; class ADB { constructor() { @@ -345,7 +345,6 @@ class ADB { return this.adbCmd(deviceId, `emu kill`); } - // TODO refactor the whole thing so as to make usage of BinaryExec -- similar to EmulatorExec async adbCmd(deviceId, params, options = {}) { const serial = `${deviceId ? `-s ${deviceId}` : ''}`; const cmd = `"${this.adbBin}" ${serial} ${params}`; diff --git a/detox/src/devices/common/drivers/android/exec/__snapshots__/AAPT.test.js.snap b/detox/src/devices/common/drivers/android/exec/__snapshots__/AAPT.test.js.snap index e3affe010d..c464e8818c 100644 --- a/detox/src/devices/common/drivers/android/exec/__snapshots__/AAPT.test.js.snap +++ b/detox/src/devices/common/drivers/android/exec/__snapshots__/AAPT.test.js.snap @@ -1,5 +1,5 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`AAPT Checking whether APK holds instrumentation testing should execute the AAPT command with proper args 1`] = `"\\"escaped(mockSdk/build-tools/30.0.0/aapt)\\" dump xmlstrings \\"escaped(path/to/app.apk)\\" AndroidManifest.xml"`; +exports[`AAPT Checking whether APK holds instrumentation testing should execute the AAPT command with proper args 1`] = `""escaped(mockSdk/build-tools/30.0.0/aapt)" dump xmlstrings "escaped(path/to/app.apk)" AndroidManifest.xml"`; -exports[`AAPT Reading package name should execute the AAPT command with proper args 1`] = `"\\"escaped(mockSdk/build-tools/30.0.0/aapt)\\" dump badging \\"escaped(path/to/app.apk)\\""`; +exports[`AAPT Reading package name should execute the AAPT command with proper args 1`] = `""escaped(mockSdk/build-tools/30.0.0/aapt)" dump badging "escaped(path/to/app.apk)""`; diff --git a/detox/src/devices/common/drivers/android/tools/ApkValidator.js b/detox/src/devices/common/drivers/android/tools/ApkValidator.js index b6fcb8a3ae..3fe5c659bc 100644 --- a/detox/src/devices/common/drivers/android/tools/ApkValidator.js +++ b/detox/src/devices/common/drivers/android/tools/ApkValidator.js @@ -1,6 +1,6 @@ const DetoxRuntimeError = require('../../../../../errors/DetoxRuntimeError'); -const setupGuideHint = 'For further assistance, visit the Android setup guide: https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md'; +const setupGuideHint = 'For further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup'; class ApkValidator { constructor(aapt) { diff --git a/detox/src/devices/common/drivers/android/tools/ApkValidator.test.js b/detox/src/devices/common/drivers/android/tools/ApkValidator.test.js index b93deece43..4f4c92d629 100644 --- a/detox/src/devices/common/drivers/android/tools/ApkValidator.test.js +++ b/detox/src/devices/common/drivers/android/tools/ApkValidator.test.js @@ -1,3 +1,4 @@ + describe('APK validation', () => { const binaryPath = 'mock-bin-path'; diff --git a/detox/src/devices/common/drivers/android/tools/AppInstallHelper.js b/detox/src/devices/common/drivers/android/tools/AppInstallHelper.js index c21dfafa00..ba621d322d 100644 --- a/detox/src/devices/common/drivers/android/tools/AppInstallHelper.js +++ b/detox/src/devices/common/drivers/android/tools/AppInstallHelper.js @@ -1,5 +1,3 @@ -// TODO Tweak such that if apk's already exist on the device (need to store uniquely), they will not be resent (would optimize cloud, for example) - class AppInstallHelper { constructor(adb, fileTransfer) { this._adb = adb; diff --git a/detox/src/devices/common/drivers/android/tools/AppInstallHelper.test.js b/detox/src/devices/common/drivers/android/tools/AppInstallHelper.test.js index aedb4586a7..1bb199a4c6 100644 --- a/detox/src/devices/common/drivers/android/tools/AppInstallHelper.test.js +++ b/detox/src/devices/common/drivers/android/tools/AppInstallHelper.test.js @@ -28,10 +28,7 @@ describe('Android app installation helper', () => { it('should throw if transient dir prep fails', async () => { fileTransfer.prepareDestinationDir.mockRejectedValue(new Error('mocked error in adb-shell')); - try { - await uut.install(deviceId, appBinaryPath, testBinaryPath); - fail('expected to throw'); - } catch (err) {} + await expect(uut.install(deviceId, appBinaryPath, testBinaryPath)).rejects.toThrow(); }); it('should push app-binary file to the device', async () => { @@ -47,10 +44,7 @@ describe('Android app installation helper', () => { it('should break if file push fails', async () => { fileTransfer.send.mockRejectedValue(new Error('mocked error in adb-push')); - try { - await uut.install(deviceId, appBinaryPath, testBinaryPath); - fail('expected to throw'); - } catch(err) {} + await expect(uut.install(deviceId, appBinaryPath, testBinaryPath)).rejects.toThrow(); }); it('should remote-install both binaries via shell', async () => { @@ -66,10 +60,7 @@ describe('Android app installation helper', () => { it('should break if remote-install fails', async () => { adb.remoteInstall.mockRejectedValue(new Error('mocked error in remote-install')); - try { - await uut.install(deviceId, appBinaryPath, testBinaryPath); - fail('expected to throw'); - } catch(err) {} + await expect(uut.install(deviceId, appBinaryPath, testBinaryPath)).rejects.toThrow(); }); it('should allow for an install with no test binary', async () => { diff --git a/detox/src/devices/common/drivers/android/tools/Instrumentation.test.js b/detox/src/devices/common/drivers/android/tools/Instrumentation.test.js index fcfacd6ca1..30f9bc72d4 100644 --- a/detox/src/devices/common/drivers/android/tools/Instrumentation.test.js +++ b/detox/src/devices/common/drivers/android/tools/Instrumentation.test.js @@ -131,11 +131,7 @@ describe('Instrumentation', () => { childProcessUtils.interruptProcess.mockRejectedValue(new Error()); await uut.launch(deviceId, bundleId, []); - - try { - await invokeTerminationCallback(); - fail(); - } catch(error) {} + await expect(invokeTerminationCallback()).rejects.toThrowError(); }); it('should not terminate if dispatched twice', async () => { @@ -162,11 +158,7 @@ describe('Instrumentation', () => { it('should break if process interruption fails', async () => { childProcessUtils.interruptProcess.mockRejectedValue(new Error()); await uut.launch(deviceId, bundleId, []); - - try { - await uut.terminate(); - fail(); - } catch(error) {} + await expect(uut.terminate()).rejects.toThrowError(); }); it('should not terminate if not running', async () => { diff --git a/detox/src/devices/common/drivers/android/tools/MonitoredInstrumentation.test.js b/detox/src/devices/common/drivers/android/tools/MonitoredInstrumentation.test.js index a718cc559a..dfbab5790b 100644 --- a/detox/src/devices/common/drivers/android/tools/MonitoredInstrumentation.test.js +++ b/detox/src/devices/common/drivers/android/tools/MonitoredInstrumentation.test.js @@ -52,10 +52,7 @@ describe('Monitored instrumentation', () => { it('should break if underlying launch fails', async () => { instrumentationObj().launch.mockRejectedValue(new Error()); - try { - await uut.launch(deviceId, bundleId, {}); - fail(); - } catch (e) {} + await expect(uut.launch(deviceId, bundleId, {})).rejects.toThrowError(); }); }); @@ -81,10 +78,7 @@ describe('Monitored instrumentation', () => { await uut.launch(deviceId, bundleId, {}); - try { - await uut.terminate(); - fail(); - } catch (e) {} + await expect(uut.terminate()).rejects.toThrowError(); }); it('should allow for termination without launch', async () => { diff --git a/detox/src/devices/common/drivers/android/tools/__snapshots__/ApkValidator.test.js.snap b/detox/src/devices/common/drivers/android/tools/__snapshots__/ApkValidator.test.js.snap index 215dde3dfc..35b07b32d1 100644 --- a/detox/src/devices/common/drivers/android/tools/__snapshots__/ApkValidator.test.js.snap +++ b/detox/src/devices/common/drivers/android/tools/__snapshots__/ApkValidator.test.js.snap @@ -4,7 +4,7 @@ exports[`APK validation App APK validation should throw a descriptive error if a "App APK at path mock-bin-path was detected as the *test* APK! HINT: Your binary path was probably wrongly set in the active Detox configuration. -For further assistance, visit the Android setup guide: https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md" +For further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup" `; exports[`APK validation App APK validation should throw a specific error if AAPT throws 1`] = ` @@ -12,14 +12,14 @@ exports[`APK validation App APK validation should throw a specific error if AAPT Error: mock error HINT: Check that the binary path in the active Detox configuration has been set to a path of an APK file. -For further assistance, visit the Android setup guide: https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md" +For further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup" `; exports[`APK validation Test APK validation should throw a descriptive error if APK happens to be an app APK 1`] = ` "Test APK at path mock-test-bin-path was detected as the *app* APK! HINT: Your test test-binary path was probably wrongly set in the active Detox configuration. -For further assistance, visit the Android setup guide: https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md" +For further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup" `; exports[`APK validation Test APK validation should throw a specific error if AAPT throws 1`] = ` @@ -27,5 +27,5 @@ exports[`APK validation Test APK validation should throw a specific error if AAP Error: mock error HINT: Check that the test-binary path in the active Detox configuration has been set to a path of an APK file. -For further assistance, visit the Android setup guide: https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md" +For further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup" `; diff --git a/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js b/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js index 06ce77e488..3a8b7b86d1 100644 --- a/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js +++ b/detox/src/devices/common/drivers/ios/tools/AppleSimUtils.js @@ -10,21 +10,123 @@ const environment = require('../../../../../utils/environment'); const log = require('../../../../../utils/logger').child({ cat: 'device' }); const { quote } = require('../../../../../utils/shellQuote'); +const PERMISSIONS_VALUES = { + YES: 'YES', + NO: 'NO', + UNSET: 'unset', + LIMITED: 'limited', +}; + +const SIMCTL_SET_PERMISSION_ACTIONS ={ + GRANT: 'grant', + REVOKE: 'revoke', + RESET: 'reset', +}; + class AppleSimUtils { async setPermissions(udid, bundleId, permissionsObj) { - let permissions = []; - _.forEach(permissionsObj, function (shouldAllow, permission) { - permissions.push(permission + '=' + shouldAllow); - }); + for (const [service, value] of Object.entries(permissionsObj)) { + switch (service) { + case 'location': + switch (value) { + case 'always': + await this.setPermissionWithSimctl(udid, bundleId, SIMCTL_SET_PERMISSION_ACTIONS.GRANT, 'location-always'); + break; + + case 'inuse': + await this.setPermissionWithSimctl(udid, bundleId, SIMCTL_SET_PERMISSION_ACTIONS.GRANT, 'location'); + break; + + case 'never': + await this.setPermissionWithSimctl(udid, bundleId, SIMCTL_SET_PERMISSION_ACTIONS.REVOKE, 'location'); + break; + + case 'unset': + await this.setPermissionWithSimctl(udid, bundleId, SIMCTL_SET_PERMISSION_ACTIONS.RESET, 'location'); + break; + } + + break; + + case 'contacts': + if (value === PERMISSIONS_VALUES.LIMITED) { + await this.setPermissionWithSimctl(udid, bundleId, SIMCTL_SET_PERMISSION_ACTIONS.GRANT, 'contacts-limited'); + } else { + await this.setPermissionWithAppleSimUtils(udid, bundleId, service, value); + } + break; + + case 'photos': + if (value === PERMISSIONS_VALUES.LIMITED) { + await this.setPermissionWithSimctl(udid, bundleId, SIMCTL_SET_PERMISSION_ACTIONS.GRANT, 'photos-add'); + } else { + await this.setPermissionWithAppleSimUtils(udid, bundleId, service, value); + } + break; + + // eslint-disable-next-line no-fallthrough + case 'calendar': + case 'camera': + case 'medialibrary': + case 'microphone': + case 'motion': + case 'reminders': + case 'siri': + // Simctl uses kebab-case for service names. + const simctlService = service.replace('medialibrary', 'media-library'); + await this.setPermissionWithSimctl(udid, bundleId, this.basicPermissionValueToSimctlAction(value), simctlService); + break; + + // Requires AppleSimUtils: unsupported by latest Simctl at the moment of writing this code. + // eslint-disable-next-line no-fallthrough + case 'notifications': + case 'health': + case 'homekit': + case 'speech': + case 'faceid': + case 'userTracking': + await this.setPermissionWithAppleSimUtils(udid, bundleId, service, value); + break; + } + } + } + + basicPermissionValueToSimctlAction(value) { + switch (value) { + case PERMISSIONS_VALUES.YES: + return SIMCTL_SET_PERMISSION_ACTIONS.GRANT; + + case PERMISSIONS_VALUES.NO: + return SIMCTL_SET_PERMISSION_ACTIONS.REVOKE; + + case PERMISSIONS_VALUES.UNSET: + return SIMCTL_SET_PERMISSION_ACTIONS.RESET; + } + } + async setPermissionWithSimctl(udid, bundleId, action, service) { const options = { - args: `--byId ${udid} --bundle ${bundleId} --restartSB --setPermissions ${_.join(permissions, ',')}`, + cmd: `privacy ${udid} ${action} ${service} ${bundleId}`, statusLogs: { - trying: `Trying to set permissions...`, - successful: 'Permissions are set' + trying: `Trying to set permissions with Simctl: ${action} ${service}...`, + successful: `${service} permissions are set` }, retries: 1, }; + + await this._execSimctl(options); + } + + async setPermissionWithAppleSimUtils(udid, bundleId, service, value) { + const options = { + args: `--byId ${udid} --bundle ${bundleId} --restartSB --setPermissions ${service}=${value}`, + statusLogs: { + trying: `Trying to set permissions with AppleSimUtils: ${service}=${value}...`, + successful: `${service} permissions are set` + }, + retries: 1, + }; + await this._execAppleSimUtils(options); } @@ -33,6 +135,7 @@ class AppleSimUtils { args: `--list ${joinArgs(query)}`, retries: 1, statusLogs: listOptions.trying ? { trying: listOptions.trying } : undefined, + maxBuffer: 4 * 1024 * 1024, }; const response = await this._execAppleSimUtils(options); const parsed = this._parseResponseFromAppleSimUtils(response); @@ -318,14 +421,7 @@ class AppleSimUtils { } async setLocation(udid, lat, lon) { - const result = await childProcess.execWithRetriesAndLogs(`which fbsimctl`, { retries: 1 }); - if (_.get(result, 'stdout')) { - await childProcess.execWithRetriesAndLogs(`fbsimctl ${udid} set_location ${lat} ${lon}`, { retries: 1 }); - } else { - throw new DetoxRuntimeError(`setLocation currently supported only through fbsimctl. - Install fbsimctl using: - "brew tap facebook/fb && export CODE_SIGNING_REQUIRED=NO && brew install fbsimctl"`); - } + await this._execSimctl({ cmd: `location ${udid} set ${lat},${lon}` }); } async resetContentAndSettings(udid) { diff --git a/detox/src/devices/runtime/RuntimeDevice.test.js b/detox/src/devices/runtime/RuntimeDevice.test.js index c6a7438345..f32bcb5772 100644 --- a/detox/src/devices/runtime/RuntimeDevice.test.js +++ b/detox/src/devices/runtime/RuntimeDevice.test.js @@ -402,12 +402,7 @@ describe('Device', () => { it(`(relaunch) with url and userNofitication should throw`, async () => { const device = await aValidDevice(); - try { - await device.relaunchApp({ url: 'scheme://some.url', userNotification: 'notif' }); - fail('should fail'); - } catch (ex) { - expect(ex).toBeDefined(); - } + await expect(device.relaunchApp({ url: 'scheme://some.url', userNotification: 'notif' })).rejects.toThrowError(); }); it(`(relaunch) with permissions should send trigger setpermissions before app starts`, async () => { @@ -537,13 +532,7 @@ describe('Device', () => { const device = await aValidDevice(); - try { - await device.launchApp(launchParams); - fail('should throw'); - } catch (ex) { - expect(ex).toBeDefined(); - } - + await expect(device.launchApp(launchParams)).rejects.toThrowError(); expect(device.deviceDriver.deliverPayload).not.toHaveBeenCalled(); }); @@ -810,12 +799,7 @@ describe('Device', () => { it(`openURL(notAnObject) should pass to device driver`, async () => { const device = await aValidDevice(); - try { - await device.openURL('url'); - fail('should throw'); - } catch (ex) { - expect(ex).toBeDefined(); - } + await expect(device.openURL('url')).rejects.toThrowError(); }); it(`reloadReactNative() should pass to device driver`, async () => { diff --git a/detox/src/devices/runtime/drivers/android/AndroidDriver.js b/detox/src/devices/runtime/drivers/android/AndroidDriver.js index 8ff4d523cf..7f0f2cbc30 100644 --- a/detox/src/devices/runtime/drivers/android/AndroidDriver.js +++ b/detox/src/devices/runtime/drivers/android/AndroidDriver.js @@ -280,7 +280,7 @@ class AndroidDriver extends DeviceDriverBase { throw new DetoxRuntimeError({ message: `The test APK could not be found at path: '${testApkPath}'`, hint: 'Try running the detox build command, and make sure it was configured to execute a build command (e.g. \'./gradlew assembleAndroidTest\')' + - '\nFor further assistance, visit the Android setup guide: https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md', + '\nFor further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup', }); } return testApkPath; diff --git a/detox/src/devices/runtime/drivers/android/AndroidDriver.test.js b/detox/src/devices/runtime/drivers/android/AndroidDriver.test.js index cccb788cae..1def20be00 100644 --- a/detox/src/devices/runtime/drivers/android/AndroidDriver.test.js +++ b/detox/src/devices/runtime/drivers/android/AndroidDriver.test.js @@ -8,7 +8,7 @@ describe('Android driver', () => { const mockNotificationDataTargetPath = '/ondevice/path/to/notification.json'; let logger; - let fs; // TODO don't mock + let fs; // on the next rewrite, do your best not to mock fs let client; let getAbsoluteBinaryPath; let eventEmitter; @@ -21,7 +21,6 @@ describe('Android driver', () => { let appInstallHelper; let appUninstallHelper; let instrumentation; - let DeviceRegistryClass; let uut; beforeEach(() => { @@ -53,12 +52,9 @@ describe('Android driver', () => { }); it('should break if instrumentation launch fails', async () => { - instrumentation.launch.mockRejectedValue(new Error()); + instrumentation.launch.mockRejectedValue(new Error('Simulated failure')); - try { - await uut.launchApp(bundleId, {}, ''); - fail(); - } catch (e) {} + await expect(uut.launchApp(bundleId, {}, '')).rejects.toThrowError('Simulated failure'); }); it('should set a termination callback function', async () => { @@ -354,12 +350,8 @@ describe('Android driver', () => { instrumentation.waitForCrash.mockRejectedValue(new Error()); await uut.launchApp(bundleId, {}, ''); - try { - await uut.waitUntilReady(); - fail(); - } catch (e) { - expect(instrumentation.abortWaitForCrash).toHaveBeenCalled(); - } + await expect(uut.waitUntilReady()).rejects.toThrowError(); + expect(instrumentation.abortWaitForCrash).toHaveBeenCalled(); }); }); @@ -440,14 +432,10 @@ describe('Android driver', () => { .mockRejectedValueOnce(new Error()) .mockResolvedValueOnce(); - try { - await uut.installUtilBinaries(binaryPaths); - fail(); - } catch (e) { - expect(appInstallHelper.install).toHaveBeenCalledWith(adbName, binaryPaths[0]); - expect(appInstallHelper.install).toHaveBeenCalledWith(adbName, binaryPaths[1]); - expect(appInstallHelper.install).toHaveBeenCalledTimes(2); - } + await expect(uut.installUtilBinaries(binaryPaths)).rejects.toThrowError(); + expect(appInstallHelper.install).toHaveBeenCalledWith(adbName, binaryPaths[0]); + expect(appInstallHelper.install).toHaveBeenCalledWith(adbName, binaryPaths[1]); + expect(appInstallHelper.install).toHaveBeenCalledTimes(2); }); it('should not install if already installed', async () => { @@ -595,7 +583,6 @@ describe('Android driver', () => { jest.mock('../../../allocation/DeviceRegistry'); - DeviceRegistryClass = require('../../../allocation/DeviceRegistry'); }; const mockGetAbsoluteBinaryPathImpl = (x) => `absolutePathOf(${x})`; diff --git a/detox/src/devices/runtime/drivers/android/__snapshots__/AndroidDriver.test.js.snap b/detox/src/devices/runtime/drivers/android/__snapshots__/AndroidDriver.test.js.snap index 566c9cd809..57e0ef834c 100644 --- a/detox/src/devices/runtime/drivers/android/__snapshots__/AndroidDriver.test.js.snap +++ b/detox/src/devices/runtime/drivers/android/__snapshots__/AndroidDriver.test.js.snap @@ -4,5 +4,5 @@ exports[`Android driver App installation should throw if auto test-binary path r "The test APK could not be found at path: 'testApkPathOf(absolutePathOf(mock-bin-path))' HINT: Try running the detox build command, and make sure it was configured to execute a build command (e.g. './gradlew assembleAndroidTest') -For further assistance, visit the Android setup guide: https://github.com/wix/Detox/blob/master/docs/Introduction.Android.md" +For further assistance, visit the project setup guide (select the Android tabs): https://wix.github.io/Detox/docs/introduction/project-setup" `; diff --git a/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js b/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js index da73dcfcf3..1b1333dbfb 100644 --- a/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js +++ b/detox/src/devices/runtime/drivers/android/emulator/EmulatorDriver.js @@ -11,7 +11,6 @@ const AndroidDriver = require('../AndroidDriver'); * @property forceAdbInstall { Boolean } */ -// TODO Unit test coverage class EmulatorDriver extends AndroidDriver { /** * @param deps { EmulatorDriverDeps } diff --git a/detox/src/devices/runtime/factories/ios.js b/detox/src/devices/runtime/factories/ios.js index 5027684cb9..f11f69aad9 100644 --- a/detox/src/devices/runtime/factories/ios.js +++ b/detox/src/devices/runtime/factories/ios.js @@ -2,8 +2,6 @@ const RuntimeDeviceFactory = require('./base'); class RuntimeDriverFactoryIos extends RuntimeDeviceFactory { _createDriverDependencies(commonDeps) { - const { eventEmitter } = commonDeps; - const AppleSimUtils = require('../../../devices/common/drivers/ios/tools/AppleSimUtils'); const applesimutils = new AppleSimUtils(); diff --git a/detox/src/ios/expectTwo.js b/detox/src/ios/expectTwo.js index b2153b4386..f89b7f26ae 100644 --- a/detox/src/ios/expectTwo.js +++ b/detox/src/ios/expectTwo.js @@ -247,10 +247,13 @@ class Element { return this.withAction('scroll', traceDescription, pixels, direction, startPositionX, startPositionY); } - scrollTo(edge) { + scrollTo(edge, startPositionX = NaN, startPositionY = NaN) { if (!['left', 'right', 'top', 'bottom'].some(option => option === edge)) throw new Error('edge should be one of [left, right, top, bottom], but got ' + edge); - const traceDescription = actionDescription.scrollTo(edge); - return this.withAction('scrollTo', traceDescription, edge); + if (typeof startPositionX !== 'number') throw new Error('startPositionX should be a number, but got ' + (startPositionX + (' (' + (typeof startPositionX + ')')))); + if (typeof startPositionY !== 'number') throw new Error('startPositionY should be a number, but got ' + (startPositionY + (' (' + (typeof startPositionY + ')')))); + + const traceDescription = actionDescription.scrollTo(edge, startPositionX, startPositionY); + return this.withAction('scrollTo', traceDescription, edge, startPositionX, startPositionY); } swipe(direction, speed = 'fast', normalizedSwipeOffset = NaN, normalizedStartingPointX = NaN, normalizedStartingPointY = NaN) { diff --git a/detox/src/ios/expectTwoApiCoverage.test.js b/detox/src/ios/expectTwoApiCoverage.test.js index 53ab4dd6b3..442366d75b 100644 --- a/detox/src/ios/expectTwoApiCoverage.test.js +++ b/detox/src/ios/expectTwoApiCoverage.test.js @@ -181,6 +181,8 @@ describe('expectTwo API Coverage', () => { await expectToThrow(() => e.element(e.by.id('someId')).scrollTo(0)); await expectToThrow(() => e.element(e.by.id('someId')).scrollTo('noDirection')); + await expectToThrow(() => e.element(e.by.id('someId')).scrollTo('top','Nan', 0.5)); + await expectToThrow(() => e.element(e.by.id('someId')).scrollTo('top', 0.5, 'Nan')); await expectToThrow(() => e.element(e.by.id('someId')).swipe(4, 'fast')); await expectToThrow(() => e.element(e.by.id('someId')).swipe('left', 'fast', 20)); @@ -266,6 +268,7 @@ describe('expectTwo API Coverage', () => { 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, 'down', 0, 0); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scrollTo('left'); + await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).scrollTo('left', 0.1, 0.1); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).swipe('left'); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).swipe('left', 'fast'); await e.waitFor(e.element(e.by.id('id'))).toBeVisible().whileElement(e.by.id('id2')).swipe('left', 'slow', 0.1); @@ -283,12 +286,8 @@ describe('expectTwo API Coverage', () => { }); async function expectToThrow(func) { - try { - await func(); - fail('should throw'); - } catch (ex) { - expect(ex).toBeDefined(); - } + const asyncWrapper = async () => await func(); + await expect(asyncWrapper()).rejects.toThrow(); } class MockExecutor { diff --git a/detox/src/ipc/IPCClient.js b/detox/src/ipc/IPCClient.js index 5033d1804f..bcc5384326 100644 --- a/detox/src/ipc/IPCClient.js +++ b/detox/src/ipc/IPCClient.js @@ -60,8 +60,8 @@ class IPCClient { this._sessionState.patch(sessionState); } - async allocateDevice() { - const { deviceCookie, error } = deserializeObjectWithError(await this._emit('allocateDevice', {})); + async allocateDevice(deviceConfig) { + const { deviceCookie, error } = deserializeObjectWithError(await this._emit('allocateDevice', { deviceConfig })); if (error) { throw error; } @@ -86,8 +86,8 @@ class IPCClient { this._sessionState.patch(sessionState); } - async conductEarlyTeardown() { - const sessionState = await this._emit('conductEarlyTeardown', {}); + async conductEarlyTeardown({ permanent }) { + const sessionState = await this._emit('conductEarlyTeardown', { permanent }); this._sessionState.patch(sessionState); } diff --git a/detox/src/ipc/IPCServer.js b/detox/src/ipc/IPCServer.js index 1fba85ac27..203ccd597a 100644 --- a/detox/src/ipc/IPCServer.js +++ b/detox/src/ipc/IPCServer.js @@ -9,7 +9,7 @@ class IPCServer { * @param {import('./SessionState')} options.sessionState * @param {Detox.Logger} options.logger * @param {object} options.callbacks - * @param {() => Promise} options.callbacks.onAllocateDevice + * @param {(deviceConfig: DetoxInternals.RuntimeConfig['device']) => Promise} options.callbacks.onAllocateDevice * @param {(cookie: any) => Promise} options.callbacks.onDeallocateDevice */ constructor({ sessionState, logger, callbacks }) { @@ -40,7 +40,7 @@ class IPCServer { this._ipc.config.logger = (msg) => this._logger.trace(msg); await new Promise((resolve) => { - // TODO: handle reject + // It is worth to handle rejection here some day this._ipc.serve(() => resolve()); this._ipc.server.on('conductEarlyTeardown', this.onConductEarlyTeardown.bind(this)); this._ipc.server.on('registerContext', this.onRegisterContext.bind(this)); @@ -73,6 +73,7 @@ class IPCServer { this._ipc.server.emit(socket, 'registerContextDone', { testResults: this._sessionState.testResults, testSessionIndex: this._sessionState.testSessionIndex, + unsafe_earlyTeardown: this._sessionState.unsafe_earlyTeardown, }); } @@ -90,10 +91,11 @@ class IPCServer { } } - onConductEarlyTeardown(_data = null, socket = null) { - // Note that we don't save `unsafe_earlyTeardown` in the primary session state - // because it's transient and needed only to make the workers quit early. + onConductEarlyTeardown({ permanent }, socket = null) { const newState = { unsafe_earlyTeardown: true }; + if (permanent) { + Object.assign(this._sessionState, newState); + } if (socket) { this._ipc.server.emit(socket, 'conductEarlyTeardownDone', newState); @@ -102,11 +104,11 @@ class IPCServer { this._ipc.server.broadcast('sessionStateUpdate', newState); } - async onAllocateDevice(_payload, socket) { + async onAllocateDevice({ deviceConfig }, socket) { let deviceCookie; try { - deviceCookie = await this._callbacks.onAllocateDevice(); + deviceCookie = await this._callbacks.onAllocateDevice(deviceConfig); this._ipc.server.emit(socket, 'allocateDeviceDone', { deviceCookie }); } catch (error) { this._ipc.server.emit(socket, 'allocateDeviceDone', serializeObjectWithError({ error })); diff --git a/detox/src/ipc/ipc.test.js b/detox/src/ipc/ipc.test.js index 4414935ffe..6e33cd97fc 100644 --- a/detox/src/ipc/ipc.test.js +++ b/detox/src/ipc/ipc.test.js @@ -129,6 +129,36 @@ describe('IPC', () => { }); }); + describe('conductEarlyTeardown', () => { + beforeEach(() => ipcServer.init()); + + describe('(permanent)', () => { + beforeEach(() => ipcServer.onConductEarlyTeardown({ permanent: true })); + + it('should change the session state', async () => { + expect(ipcServer.sessionState.unsafe_earlyTeardown).toEqual(true); + }); + + it('should pass the session state to the client', async () => { + await ipcClient1.init(); + expect(ipcClient1.sessionState.unsafe_earlyTeardown).toEqual(true); + }); + }); + + describe('(transient)', () => { + beforeEach(() => ipcServer.onConductEarlyTeardown({ permanent: false })); + + it('should not change the session state', async () => { + expect(ipcServer.sessionState.unsafe_earlyTeardown).toBe(undefined); + }); + + it('should not pass the session state to the client', async () => { + await ipcClient1.init(); + expect(ipcClient1.sessionState.unsafe_earlyTeardown).toBe(undefined); + }); + }); + }); + describe('dispose()', () => { it('should resolve if there are no connected clients', async () => { await ipcServer.init(); @@ -278,7 +308,7 @@ describe('IPC', () => { expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined })); expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined })); - await ipcClient1.conductEarlyTeardown(); + await ipcClient1.conductEarlyTeardown({ permanent: false }); expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true })); await sleep(10); // broadcasting might happen with a delay expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true })); @@ -287,7 +317,7 @@ describe('IPC', () => { it('should broadcast early teardown in all connected clients (from server)', async () => { expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined })); expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: undefined })); - await ipcServer.onConductEarlyTeardown(); + await ipcServer.onConductEarlyTeardown({ permanent: false }); await sleep(10); // broadcasting might happen with a delay expect(ipcClient1.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true })); expect(ipcClient2.sessionState).toEqual(expect.objectContaining({ unsafe_earlyTeardown: true })); @@ -332,18 +362,22 @@ describe('IPC', () => { }); describe('integration', () => { + const deviceConfig = { type: 'stub.device' }; + beforeEach(() => ipcServer.init()); beforeEach(() => ipcClient1.init()); describe('onAllocateDevice', () => { it('should allocate a device and return its cookie', async () => { callbacks.onAllocateDevice.mockResolvedValue({ id: 'device-1' }); - await expect(ipcClient1.allocateDevice()).resolves.toEqual({ id: 'device-1' }); + await expect(ipcClient1.allocateDevice(deviceConfig)).resolves.toEqual({ id: 'device-1' }); + expect(callbacks.onAllocateDevice).toHaveBeenCalledWith(deviceConfig); }); it('should return an error if allocation fails', async () => { callbacks.onAllocateDevice.mockRejectedValue(new Error('foo')); - await expect(ipcClient1.allocateDevice()).rejects.toThrow('foo'); + await expect(ipcClient1.allocateDevice(deviceConfig)).rejects.toThrow('foo'); + expect(callbacks.onAllocateDevice).toHaveBeenCalledWith(deviceConfig); }); }); diff --git a/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap b/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap index fd1ce5ff3d..47c3a06c1b 100644 --- a/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap +++ b/detox/src/logger/__snapshots__/DetoxLogger.test.js.snap @@ -15,7 +15,7 @@ exports[`DetoxLogger - main functionality - should format messages according to origin: some-module/index.js 00:00:00.000 detox[PID] i custom-category:MESSAGE A message with a payload data: { - \\"foo\\": \\"bar\\" + "foo": "bar" } 00:00:00.000 detox[PID] i custom-category One more message with a payload data: raw string data" @@ -59,13 +59,13 @@ exports[`DetoxLogger - main functionality - should format messages according to origin: some-module/index.js 00:00:00.000 detox[PID] i custom-category:MESSAGE A message with a payload data: { - \\"foo\\": \\"bar\\" + "foo": "bar" } 00:00:00.000 detox[PID] i custom-category One more message with a payload data: raw string data 00:00:00.000 detox[PID] i custom-category:MESSAGE Trace message 00:00:00.000 detox[PID] i someMethodCall - args: (\\"stringArgument\\", {\\"prop\\":\\"value\\"})" + args: ("stringArgument", {"prop":"value"})" `; exports[`DetoxLogger - main functionality - should format messages according to the log level: warn 1`] = ` diff --git a/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap b/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap index 872ba9cc2f..c508a95ba4 100644 --- a/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap +++ b/detox/src/logger/utils/__snapshots__/DetoxLogFinalizer.test.js.snap @@ -1,9 +1,9 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DetoxLogFinalizer createEventStream should create a stream of Chrome Trace format events 1`] = ` -Array [ - Object { - "args": Object { +[ + { + "args": { "name": "primary", }, "name": "process_name", @@ -12,8 +12,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "sort_index": 0, }, "name": "process_sort_index", @@ -22,8 +22,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "name": "main", }, "name": "thread_name", @@ -32,8 +32,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "sort_index": 0, }, "name": "thread_sort_index", @@ -42,8 +42,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "level": 30, "v": 0, }, @@ -54,8 +54,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "name": "secondary", }, "name": "process_name", @@ -64,8 +64,8 @@ Array [ "tid": 0, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "sort_index": 1, }, "name": "process_sort_index", @@ -74,8 +74,8 @@ Array [ "tid": 0, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "level": 30, "v": 0, }, @@ -86,8 +86,8 @@ Array [ "tid": 0, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -99,8 +99,8 @@ Array [ "tid": 0, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "level": 20, "v": 0, }, @@ -111,8 +111,8 @@ Array [ "tid": 0, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -124,8 +124,8 @@ Array [ "tid": 0, "ts": 1672531201100000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -136,8 +136,8 @@ Array [ "tid": 0, "ts": 1672531201400000, }, - Object { - "args": Object { + { + "args": { "name": "parallel", }, "name": "thread_name", @@ -146,8 +146,8 @@ Array [ "tid": 1, "ts": 1672531201500000, }, - Object { - "args": Object { + { + "args": { "sort_index": 1, }, "name": "thread_sort_index", @@ -156,8 +156,8 @@ Array [ "tid": 1, "ts": 1672531201500000, }, - Object { - "args": Object { + { + "args": { "id": 2, "level": 10, "v": 0, @@ -169,8 +169,8 @@ Array [ "tid": 1, "ts": 1672531201500000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -181,8 +181,8 @@ Array [ "tid": 0, "ts": 1672531202000000, }, - Object { - "args": Object { + { + "args": { "level": 10, "v": 0, }, @@ -192,8 +192,8 @@ Array [ "tid": 0, "ts": 1672531202250000, }, - Object { - "args": Object { + { + "args": { "id": 2, "level": 10, "v": 0, @@ -208,9 +208,9 @@ Array [ `; exports[`DetoxLogFinalizer finalize should convert JSONL logs to Chrome Trace format: chrome-trace 1`] = ` -Array [ - Object { - "args": Object { +[ + { + "args": { "name": "primary", }, "name": "process_name", @@ -219,8 +219,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "sort_index": 0, }, "name": "process_sort_index", @@ -229,8 +229,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "name": "main", }, "name": "thread_name", @@ -239,8 +239,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "sort_index": 0, }, "name": "thread_sort_index", @@ -249,8 +249,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "level": 30, "v": 0, }, @@ -261,8 +261,8 @@ Array [ "tid": 0, "ts": 1672531200000000, }, - Object { - "args": Object { + { + "args": { "name": "secondary", }, "name": "process_name", @@ -271,8 +271,8 @@ Array [ "tid": 0, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "sort_index": 1, }, "name": "process_sort_index", @@ -281,8 +281,8 @@ Array [ "tid": 0, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "name": "parallel", }, "name": "thread_name", @@ -291,8 +291,8 @@ Array [ "tid": 3, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "sort_index": 3, }, "name": "thread_sort_index", @@ -301,8 +301,8 @@ Array [ "tid": 3, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "level": 30, "v": 0, }, @@ -313,8 +313,8 @@ Array [ "tid": 3, "ts": 1672531200500000, }, - Object { - "args": Object { + { + "args": { "name": "parallel", }, "name": "thread_name", @@ -323,8 +323,8 @@ Array [ "tid": 1, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "sort_index": 1, }, "name": "thread_sort_index", @@ -333,8 +333,8 @@ Array [ "tid": 1, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -346,8 +346,8 @@ Array [ "tid": 1, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "name": "undefined", }, "name": "thread_name", @@ -356,8 +356,8 @@ Array [ "tid": 4, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "sort_index": 4, }, "name": "thread_sort_index", @@ -366,8 +366,8 @@ Array [ "tid": 4, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "level": 20, "v": 0, }, @@ -378,8 +378,8 @@ Array [ "tid": 4, "ts": 1672531201000000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -391,8 +391,8 @@ Array [ "tid": 1, "ts": 1672531201100000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -403,8 +403,8 @@ Array [ "tid": 1, "ts": 1672531201400000, }, - Object { - "args": Object { + { + "args": { "name": "parallel", }, "name": "thread_name", @@ -413,8 +413,8 @@ Array [ "tid": 2, "ts": 1672531201500000, }, - Object { - "args": Object { + { + "args": { "sort_index": 2, }, "name": "thread_sort_index", @@ -423,8 +423,8 @@ Array [ "tid": 2, "ts": 1672531201500000, }, - Object { - "args": Object { + { + "args": { "id": 2, "level": 10, "v": 0, @@ -436,8 +436,8 @@ Array [ "tid": 2, "ts": 1672531201500000, }, - Object { - "args": Object { + { + "args": { "id": 1, "level": 10, "v": 0, @@ -448,8 +448,8 @@ Array [ "tid": 1, "ts": 1672531202000000, }, - Object { - "args": Object { + { + "args": { "level": 10, "v": 0, }, @@ -459,8 +459,8 @@ Array [ "tid": 3, "ts": 1672531202250000, }, - Object { - "args": Object { + { + "args": { "id": 2, "level": 10, "v": 0, diff --git a/detox/src/realms/DetoxContext.js b/detox/src/realms/DetoxContext.js index 28d2f6d870..bf5111d343 100644 --- a/detox/src/realms/DetoxContext.js +++ b/detox/src/realms/DetoxContext.js @@ -47,7 +47,7 @@ class DetoxContext { this[$sessionState] = this[$restoreSessionState](); /** - * @type {DetoxLogger & Detox.Logger} + * @type {import('../logger/').DetoxLogger & Detox.Logger} */ this[symbols.logger] = new DetoxLogger({ file: temporary.for.jsonl(`${this[$sessionState].id}.${process.pid}`), @@ -102,7 +102,7 @@ class DetoxContext { /** @abstract */ [symbols.reportTestResults](_testResults) {} /** @abstract */ - [symbols.conductEarlyTeardown]() {} + [symbols.conductEarlyTeardown](_permanent) {} /** * @abstract * @param {Partial} _opts @@ -152,10 +152,10 @@ class DetoxContext { } /** @abstract */ - async [symbols.allocateDevice]() {} + async [symbols.allocateDevice](_deviceConfig) {} /** @abstract */ - async [symbols.deallocateDevice]() {} + async [symbols.deallocateDevice](_deviceCookie) {} async [symbols.uninstallWorker]() { try { diff --git a/detox/src/realms/DetoxPrimaryContext.js b/detox/src/realms/DetoxPrimaryContext.js index bec924eda2..e2f634138b 100644 --- a/detox/src/realms/DetoxPrimaryContext.js +++ b/detox/src/realms/DetoxPrimaryContext.js @@ -23,7 +23,9 @@ const _emergencyTeardown = Symbol('emergencyTeardown'); const _lifecycleLogger = Symbol('lifecycleLogger'); const _sessionFile = Symbol('sessionFile'); const _logFinalError = Symbol('logFinalError'); -const _deviceAllocator = Symbol('deviceAllocator'); +const _cookieAllocators = Symbol('cookieAllocators'); +const _deviceAllocators = Symbol('deviceAllocators'); +const _createDeviceAllocator = Symbol('createDeviceAllocator'); //#endregion class DetoxPrimaryContext extends DetoxContext { @@ -32,7 +34,8 @@ class DetoxPrimaryContext extends DetoxContext { this[_dirty] = false; this[_wss] = null; - this[_deviceAllocator] = null; + this[_cookieAllocators] = {}; + this[_deviceAllocators] = {}; /** Path to file where the initial session object is serialized */ this[_sessionFile] = ''; @@ -51,9 +54,9 @@ class DetoxPrimaryContext extends DetoxContext { } } - [symbols.conductEarlyTeardown] = async () => { + [symbols.conductEarlyTeardown] = async (permanent = false) => { if (this[_ipcServer]) { - await this[_ipcServer].onConductEarlyTeardown(); + await this[_ipcServer].onConductEarlyTeardown({ permanent }); } }; @@ -85,7 +88,6 @@ class DetoxPrimaryContext extends DetoxContext { const detoxConfig = await this[symbols.resolveConfig](opts); const { - device: deviceConfig, logger: loggerConfig, session: sessionConfig } = detoxConfig; @@ -96,7 +98,6 @@ class DetoxPrimaryContext extends DetoxContext { data: this[$sessionState], }, getCurrentCommand()); - // TODO: IPC Server creation ought to be delegated to a generator/factory. const IPCServer = require('../ipc/IPCServer'); this[_ipcServer] = new IPCServer({ sessionState: this[$sessionState], @@ -109,17 +110,6 @@ class DetoxPrimaryContext extends DetoxContext { await this[_ipcServer].init(); - const environmentFactory = require('../environmentFactory'); - - const { deviceAllocatorFactory } = environmentFactory.createFactories(deviceConfig); - this[_deviceAllocator] = deviceAllocatorFactory.createDeviceAllocator({ - detoxConfig, - detoxSession: this[$sessionState], - }); - - await this[_deviceAllocator].init(); - - // TODO: Detox-server creation ought to be delegated to a generator/factory. const DetoxServer = require('../server/DetoxServer'); if (sessionConfig.autoStart) { this[_wss] = new DetoxServer({ @@ -132,7 +122,6 @@ class DetoxPrimaryContext extends DetoxContext { await this[_wss].open(); } - // TODO: double check that this config is indeed propogated onto the client create at the detox-worker side if (!sessionConfig.server && this[_wss]) { // @ts-ignore sessionConfig.server = `ws://localhost:${this[_wss].port}`; @@ -162,17 +151,20 @@ class DetoxPrimaryContext extends DetoxContext { } /** @override */ - async [symbols.allocateDevice]() { - const { device } = this[$sessionState].detoxConfig; - const deviceCookie = await this[_deviceAllocator].allocate(device); + async [symbols.allocateDevice](deviceConfig) { + const deviceAllocator = await this[_createDeviceAllocator](deviceConfig); + const deviceCookie = await deviceAllocator.allocate(deviceConfig); + this[_cookieAllocators][deviceCookie.id] = deviceAllocator; try { - return await this[_deviceAllocator].postAllocate(deviceCookie); + return await deviceAllocator.postAllocate(deviceCookie); } catch (e) { try { - await this[_deviceAllocator].free(deviceCookie, { shutdown: true }); + await deviceAllocator.free(deviceCookie, { shutdown: true }); } catch (e2) { - this[symbols.logger].error({ cat: 'device', err: e2 }, `Failed to free ${deviceCookie.name || deviceCookie.id} after a failed allocation`); + this[symbols.logger].error({ cat: 'device', err: e2 }, `Failed to free ${deviceCookie.name || deviceCookie.id} after a failed allocation attempt`); + } finally { + delete this[_cookieAllocators][deviceCookie.id]; } throw e; @@ -181,7 +173,17 @@ class DetoxPrimaryContext extends DetoxContext { /** @override */ async [symbols.deallocateDevice](cookie) { - await this[_deviceAllocator].free(cookie); + const deviceAllocator = this[_cookieAllocators][cookie.id]; + if (!deviceAllocator) { + throw new DetoxRuntimeError({ + message: `Cannot deallocate device ${cookie.id} because it was not allocated by this context.`, + hint: `See the actually known allocated devices below:`, + debugInfo: Object.keys(this[_cookieAllocators]).map(id => `- ${id}`).join('\n'), + }); + } + + await deviceAllocator.free(cookie); + delete this[_cookieAllocators][cookie.id]; } /** @override */ @@ -191,11 +193,18 @@ class DetoxPrimaryContext extends DetoxContext { await this[symbols.uninstallWorker](); } } finally { - if (this[_deviceAllocator]) { - await this[_deviceAllocator].cleanup(); - this[_deviceAllocator] = null; + for (const key of Object.keys(this[_deviceAllocators])) { + const deviceAllocator = this[_deviceAllocators][key]; + delete this[_deviceAllocators][key]; + try { + await deviceAllocator.cleanup(); + } catch (err) { + this[symbols.logger].error({ cat: 'device', err }, `Failed to cleanup the device allocation driver for ${key}`); + } } + this[_cookieAllocators] = {}; + if (this[_wss]) { await this[_wss].close(); this[_wss] = null; @@ -227,11 +236,18 @@ class DetoxPrimaryContext extends DetoxContext { return; } - if (this[_deviceAllocator]) { - this[_deviceAllocator].emergencyCleanup(); - this[_deviceAllocator] = null; + for (const key of Object.keys(this[_deviceAllocators])) { + const deviceAllocator = this[_deviceAllocators][key]; + delete this[_deviceAllocators][key]; + try { + deviceAllocator.emergencyCleanup(); + } catch (err) { + this[symbols.logger].error({ cat: 'device', err }, `Failed to clean up the device allocation driver for ${key} in emergency mode`); + } } + this[_cookieAllocators] = {}; + if (this[_wss]) { this[_wss].close(); } @@ -253,6 +269,35 @@ class DetoxPrimaryContext extends DetoxContext { } }; + /** @param {Detox.DetoxDeviceConfig} deviceConfig */ + [_createDeviceAllocator] = async (deviceConfig) => { + const deviceType = deviceConfig.type; + if (!this[_deviceAllocators][deviceType]) { + const environmentFactory = require('../environmentFactory'); + const { deviceAllocatorFactory } = environmentFactory.createFactories(deviceConfig); + const { detoxConfig } = this[$sessionState]; + const deviceAllocator = deviceAllocatorFactory.createDeviceAllocator({ + detoxConfig, + detoxSession: this[$sessionState], + }); + + try { + await deviceAllocator.init(); + this[_deviceAllocators][deviceType] = deviceAllocator; + } catch (e) { + try { + await deviceAllocator.cleanup(); + } catch (e2) { + this[symbols.logger].error({ cat: 'device', err: e2 }, `Failed to cleanup the device allocation driver for ${deviceType} after a failed initialization`); + } + + throw e; + } + } + + return this[_deviceAllocators][deviceType]; + }; + [_logFinalError] = (err) => { this[_lifecycleLogger].error(err, 'Encountered an error while merging the process logs:'); }; diff --git a/detox/src/realms/DetoxPrimaryContext.test.js b/detox/src/realms/DetoxPrimaryContext.test.js index de374c853b..3f373f09b3 100644 --- a/detox/src/realms/DetoxPrimaryContext.test.js +++ b/detox/src/realms/DetoxPrimaryContext.test.js @@ -53,15 +53,22 @@ describe('DetoxPrimaryContext', () => { let DetoxWorker; //#endregion + /** @type {import('./DetoxPrimaryContext')} */ + let context; /** @type {import('./DetoxInternalsFacade')} */ let facade; + /** @type {import('./symbols')} */ + let symbols; const detoxServer = () => latestInstanceOf(DetoxServer); const ipcServer = () => latestInstanceOf(IPCServer); const detoxWorker = () => latestInstanceOf(DetoxWorker); + // @ts-ignore + const log = () => logger.DetoxLogger.instances[0]; const logFinalizer = () => latestInstanceOf(logger.DetoxLogFinalizer); const getSignalHandler = () => lastCallTo(signalExit)[FIRST_ARGUMENT]; const facadeInit = () => facade.init({ workerId: null }); + const facadeInitWithWorker = async () => facade.init({ workerId: WORKER_ID }); backupProcessEnv(); @@ -72,8 +79,9 @@ describe('DetoxPrimaryContext', () => { const DetoxPrimaryContext = require('./DetoxPrimaryContext'); const DetoxInternalsFacade = require('./DetoxInternalsFacade'); - const context = new DetoxPrimaryContext(); + context = new DetoxPrimaryContext(); facade = new DetoxInternalsFacade(context); + symbols = require('./symbols'); }); describe('when not initialized', () => { @@ -94,12 +102,12 @@ describe('DetoxPrimaryContext', () => { }); }); - describe('when initializing', () => { + describe('when initialized', () => { + beforeEach(facadeInit); + it('should create an IPC server with a valid session state', async () => { const expectedIPCServerName = `primary-${process.pid}`; - await facadeInit(); - expect(IPCServer).toHaveBeenCalledWith(expect.objectContaining({ sessionState: expect.objectContaining({ id: expect.stringMatching(UUID_REGEXP), @@ -109,56 +117,10 @@ describe('DetoxPrimaryContext', () => { }); it('should init the IPC server', async () => { - await facadeInit(); expect(ipcServer().init).toHaveBeenCalled(); }); - it('should init the device allocation driver', async () => { - await facadeInit(); - expect(deviceAllocator.init).toHaveBeenCalled(); - }); - - describe('given detox-server auto-start enabled via config', () => { - beforeEach(() => detoxConfigDriver.givenDetoxServerAutostart()); - - it('should create the Detox server', async () => { - const expectedServerArgs = { - port: 0, - standalone: false, - }; - - await facadeInit(); - expect(DetoxServer).toHaveBeenCalledWith(expectedServerArgs); - }); - - it('should create the Detox server based on a specified port', async () => { - const port = '666'; - detoxConfigDriver.givenDetoxServerPort(port); - - const expectedServerArgs = { - port, - standalone: false, - }; - await facadeInit(); - expect(DetoxServer).toHaveBeenCalledWith(expectedServerArgs); - }); - - it('should start the server', async () => { - await facadeInit(); - expect(detoxServer().open).toHaveBeenCalled(); - }); - }); - - describe('given detox-server auto-start disabled via config', () => { - it('should not create a server', async () => { - await facadeInit(); - expect(DetoxServer).not.toHaveBeenCalled(); - }); - }); - it('should save the session state onto the context-shared file', async () => { - await facadeInit(); - expect(fs.writeFile).toHaveBeenCalledWith( expect.stringMatching(TEMP_FILE_REGEXP), expect.any(String), @@ -171,150 +133,182 @@ describe('DetoxPrimaryContext', () => { }); it('should export context-shared file via DETOX_CONFIG_SNAPSHOT_PATH', async () => { - await facadeInit(); - expect(process.env.DETOX_CONFIG_SNAPSHOT_PATH).toBeDefined(); expect(process.env.DETOX_CONFIG_SNAPSHOT_PATH).toMatch(TEMP_FILE_REGEXP); }); - it('should install a worker if called without options', async () => { - await facade.init(); - expect(facade.session).toEqual(expect.objectContaining({ workerId: 'worker' })); - expect(detoxWorker().init).toHaveBeenCalled(); + it('should reject further initializations', async () => { + await expect(() => facadeInit()).rejects.toThrowErrorMatchingSnapshot(); }); - it('should install a worker if worker ID has been specified', async () => { - await facade.init({ workerId: WORKER_ID }); - expect(facade.session).toEqual(expect.objectContaining({ workerId: WORKER_ID })); - expect(detoxWorker().init).toHaveBeenCalled(); + it('should change status to "active"', async () => { + expect(facade.getStatus()).toBe('active'); }); - it('should register the worker at the IPC server\'s', async () => { - await facade.init({ workerId: WORKER_ID }); - expect(ipcServer().onRegisterWorker).toHaveBeenCalledWith({ workerId: WORKER_ID }); - }); + describe('when a device is being allocated', () => { + let cookie; - describe('given an initialization failure', () => { - it('should report status as "init"', async () => { - IPCServer.prototype.init = jest.fn().mockRejectedValue(new Error('init failed')); + beforeEach(async () => { + cookie = await allocateSomeDevice(); + }); - await expect(() => facadeInit()).rejects.toThrow(); - expect(facade.getStatus()).toBe('init'); + it('should return a cookie', async () => { + expect(cookie).toEqual({ id: 'a-device-id' }); }); - }); - }); - describe('when initialized', () => { - it('should reject further initializations', async () => { - await facadeInit(); - await expect(() => facadeInit()).rejects.toThrowErrorMatchingSnapshot(); - }); + it('should call the device allocator', async () => { + expect(deviceAllocator.init).toHaveBeenCalled(); + expect(deviceAllocator.allocate).toHaveBeenCalled(); + expect(deviceAllocator.postAllocate).toHaveBeenCalled(); + }); - it('should change status to "active"', async () => { - await facadeInit(); - expect(facade.getStatus()).toBe('active'); - }); + it('can be deallocated', async () => { + await expect(deallocateDevice(cookie)).resolves.toBeUndefined(); + }); - describe('then cleaned-up', () => { - it('should uninstall an assigned worker', async () => { - await facade.init({ workerId: WORKER_ID }); - await facade.cleanup(); + it('should throw on attempt to deallocate a cookie that does not belong to this context', async () => { + await expect(deallocateDevice({ id: 'some-other-device' })).rejects.toThrowErrorMatchingSnapshot(); + }); - expect(detoxWorker().cleanup).toHaveBeenCalled(); + it('cannot be deallocated twice', async () => { + await deallocateDevice(cookie); + await expect(deallocateDevice(cookie)).rejects.toThrowError(/Cannot deallocate device/); }); - it('should clean up the allocation driver', async () => { - await facadeInit(); - await facade.cleanup(); + describe('and then the context has been cleaned up', () => { + beforeEach(async () => { + await facade.cleanup(); + }); - expect(deviceAllocator.cleanup).toHaveBeenCalled(); + it('should clean up the allocation driver', async () => { + expect(deviceAllocator.cleanup).toHaveBeenCalled(); + }); + + it('should not be able to find that cookie anymore', async () => { + await expect(deallocateDevice(cookie)).rejects.toThrowError(/Cannot deallocate device/); + }); }); - it('should close the detox server', async () => { - detoxConfigDriver.givenDetoxServerAutostart(); + describe('and then the context has been cleaned up with an allocator cleanup error', () => { + let error = new Error('cleanup failed'); - await facadeInit(); - await facade.cleanup(); + beforeEach(async () => { + deviceAllocator.cleanup.mockRejectedValue(error); + }); - expect(detoxServer().close).toHaveBeenCalled(); + it('should log the error but not throw', async () => { + await expect(facade.cleanup()).resolves.toBeUndefined(); + expect(log().error).toHaveBeenCalledWith({ cat: 'device', err: error }, `Failed to cleanup the device allocation driver for some.device`); + }); }); - it('should close the ipc server', async () => { - await facadeInit(); - await facade.cleanup(); + describe('on emergency context cleanup', () => { + beforeEach(async () => { + const signalHandler = getSignalHandler(); + signalHandler(123, 'SIGSMT'); + }); - expect(ipcServer().dispose).toHaveBeenCalled(); + it('should call emergencyCleanup in allocation driver', async () => { + expect(deviceAllocator.emergencyCleanup).toHaveBeenCalled(); + }); }); - it('should delete the context-shared file', async () => { - await facadeInit(); - await facade.cleanup(); + describe('on emergency context cleanup with an allocator cleanup error', () => { + let error = new Error('cleanup failed'); - expect(fs.remove).toHaveBeenCalledWith(expect.stringMatching(TEMP_FILE_REGEXP)); + beforeEach(async () => { + deviceAllocator.emergencyCleanup.mockImplementation(() => { throw error; }); + }); + + it('should log the error but not throw', async () => { + const signalHandler = getSignalHandler(); + expect(() => signalHandler(123, 'SIGSMT')).not.toThrow(); + expect(log().error).toHaveBeenCalledWith({ cat: 'device', err: error }, `Failed to clean up the device allocation driver for some.device in emergency mode`); + }); }); + }); - it('should finalize the logger', async () => { - await facadeInit(); - await facade.cleanup(); - expect(logFinalizer().finalize).toHaveBeenCalled(); + describe('when a device is being allocated using a faulty driver', () => { + beforeEach(() => { + deviceAllocator.init.mockRejectedValue(new Error('init failed')); }); - it('should change intermediate status to "cleanup"', async () => { - expect.assertions(1); - await facadeInit(); + it('should destroy the allocation driver immediately', async () => { + await expect(allocateSomeDevice()).rejects.toThrow(/init failed/); + expect(deviceAllocator.cleanup).toHaveBeenCalled(); + }); - ipcServer().dispose.mockImplementation(async () => { - expect(facade.getStatus()).toBe('cleanup'); + describe('and the driver fails to clean up', () => { + beforeEach(() => { + deviceAllocator.cleanup.mockRejectedValue(new Error('cleanup failed')); }); - await facade.cleanup(); + it('should log the error', async () => { + await expect(allocateSomeDevice()).rejects.toThrow(/init failed/); + expect(log().error).toHaveBeenCalledWith({ cat: 'device', err: new Error('cleanup failed') }, `Failed to cleanup the device allocation driver for some.device after a failed initialization`); + }); }); + }); - it('should restore status to "inactive"', async () => { - await facadeInit(); - await facade.cleanup(); - expect(facade.getStatus()).toBe('inactive'); + describe('when a faulty device is being allocated', () => { + beforeEach(async () => { + deviceAllocator.postAllocate.mockRejectedValue(new Error('postAllocate failed')); }); - describe('given a worker clean-up error', () => { - const facadeInitWithWorker = async () => facade.init({ workerId: WORKER_ID }); - const facadeCleanup = async () => expect(() => facade.cleanup()).rejects.toThrow(); + it('should free the device after an error', async () => { + await expect(allocateSomeDevice()).rejects.toThrow(/postAllocate failed/); + expect(deviceAllocator.free).toHaveBeenCalled(); + }); - beforeEach(async () => { - detoxConfigDriver.givenDetoxServerAutostart(); - await facadeInitWithWorker(); + describe('and cannot be freed properly', () => { + let error = new Error('free failed'); - detoxWorker().cleanup.mockRejectedValue(new Error('')); + beforeEach(async () => { + deviceAllocator.free.mockRejectedValue(error); }); - it('should clean-up nonetheless', async () => { - await facadeCleanup(); - expect(detoxServer().close).toHaveBeenCalled(); - expect(ipcServer().dispose).toHaveBeenCalled(); + it('should throw the original allocation error', async () => { + await expect(allocateSomeDevice()).rejects.toThrow(/postAllocate failed/); + expect(log().error).toHaveBeenCalledWith({ cat: 'device', err: error }, `Failed to free a-device-id after a failed allocation attempt`); }); + }); + }); - it('should restore status to "inactive"', async () => { - await facadeCleanup(); - expect(facade.getStatus()).toBe('inactive'); + describe('and cleaning up', () => { + it('should change intermediate status to "cleanup"', async () => { + expect.assertions(1); + ipcServer().dispose.mockImplementation(async () => { + expect(facade.getStatus()).toBe('cleanup'); }); + await facade.cleanup(); }); }); - describe('given an exit signal', () => { - beforeEach(async () => { - detoxConfigDriver.givenDetoxServerAutostart(); + describe('and cleaned up', () => { + beforeEach(async () => facade.cleanup()); - await facadeInit(); + it('should close the ipc server', async () => { + expect(ipcServer().dispose).toHaveBeenCalled(); + }); - const signalHandler = getSignalHandler(); - signalHandler(123, 'SIGSMT'); + it('should delete the context-shared file', async () => { + expect(fs.remove).toHaveBeenCalledWith(expect.stringMatching(TEMP_FILE_REGEXP)); + }); + + it('should finalize the logger', async () => { + expect(logFinalizer().finalize).toHaveBeenCalled(); }); - it('should *emergency* cleanup the global lifecycle handler', () => - expect(deviceAllocator.emergencyCleanup).toHaveBeenCalled()); + it('should restore status to "inactive"', async () => { + expect(facade.getStatus()).toBe('inactive'); + }); + }); - it('should close the detox server', async () => - expect(detoxServer().close).toHaveBeenCalled()); + describe('given an exit signal', () => { + beforeEach(async () => { + const signalHandler = getSignalHandler(); + signalHandler(123, 'SIGSMT'); + }); it('should close the ipc server', async () => expect(ipcServer().dispose).toHaveBeenCalled()); @@ -325,19 +319,47 @@ describe('DetoxPrimaryContext', () => { it('should finalize the logger', async () => expect(logFinalizer().finalizeSync).toHaveBeenCalled()); }); + }); - describe('given a broken exit signal', () => { - let signalHandler; - beforeEach(async () => { - detoxConfigDriver.givenDetoxServerAutostart(); - await facadeInit(); + describe('when initialized with no options', () => { + beforeEach(async () => facade.init()); + + it('should also install a worker', async () => { + expect(detoxWorker().init).toHaveBeenCalled(); + expect(facade.session).toEqual(expect.objectContaining({ workerId: 'worker' })); + }); + }); + + describe('when initialized with auto-start of Detox server', () => { + beforeEach(() => detoxConfigDriver.givenDetoxServerAutostart()); + beforeEach(facadeInit); + + it('should create the Detox server', async () => { + expect(DetoxServer).toHaveBeenCalledWith({ + port: 0, + standalone: false, + }); + }); + + it('should start the server', async () => { + expect(detoxServer().open).toHaveBeenCalled(); + }); + + describe('and cleaned up', () => { + beforeEach(async () => facade.cleanup()); - signalHandler = getSignalHandler(); + it('should close the detox server', async () => { + expect(detoxServer().close).toHaveBeenCalled(); }); + }); - it('should do nothing', () => { + describe('given a non-conforming exit signal', () => { + beforeEach(async () => { + const signalHandler = getSignalHandler(); signalHandler(123, undefined); + }); + it('should do nothing', () => { expect(deviceAllocator.emergencyCleanup).not.toHaveBeenCalled(); expect(detoxServer().close).not.toHaveBeenCalled(); expect(ipcServer().dispose).not.toHaveBeenCalled(); @@ -345,6 +367,76 @@ describe('DetoxPrimaryContext', () => { }); }); + describe('when initialized with Detox server on a certain port', () => { + const port = '666'; + + beforeEach(() => detoxConfigDriver.givenDetoxServerAutostart(port)); + beforeEach(() => detoxConfigDriver.givenDetoxServerPort(port)); + beforeEach(facadeInit); + + it('should create it', async () => { + expect(DetoxServer).toHaveBeenCalledWith({ + port, + standalone: false, + }); + }); + }); + + describe('when initialized without auto-start of Detox server', () => { + beforeEach(facadeInit); + + it('should not create a server', async () => { + expect(DetoxServer).not.toHaveBeenCalled(); + }); + }); + + describe('when initialized not successfully', () => { + it('should report status as "init"', async () => { + IPCServer.prototype.init = jest.fn().mockRejectedValue(new Error('init failed')); + + await expect(() => facadeInit()).rejects.toThrow(); + expect(facade.getStatus()).toBe('init'); + }); + }); + + describe('when initialized with a worker', () => { + beforeEach(() => detoxConfigDriver.givenDetoxServerAutostart()); + beforeEach(facadeInitWithWorker); + + it('should install a worker if worker ID has been specified', async () => { + expect(facade.session).toEqual(expect.objectContaining({ workerId: WORKER_ID })); + expect(detoxWorker().init).toHaveBeenCalled(); + }); + + it('should register the worker at the IPC server\'s', async () => { + expect(ipcServer().onRegisterWorker).toHaveBeenCalledWith({ workerId: WORKER_ID }); + }); + + describe('and cleaned up', () => { + beforeEach(async () => facade.cleanup()); + + it('should uninstall an assigned worker', async () => { + expect(detoxWorker().cleanup).toHaveBeenCalled(); + }); + }); + + describe('and cleaned up with an error', () => { + beforeEach(async () => { + detoxWorker().cleanup.mockRejectedValue(new Error('')); + await expect(() => facade.cleanup()).rejects.toThrow(); + }); + + it('should clean-up nonetheless', async () => { + expect(detoxServer().close).toHaveBeenCalled(); + expect(ipcServer().dispose).toHaveBeenCalled(); + }); + + it('should restore status to "inactive"', async () => { + expect(facade.getStatus()).toBe('inactive'); + }); + }); + }); + function _initDetoxConfig() { detoxConfigDriver = new DetoxConfigDriver(_.cloneDeep(DETOX_CONFIG_BASE)); @@ -369,8 +461,8 @@ describe('DetoxPrimaryContext', () => { deviceAllocator = { init: jest.fn(), - allocate: jest.fn(), - postAllocate: jest.fn(), + allocate: jest.fn().mockResolvedValue({ id: 'a-device-id' }), + postAllocate: jest.fn().mockResolvedValue({ id: 'a-device-id' }), free: jest.fn(), cleanup: jest.fn(), emergencyCleanup: jest.fn(), @@ -391,6 +483,14 @@ describe('DetoxPrimaryContext', () => { DetoxWorker = jest.requireMock('../DetoxWorker'); } + async function allocateSomeDevice() { + return context[symbols.allocateDevice]({ type: 'some.device' }); + } + + async function deallocateDevice(cookie) { + return context[symbols.deallocateDevice](cookie); + } + class DetoxConfigDriver { constructor(detoxConfig) { this.detoxConfig = detoxConfig; diff --git a/detox/src/realms/DetoxSecondaryContext.js b/detox/src/realms/DetoxSecondaryContext.js index 0a74e9a25a..e5795f1ef8 100644 --- a/detox/src/realms/DetoxSecondaryContext.js +++ b/detox/src/realms/DetoxSecondaryContext.js @@ -33,9 +33,9 @@ class DetoxSecondaryContext extends DetoxContext { } } - [symbols.conductEarlyTeardown] = async () => { + [symbols.conductEarlyTeardown] = async (permanent = false) => { if (this[_ipcClient]) { - await this[_ipcClient].conductEarlyTeardown(); + await this[_ipcClient].conductEarlyTeardown({ permanent }); } else { throw new DetoxInternalError('Detected an attempt to report early teardown using a non-initialized context.'); } @@ -63,9 +63,9 @@ class DetoxSecondaryContext extends DetoxContext { } /** @override */ - async [symbols.allocateDevice]() { + async [symbols.allocateDevice](deviceConfig) { if (this[_ipcClient]) { - const deviceCookie = await this[_ipcClient].allocateDevice(); + const deviceCookie = await this[_ipcClient].allocateDevice(deviceConfig); return deviceCookie; } else { throw new DetoxInternalError('Detected an attempt to allocate a device using a non-initialized context.'); diff --git a/detox/src/realms/__snapshots__/DetoxPrimaryContext.test.js.snap b/detox/src/realms/__snapshots__/DetoxPrimaryContext.test.js.snap index d15431ff0f..768841cddd 100644 --- a/detox/src/realms/__snapshots__/DetoxPrimaryContext.test.js.snap +++ b/detox/src/realms/__snapshots__/DetoxPrimaryContext.test.js.snap @@ -7,6 +7,14 @@ HINT: If you are using Detox with Jest according to the latest guide, please rep https://github.com/wix/Detox/issues" `; +exports[`DetoxPrimaryContext when initialized when a device is being allocated should throw on attempt to deallocate a cookie that does not belong to this context 1`] = ` +"Cannot deallocate device some-other-device because it was not allocated by this context. + +HINT: See the actually known allocated devices below: + +- a-device-id" +`; + exports[`DetoxPrimaryContext when not initialized should throw on attempt to get a worker 1`] = ` "Detox worker instance has not been installed in this context (DetoxPrimaryContext). diff --git a/detox/src/server/__snapshots__/DetoxServer.test.js.snap b/detox/src/server/__snapshots__/DetoxServer.test.js.snap index aa56a028bc..ef14ccbdb7 100644 --- a/detox/src/server/__snapshots__/DetoxServer.test.js.snap +++ b/detox/src/server/__snapshots__/DetoxServer.test.js.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`DetoxServer .open() / .close() should WARN log a message upon unsuccessful server closing (error emit case) 1`] = ` -Array [ - Object { +[ + { "err": [Error: TEST_ERROR], }, "Detox server has been closed abruptly! See the error details below:", @@ -10,8 +10,8 @@ Array [ `; exports[`DetoxServer .open() / .close() should WARN log a message upon unsuccessful server closing (rejection case) 1`] = ` -Array [ - Object { +[ + { "err": [Error: TEST_ERROR], }, "Detox server has been closed abruptly! See the error details below:", @@ -19,8 +19,8 @@ Array [ `; exports[`DetoxServer .open() / .close() should WARN log a message upon unsuccessful server closing (timeout case) 1`] = ` -Array [ - Object { +[ + { "err": [DetoxRuntimeError: Detox server close callback was not invoked within the 10000 ms timeout], }, "Detox server has been closed abruptly! See the error details below:", @@ -28,8 +28,8 @@ Array [ `; exports[`DetoxServer should ERROR log messages from wss.Server 1`] = ` -Array [ - Object { +[ + { "err": [Error: TEST_ERROR], }, "Detox server has got an unhandled error:", diff --git a/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap b/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap index e6c77eacc8..c58f8b1355 100644 --- a/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap +++ b/detox/src/server/__tests__/__snapshots__/server-integration.test.js.snap @@ -1,8 +1,8 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP exports[`Detox server integration "app" connects first, and then disconnects 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 8081, }, @@ -12,8 +12,8 @@ Array [ `; exports[`Detox server integration "tester" connects first, and then disconnects 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 8081, }, @@ -23,8 +23,8 @@ Array [ `; exports[`Detox server integration edge cases .registerSession - calling twice 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, "role": "app", @@ -38,18 +38,18 @@ https://github.com/wix/Detox/issues", `; exports[`Detox server integration edge cases app dispatches "ready" action before login 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - "The app has dispatched \\"ready\\" action too early.", + "The app has dispatched "ready" action too early.", ] `; exports[`Detox server integration edge cases attempt to register the same connection twice 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, @@ -60,8 +60,8 @@ https://github.com/wix/Detox/issues", `; exports[`Detox server integration edge cases attempt to unregister an unknown connection 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", }, "DetoxInternalError: Cannot unregister an unknown WebSocket instance. @@ -71,12 +71,12 @@ https://github.com/wix/Detox/issues", `; exports[`Detox server integration edge cases login - empty .params 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [DetoxRuntimeError: Invalid login action received, it has no .params HINT: Please report this issue on our GitHub tracker: @@ -91,12 +91,12 @@ https://github.com/wix/Detox/issues `; exports[`Detox server integration edge cases login - invalid .role 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [DetoxRuntimeError: Invalid login action received, it has invalid .role HINT: Please report this issue on our GitHub tracker: @@ -115,12 +115,12 @@ https://github.com/wix/Detox/issues `; exports[`Detox server integration edge cases login - missing .sessionId 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [DetoxRuntimeError: Invalid login action received, it has no .sessionId HINT: Please report this issue on our GitHub tracker: @@ -139,12 +139,12 @@ https://github.com/wix/Detox/issues `; exports[`Detox server integration edge cases login - non-string .sessionId 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [DetoxRuntimeError: Invalid login action received, it has a non-string .sessionId HINT: Please report this issue on our GitHub tracker: @@ -165,15 +165,15 @@ https://github.com/wix/Detox/issues `; exports[`Detox server integration edge cases login twice (as app) 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, "role": "app", "sessionId": "aSession", "trackingId": "app", }, - Object { + { "error": [DetoxInternalError: Cannot log in twice into the same session (aSession) being "app" already Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues], @@ -183,15 +183,15 @@ https://github.com/wix/Detox/issues], `; exports[`Detox server integration edge cases login twice (as tester) + socket send error 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, "role": "tester", "sessionId": "aSession", "trackingId": "tester", }, - Object { + { "err": [Error: TestError], }, "Cannot forward the error details to the tester due to the error:", @@ -199,15 +199,15 @@ Array [ `; exports[`Detox server integration edge cases login twice (as tester) + socket send error 2`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, "role": "tester", "sessionId": "aSession", "trackingId": "tester", }, - Object { + { "error": [DetoxInternalError: Cannot log in twice into the same session (aSession) being "tester" already Please report this issue on our GitHub tracker: https://github.com/wix/Detox/issues], @@ -217,12 +217,12 @@ https://github.com/wix/Detox/issues], `; exports[`Detox server integration edge cases on(message) - malformed data 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [DetoxRuntimeError: The payload received is not a valid JSON. HINT: Please report this issue on our GitHub tracker: @@ -233,12 +233,12 @@ https://github.com/wix/Detox/issues], `; exports[`Detox server integration edge cases on(message) - no .type 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [DetoxRuntimeError: Cannot process an action without a type. HINT: Please report this issue on our GitHub tracker: @@ -253,12 +253,12 @@ https://github.com/wix/Detox/issues `; exports[`Detox server integration edge cases receiving an action before we login 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [DetoxRuntimeError: Action dispatched too early, there is no session to use: HINT: Please report this issue on our GitHub tracker: @@ -274,12 +274,12 @@ https://github.com/wix/Detox/issues `; exports[`Detox server integration edge cases socket error 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 10, }, - Object { + { "error": [Error: Test error], }, "Caught socket error:", @@ -287,15 +287,15 @@ Array [ `; exports[`Detox server integration tester and app interconnect and then disconnect 1`] = ` -Array [ - Object { +[ + { "cat": "ws-server,ws-session", "id": 11, "role": "app", "sessionId": "aSession", "trackingId": "app", }, - Object { + { "error": [DetoxRuntimeError: Cannot forward the message to the Detox client. { diff --git a/detox/src/utils/Deferred.test.js b/detox/src/utils/Deferred.test.js index e6bf3ac1ab..629feef92c 100644 --- a/detox/src/utils/Deferred.test.js +++ b/detox/src/utils/Deferred.test.js @@ -105,12 +105,7 @@ describe('Deferred', () => { deferred = Deferred.rejected(new Error('error mock')); expect(deferred.status).toBe(Deferred.REJECTED); - try { - await deferred.promise; - fail(); - } catch (e) { - expect(e.message).toEqual('error mock'); - } + await expect(deferred.promise).rejects.toThrowError('error mock'); }); }); }); diff --git a/detox/src/utils/childProcess/exec.js b/detox/src/utils/childProcess/exec.js index 4cf25c7aa9..c24ca635e8 100644 --- a/detox/src/utils/childProcess/exec.js +++ b/detox/src/utils/childProcess/exec.js @@ -14,9 +14,10 @@ async function execWithRetriesAndLogs(bin, options = {}) { interval = 1000, prefix = null, args = null, - timeout = 0, + timeout, statusLogs = {}, verbosity = 'normal', + maxBuffer, } = options; const trackingId = execsCounter.inc(); @@ -35,7 +36,7 @@ async function execWithRetriesAndLogs(bin, options = {}) { } else if (statusLogs.retrying) { _logExecRetrying(logger, cmd, tryNumber, lastError); } - result = await exec(cmd, { timeout }); + result = await exec(cmd, _.omitBy({ timeout, maxBuffer }, _.isUndefined)); }); } catch (err) { const failReason = err.code == null && timeout > 0 diff --git a/detox/src/utils/childProcess/exec.test.js b/detox/src/utils/childProcess/exec.test.js index 33e5d42e89..cd5bcff9a7 100644 --- a/detox/src/utils/childProcess/exec.test.js +++ b/detox/src/utils/childProcess/exec.test.js @@ -13,6 +13,16 @@ describe('Exec utils', () => { exec = require('./exec'); }); + const execWithRetriesAndLogs = async (command, options) => { + try { + const result = await exec.execWithRetriesAndLogs(command, options); + return result; + } catch (e) { + // Workaround for Jest's expect(...).rejects.toThrowError() not working with thrown plain objects + throw new Error(e); + } + }; + const advanceOpsCounter = (count) => { const opsCounter = require('./opsCounter'); for (let i = 0; i < count; i++) opsCounter.inc(); @@ -20,22 +30,22 @@ describe('Exec utils', () => { it(`exec command with no arguments ends successfully`, async () => { mockCppSuccessful(cpp); - await exec.execWithRetriesAndLogs('bin'); - expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 0 }); + await execWithRetriesAndLogs('bin'); + expect(cpp.exec).toHaveBeenCalledWith(`bin`, {}); }); it(`exec command with arguments ends successfully`, async () => { mockCppSuccessful(cpp); const options = { args: `--argument 123` }; - await exec.execWithRetriesAndLogs('bin', options); + await execWithRetriesAndLogs('bin', options); - expect(cpp.exec).toHaveBeenCalledWith(`bin --argument 123`, { timeout: 0 }); + expect(cpp.exec).toHaveBeenCalledWith(`bin --argument 123`, {}); }); it(`exec command with env-vars pass-through (i.e. no custom env-vars specification`, async () => { mockCppSuccessful(cpp); - await exec.execWithRetriesAndLogs('bin'); + await execWithRetriesAndLogs('bin'); const usedOptions = cpp.exec.mock.calls[0][1]; expect(usedOptions).not.toHaveProperty('env'); expect(cpp.exec).toHaveBeenCalledTimes(1); @@ -48,18 +58,18 @@ describe('Exec utils', () => { args: `--argument 123`, prefix: `export MY_PREFIX` }; - await exec.execWithRetriesAndLogs('bin', options); + await execWithRetriesAndLogs('bin', options); - expect(cpp.exec).toHaveBeenCalledWith(`export MY_PREFIX && bin --argument 123`, { timeout: 0 }); + expect(cpp.exec).toHaveBeenCalledWith(`export MY_PREFIX && bin --argument 123`, {}); }); it(`exec command with prefix (no args) ends successfully`, async () => { mockCppSuccessful(cpp); const options = { prefix: `export MY_PREFIX` }; - await exec.execWithRetriesAndLogs('bin', options); + await execWithRetriesAndLogs('bin', options); - expect(cpp.exec).toHaveBeenCalledWith(`export MY_PREFIX && bin`, { timeout: 0 }); + expect(cpp.exec).toHaveBeenCalledWith(`export MY_PREFIX && bin`, {}); }); it(`exec command log using a custom logger`, async () => { @@ -68,7 +78,7 @@ describe('Exec utils', () => { jest.spyOn(logger, 'child'); mockCppSuccessful(cpp); - await exec.execWithRetriesAndLogs('bin'); + await execWithRetriesAndLogs('bin'); expect(logger.child).toHaveBeenCalledWith({ fn: 'execWithRetriesAndLogs', trackingId, cmd: 'bin' }); }); @@ -84,9 +94,9 @@ describe('Exec utils', () => { successful: 'successful status log', }, }; - await exec.execWithRetriesAndLogs('bin', options); + await execWithRetriesAndLogs('bin', options); - expect(cpp.exec).toHaveBeenCalledWith(`bin --argument 123`, { timeout: 0 }); + expect(cpp.exec).toHaveBeenCalledWith(`bin --argument 123`, {}); expect(logger.debug).toHaveBeenCalledWith({ event: 'EXEC_TRY', retryNumber: 1 }, options.statusLogs.trying); expect(logger.debug).toHaveBeenCalledWith({ event: 'EXEC_TRY', retryNumber: 2 }, options.statusLogs.trying); expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_TRY_FAIL' }, 'error result'); @@ -106,9 +116,9 @@ describe('Exec utils', () => { logger.debug.mockClear(); - await exec.execWithRetriesAndLogs('bin', options); + await execWithRetriesAndLogs('bin', options); - expect(cpp.exec).toHaveBeenCalledWith(`bin --argument 123`, { timeout: 0 }); + expect(cpp.exec).toHaveBeenCalledWith(`bin --argument 123`, {}); expect(logger.debug).toHaveBeenCalledWith({ event: 'EXEC_RETRY' }, '(Retry #1)', 'bin --argument 123'); expect(logger.debug).not.toHaveBeenCalledWith({ event: 'EXEC_RETRY' }, expect.stringContaining('Retry #0'), expect.any(String)); expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_TRY_FAIL' }, 'error result'); @@ -116,7 +126,7 @@ describe('Exec utils', () => { it(`exec command should output success and err logs`, async () => { mockCppSuccessful(cpp); - await exec.execWithRetriesAndLogs('bin'); + await execWithRetriesAndLogs('bin'); expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stdout: true }, '"successful result"'); expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stderr: true }, 'err'); @@ -130,7 +140,7 @@ describe('Exec utils', () => { }; cpp.exec.mockResolvedValueOnce(cppResult); - await exec.execWithRetriesAndLogs('bin'); + await execWithRetriesAndLogs('bin'); expect(logger.trace).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS' }, ''); expect(logger.trace).toHaveBeenCalledTimes(1); @@ -138,7 +148,7 @@ describe('Exec utils', () => { it(`exec command should output success with high severity if verbosity set to high`, async () => { mockCppSuccessful(cpp); - await exec.execWithRetriesAndLogs('bin', { verbosity: 'high' }); + await execWithRetriesAndLogs('bin', { verbosity: 'high' }); expect(logger.debug).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stdout: true }, '"successful result"'); expect(logger.debug).toHaveBeenCalledWith({ event: 'EXEC_SUCCESS', stderr: true }, 'err'); @@ -147,50 +157,40 @@ describe('Exec utils', () => { it(`exec command with undefined return should throw`, async () => { cpp.exec.mockReturnValueOnce(undefined); - try { - await exec.execWithRetriesAndLogs('bin'); - fail('should throw'); - } catch (ex) { - expect(ex).toBeDefined(); - } + await expect(execWithRetriesAndLogs('bin')).rejects.toThrowError(); }); it(`exec command and fail with error code`, async () => { mockCppFailure(cpp); - try { - await exec.execWithRetriesAndLogs('bin', { retries: 0, interval: 1 }); - fail('expected execWithRetriesAndLogs() to throw'); - } catch (object) { - expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 0 }); - expect(logger.error.mock.calls).toHaveLength(3); - expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: 'EXEC_FAIL' }), expect.anything()); - } + await expect(execWithRetriesAndLogs('bin', { retries: 0, interval: 1 })).rejects.toThrowError(); + expect(cpp.exec).toHaveBeenCalledWith(`bin`, {}); + expect(logger.error.mock.calls).toHaveLength(3); + expect(logger.error).toHaveBeenCalledWith(expect.objectContaining({ event: 'EXEC_FAIL' }), expect.anything()); }); it(`exec command and fail with error code, report only to debug log if verbosity is low`, async () => { mockCppFailure(cpp); - try { - await exec.execWithRetriesAndLogs('bin', { verbosity: 'low', retries: 0, interval: 1 }); - fail('expected execWithRetriesAndLogs() to throw'); - } catch (object) { - expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 0 }); - expect(logger.error).not.toHaveBeenCalled(); - expect(logger.debug.mock.calls).toHaveLength(4); - } + await expect(execWithRetriesAndLogs('bin', { verbosity: 'low', retries: 0, interval: 1 })).rejects.toThrowError(); + expect(cpp.exec).toHaveBeenCalledWith(`bin`, {}); + expect(logger.error).not.toHaveBeenCalled(); + expect(logger.debug.mock.calls).toHaveLength(4); }); it(`exec command and fail with timeout`, async () => { mockCppFailure(cpp); - try { - await exec.execWithRetriesAndLogs('bin', { timeout: 1, retries: 0, interval: 1 }); - fail('expected execWithRetriesAndLogs() to throw'); - } catch (object) { - expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 1 }); - expect(logger.error.mock.calls).toHaveLength(3); - } + await expect(execWithRetriesAndLogs('bin', { timeout: 1, retries: 0, interval: 1 })).rejects.toThrowError(); + expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 1 }); + expect(logger.error.mock.calls).toHaveLength(3); + }); + + it(`exec command with a given maxBuffer`, async () => { + mockCppSuccessful(cpp); + + await execWithRetriesAndLogs('bin', { maxBuffer: 1000 }); + expect(cpp.exec).toHaveBeenCalledWith(`bin`, { maxBuffer: 1000 }); }); it(`exec command with multiple failures`, async () => { @@ -203,14 +203,9 @@ describe('Exec utils', () => { .mockRejectedValueOnce(errorResult) .mockRejectedValueOnce(errorResult); - try { - await exec.execWithRetriesAndLogs('bin', { retries: 5, interval: 1 }); - fail('expected execWithRetriesAndLogs() to throw'); - } catch (object) { - expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 0 }); - expect(cpp.exec).toHaveBeenCalledTimes(6); - expect(object).toBeDefined(); - } + await expect(execWithRetriesAndLogs('bin', { retries: 5, interval: 1 })).rejects.toThrowError(); + expect(cpp.exec).toHaveBeenCalledWith(`bin`, {}); + expect(cpp.exec).toHaveBeenCalledTimes(6); }); it(`exec command with multiple failures and then a success`, async () => { @@ -225,8 +220,8 @@ describe('Exec utils', () => { .mockRejectedValueOnce(errorResult) .mockResolvedValueOnce(successfulResult); - await exec.execWithRetriesAndLogs('bin', { retries: 6, interval: 1 }); - expect(cpp.exec).toHaveBeenCalledWith(`bin`, { timeout: 0 }); + await execWithRetriesAndLogs('bin', { retries: 6, interval: 1 }); + expect(cpp.exec).toHaveBeenCalledWith(`bin`, {}); expect(cpp.exec).toHaveBeenCalledTimes(6); }); diff --git a/detox/src/utils/childProcess/spawn.js b/detox/src/utils/childProcess/spawn.js index b0f0ed30da..bd803d853f 100644 --- a/detox/src/utils/childProcess/spawn.js +++ b/detox/src/utils/childProcess/spawn.js @@ -45,7 +45,7 @@ async function interruptProcess(childProcessPromise, schedule) { const childProcess = childProcessPromise.childProcess; const cpid = childProcess.pid; const spawnargs = childProcess.spawnargs.join(' '); - const log = rootLogger.child({ event: 'SPAWN_KILL', pid: cpid }); + const log = rootLogger.child({ event: 'SPAWN_KILL', cpid }); const handles = _.mapValues({ ...DEFAULT_KILL_SCHEDULE, ...schedule }, (ms, signal) => { return setTimeout(() => { diff --git a/detox/src/utils/invocationTraceDescriptions.js b/detox/src/utils/invocationTraceDescriptions.js index a9168327ae..63969bf635 100644 --- a/detox/src/utils/invocationTraceDescriptions.js +++ b/detox/src/utils/invocationTraceDescriptions.js @@ -12,8 +12,9 @@ module.exports = { pinchWithAngle: (direction, speed, angle) => `pinch with direction ${direction}, speed ${speed}, and angle ${angle}`, replaceText: (value) => `replace input text: "${value}"`, scroll: (amount, direction, startPositionX, startPositionY) => - `scroll ${amount} pixels ${direction}${startPositionX !== undefined && startPositionY !== undefined ? ` from normalized position (${startPositionX}, ${startPositionY})` : ''}`, - scrollTo: (edge) => `scroll to ${edge}`, + `scroll ${amount} pixels ${direction}${startPositionX !== undefined || startPositionY !== undefined ? ` from normalized position (${startPositionX}, ${startPositionY})` : ''}`, + scrollTo: (edge, startPositionX, startPositionY) => + `scroll to ${edge} ${startPositionX !== undefined || startPositionY !== undefined ? ` from normalized position (${startPositionX}, ${startPositionY})` : ''}`, scrollToIndex: (index) => `scroll to index #${index}`, setColumnToValue: (column, value) => `set column ${column} to value ${value}`, setDatePickerDate: (dateString, dateFormat) => `set date picker date to ${dateString} using format ${dateFormat}`, diff --git a/detox/src/utils/retry.test.js b/detox/src/utils/retry.test.js index e00ebdd49d..82baa6648f 100644 --- a/detox/src/utils/retry.test.js +++ b/detox/src/utils/retry.test.js @@ -4,12 +4,12 @@ describe('retry', () => { const mockFailingUserFn = () => jest.fn().mockReturnValue(Promise.reject(new Error('a thing'))); const mockFailingOnceUserFn = () => jest.fn() - .mockReturnValueOnce(Promise.reject()) - .mockReturnValueOnce(Promise.resolve()); + .mockRejectedValueOnce(new Error('once')) + .mockReturnValueOnce(); const mockFailingTwiceUserFn = () => jest.fn() - .mockReturnValueOnce(Promise.reject(new Error('once'))) - .mockReturnValueOnce(Promise.reject(new Error('twice'))) - .mockReturnValueOnce(Promise.resolve()); + .mockRejectedValueOnce(new Error('once')) + .mockRejectedValueOnce(new Error('twice')) + .mockReturnValueOnce(); beforeEach(() => { jest.mock('./sleep', () => jest.fn().mockReturnValue(Promise.resolve())); @@ -21,11 +21,7 @@ describe('retry', () => { it('should retry once over a function that fails once', async () => { const mockFn = mockFailingOnceUserFn(); - try { - await retry({ retries: 999, interval: 0 }, mockFn); - } catch (e) { - fail('expected retry not to fail'); - } + await expect(retry({ retries: 999, interval: 0 }, mockFn)).resolves.not.toThrow(); expect(mockFn).toHaveBeenCalledTimes(2); }); @@ -33,11 +29,7 @@ describe('retry', () => { it('should sleep before calling a function if initialSleep is set', async () => { const mockFn = jest.fn(); - try { - await retry({ initialSleep: 1234, retries: 999, interval: 0 }, mockFn); - } catch (e) { - fail('expected retry not to fail'); - } + await expect(retry({ initialSleep: 1234, retries: 999, interval: 0 }, mockFn)).resolves.not.toThrow(); expect(mockFn).toHaveBeenCalledTimes(1); expect(sleep).toHaveBeenCalledTimes(1); @@ -47,16 +39,7 @@ describe('retry', () => { it('should call sleep() with { shouldUnref: true } if set', async () => { const mockFn = mockFailingTwiceUserFn(); - try { - await retry({ - initialSleep: 1000, - retries: 2, - interval: 0, - shouldUnref: true, - }, mockFn); - } catch (e) { - fail('expected retry not to fail'); - } + await expect(retry({ initialSleep: 1000, retries: 2, interval: 0, shouldUnref: true, }, mockFn)).resolves.not.toThrow(); expect(mockFn).toHaveBeenCalledTimes(3); expect(sleep).toHaveBeenCalledTimes(3); @@ -80,23 +63,14 @@ describe('retry', () => { it('should adhere to retries parameter', async () => { const mockFn = mockFailingUserFn(); - try { - await retry({ retries: 2, interval: 1 }, mockFn); - fail('expected retry to fail and throw'); - } catch (error) { - expect(mockFn).toHaveBeenCalledTimes(3); - expect(error).toBeDefined(); - } + await expect(retry({ retries: 2, interval: 1 }, mockFn)).rejects.toThrowError(); }); it('should adhere to interval parameter, and sleep for increasingly long intervals (i.e. the default backoff mode)', async () => { const mockFn = mockFailingUserFn(); const baseInterval = 111; - try { - await retry({ retries: 2, interval: baseInterval }, mockFn); - fail('expected retry to fail and throw'); - } catch (error) {} + await expect(retry({ retries: 2, interval: baseInterval }, mockFn)).rejects.toThrowError(); expect(sleep).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenCalledWith(baseInterval, undefined); @@ -112,10 +86,7 @@ describe('retry', () => { backoff: 'none', }; - try { - await retry(options, mockFn); - fail('expected retry to fail and throw'); - } catch (error) {} + await expect(retry(options, mockFn)).rejects.toThrow(); expect(sleep).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenNthCalledWith(1, baseInterval, undefined); @@ -131,10 +102,7 @@ describe('retry', () => { backoff: 'linear', }; - try { - await retry(options, mockFn); - fail('expected retry to fail and throw'); - } catch (error) {} + await expect(retry(options, mockFn)).rejects.toThrow(); expect(sleep).toHaveBeenCalledTimes(2); expect(sleep).toHaveBeenCalledWith(baseInterval, undefined); @@ -147,10 +115,7 @@ describe('retry', () => { .mockReturnValueOnce(true) .mockReturnValueOnce(false); - try { - await retry({ retries: 999, interval: 1, conditionFn }, mockFn); - fail('expected retry to fail and throw'); - } catch (error) {} + await expect(retry({ retries: 999, interval: 1, conditionFn }, mockFn)).rejects.toThrowError(); expect(mockFn).toHaveBeenCalledTimes(2); }); @@ -160,10 +125,7 @@ describe('retry', () => { const defaultRetries = 9; const defaultInterval = 500; - try { - await retry(mockFn); - fail('expected retry to fail and throw'); - } catch (error) {} + await expect(retry(mockFn)).rejects.toThrowError(); expect(mockFn).toHaveBeenCalledTimes(defaultRetries + 1); expect(sleep).toHaveBeenCalledWith(defaultInterval, undefined); diff --git a/detox/test/e2e/utils/rn-consts.js b/detox/src/utils/rn-consts/rn-consts.js similarity index 100% rename from detox/test/e2e/utils/rn-consts.js rename to detox/src/utils/rn-consts/rn-consts.js diff --git a/detox/test/.eslintrc.js b/detox/test/.eslintrc.js index a8cb5f932a..997e2b602a 100644 --- a/detox/test/.eslintrc.js +++ b/detox/test/.eslintrc.js @@ -1,7 +1,7 @@ module.exports = { root: true, extends: [ - '@react-native-community', + '@react-native', ], plugins: [ 'unicorn', @@ -16,8 +16,7 @@ module.exports = { // disabled due to styling conflicts between eslint and prettier 'prettier/prettier': 0, - // TODO: enable this with argsIgnorePattern - '@typescript-eslint/no-unused-vars': 0, // ['error', {argsIgnorePattern: '^_'}], + '@typescript-eslint/no-unused-vars': ['error', {argsIgnorePattern: '^_'}], // TODO: enable these rules gradually 'comma-dangle': 0, @@ -26,7 +25,6 @@ module.exports = { 'eqeqeq': 0, 'jsx-quotes': 0, 'keyword-spacing': 0, - 'no-extra-semi': 0, 'no-sequences': 0, 'no-trailing-spaces': 0, 'no-useless-escape': 0, diff --git a/detox/test/android/app/build.gradle b/detox/test/android/app/build.gradle index 022f8e8cb7..8eba840a8a 100644 --- a/detox/test/android/app/build.gradle +++ b/detox/test/android/app/build.gradle @@ -1,23 +1,33 @@ apply plugin: 'com.android.application' +apply plugin: 'com.facebook.react' +apply plugin: 'kotlin-android' apply from: '../../../android/rninfo.gradle' -if (rnInfo.isRN71OrHigher) { - apply plugin: 'com.facebook.react' -} else { - project.ext.react = [ - enableHermes: true - ] - apply from: '../../node_modules/react-native/react.gradle' -} android { namespace 'com.example' - compileSdkVersion rootProject.ext.compileSdkVersion + buildToolsVersion = rootProject.ext.buildToolsVersion + compileSdk = rootProject.ext.compileSdkVersion + + if (rnInfo.isRN72OrHigher) { + compileOptions { + sourceCompatibility JavaVersion.VERSION_17 + targetCompatibility JavaVersion.VERSION_17 + } + + kotlinOptions { + jvmTarget = '17' + } + } else { + compileOptions { + sourceCompatibility JavaVersion.VERSION_11 + targetCompatibility JavaVersion.VERSION_11 + } - compileOptions { - sourceCompatibility JavaVersion.VERSION_1_8 - targetCompatibility JavaVersion.VERSION_1_8 + kotlinOptions { + jvmTarget = '11' + } } defaultConfig { @@ -61,19 +71,9 @@ android { release { minifyEnabled true proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro', "${project(':detox').projectDir}/proguard-rules-app.pro" - signingConfig signingConfigs.release } } - productFlavors { - flavorDimensions 'reactNativeVersion' - rnDefault { - dimension 'reactNativeVersion' - } - rnLegacy { // For RN < 71 - dimension 'reactNativeVersion' - } - } packagingOptions { pickFirst '**/libc++_shared.so' @@ -83,16 +83,19 @@ android { exclude 'META-INF/LICENSE.txt' exclude 'META-INF/NOTICE.txt' } + + buildFeatures { + buildConfig = true + } } dependencies { // The version of react-native is set by the React Native Gradle Plugin - rnDefaultImplementation('com.facebook.react:react-android') - // noinspection GradleDynamicVersion - rnLegacyImplementation('com.facebook.react:react-native:+') + implementation('com.facebook.react:react-android') implementation "androidx.appcompat:appcompat:${rootProject.ext.appCompatVersion}" implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0' + implementation project(path: ':@react-native-community_slider') implementation project(':AsyncStorage') implementation project(':react-native-webview') @@ -100,6 +103,7 @@ dependencies { implementation project(':react-native-community-geolocation') implementation project(':react-native-datetimepicker') implementation project(':react-native-launcharguments') + implementation project(':react-native-permissions') androidTestImplementation(project(path: ':detox')) androidTestImplementation 'com.linkedin.testbutler:test-butler-library:2.2.1' @@ -107,11 +111,7 @@ dependencies { // Apply Hermes as our JS engine dependencies { - rnDefaultImplementation('com.facebook.react:hermes-android') - // noinspection GradleDynamicVersion - rnLegacyImplementation('com.facebook.react:hermes-engine:+') { - exclude group: 'com.facebook.fbjni' - } + implementation('com.facebook.react:hermes-android') } // Run this once to be able to run the application with BUCK diff --git a/detox/test/android/app/src/rnDefault/java/com/example/DetoxRNHost.java b/detox/test/android/app/src/main/java/com/example/DetoxRNHost.java similarity index 100% rename from detox/test/android/app/src/rnDefault/java/com/example/DetoxRNHost.java rename to detox/test/android/app/src/main/java/com/example/DetoxRNHost.java diff --git a/detox/test/android/app/src/main/java/com/example/ReactNativeAdapter.java b/detox/test/android/app/src/main/java/com/example/ReactNativeAdapter.java index e0d2552500..fe61cee526 100644 --- a/detox/test/android/app/src/main/java/com/example/ReactNativeAdapter.java +++ b/detox/test/android/app/src/main/java/com/example/ReactNativeAdapter.java @@ -9,6 +9,7 @@ import com.reactnativecommunity.slider.ReactSliderPackage; import com.reactnativecommunity.webview.RNCWebViewPackage; import com.reactnativelauncharguments.LaunchArgumentsPackage; +import com.zoontek.rnpermissions.RNPermissionsPackage; import java.util.Arrays; import java.util.List; @@ -24,7 +25,8 @@ public static List getManualLinkPackages() { new AsyncStoragePackage(), new ReactCheckBoxPackage(), new RNDateTimePickerPackage(), - new LaunchArgumentsPackage() + new LaunchArgumentsPackage(), + new RNPermissionsPackage() ); } } diff --git a/detox/test/android/app/src/rnLegacy/java/com/example/DetoxRNHost.java b/detox/test/android/app/src/rnLegacy/java/com/example/DetoxRNHost.java deleted file mode 100644 index 1fc65e9d3c..0000000000 --- a/detox/test/android/app/src/rnLegacy/java/com/example/DetoxRNHost.java +++ /dev/null @@ -1,30 +0,0 @@ -package com.example; - -import android.app.Application; - -import com.facebook.react.ReactPackage; -import com.facebook.react.ReactNativeHost; - -import java.util.List; - -class DetoxRNHost extends ReactNativeHost { - protected DetoxRNHost(Application application) { - super(application); - } - - @Override - public boolean getUseDeveloperSupport() { - return BuildConfig.DEBUG; - } - - @Override - protected List getPackages() { - // Packages that cannot be autolinked yet can be added manually here, for example: - // packages.add(new MyReactNativePackage()); - // TurboModules must also be loaded here providing a valid TurboReactPackage implementation: - // packages.add(new TurboReactPackage() { ... }); - // If you have custom Fabric Components, their ViewManagers should also be loaded here - // inside a ReactPackage. - return ReactNativeAdapter.getManualLinkPackages(); - } -} diff --git a/detox/test/android/build.gradle b/detox/test/android/build.gradle index cbcbe1ea34..be7799e17e 100644 --- a/detox/test/android/build.gradle +++ b/detox/test/android/build.gradle @@ -1,22 +1,14 @@ buildscript { apply from: '../../android/rninfo.gradle' - def androidGradlePluginVersion = - rnInfo.isRN71OrHigher ? '7.3.1' : - rnInfo.isRN70OrHigher ? '7.2.1' : - rnInfo.isRN69OrHigher ? '7.1.1' : - '7.0.4' - - println "[$project] Resorted to Android Gradle-plugin version $androidGradlePluginVersion" - ext { isOfficialDetoxApp = true - kotlinVersion = '1.6.21' - buildToolsVersion = '33.0.0' - compileSdkVersion = 33 - targetSdkVersion = 33 + kotlinVersion = '1.8.0' + buildToolsVersion = '34.0.0' + compileSdkVersion = 34 + targetSdkVersion = 34 minSdkVersion = 21 - appCompatVersion = '1.4.2' + appCompatVersion = '1.6.1' } ext.detoxKotlinVersion = ext.kotlinVersion @@ -27,18 +19,8 @@ buildscript { } dependencies { - classpath "com.android.tools.build:gradle:$androidGradlePluginVersion" - - // In RN .71, they've switched to the gradle plugin they've uploaded to maven-central - if (rnInfo.isRN71OrHigher) { - classpath 'com.facebook.react:react-native-gradle-plugin' - } - - // Gradle task downloader seems to come built-in in newer versions of RN/Gradle - if (!rnInfo.isRN71OrHigher) { - classpath 'de.undercouch:gradle-download-task:5.0.1' - } - + classpath "com.android.tools.build:gradle" + classpath 'com.facebook.react:react-native-gradle-plugin' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" // Needed by Spek (https://spekframework.org/setup-android) @@ -49,23 +31,8 @@ buildscript { allprojects { repositories { - // In RN below 71, we want RN and hermes/js-core native code to come from node_modules/ rather - // than from maven-central, because there are also older versions over there. - if (rnInfo.isRN71OrHigher) { - google() - mavenCentral() - } else { - maven { - url "$rootDir/../../node_modules/react-native/android" - } - google() - mavenCentral() { - content { - excludeGroup 'com.facebook.react' - } - } - } - + google() + mavenCentral() mavenLocal() } } @@ -79,3 +46,7 @@ subprojects { } } } + +if (ext.rnInfo.isRN73OrHigher) { + apply plugin: "com.facebook.react.rootproject" +} diff --git a/detox/test/android/gradle/wrapper/gradle-wrapper.jar b/detox/test/android/gradle/wrapper/gradle-wrapper.jar index 7454180f2a..7f93135c49 100644 Binary files a/detox/test/android/gradle/wrapper/gradle-wrapper.jar and b/detox/test/android/gradle/wrapper/gradle-wrapper.jar differ diff --git a/detox/test/android/gradle/wrapper/gradle-wrapper.properties b/detox/test/android/gradle/wrapper/gradle-wrapper.properties index e81f2af1a1..ac72c34e8a 100644 --- a/detox/test/android/gradle/wrapper/gradle-wrapper.properties +++ b/detox/test/android/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,7 @@ -#Thu Feb 09 21:36:01 IST 2023 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.6.1-bin.zip distributionPath=wrapper/dists -zipStorePath=wrapper/dists +distributionUrl=https\://services.gradle.org/distributions/gradle-8.3-bin.zip +networkTimeout=10000 +validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME +zipStorePath=wrapper/dists diff --git a/detox/test/android/gradlew b/detox/test/android/gradlew index 3447eeb0cb..0adc8e1a53 100755 --- a/detox/test/android/gradlew +++ b/detox/test/android/gradlew @@ -1,4 +1,4 @@ -#!/usr/bin/env bash +#!/bin/sh # # Copyright ยฉ 2015-2021 the original authors. @@ -55,7 +55,7 @@ # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template -# https://github.com/gradle/gradle/blob/master/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt +# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. @@ -80,13 +80,11 @@ do esac done -APP_HOME=$( cd "${APP_HOME:-./}" && pwd -P ) || exit - -APP_NAME="Gradle" +# This is normally unused +# shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} - -# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' +# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) +APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum @@ -133,22 +131,29 @@ location of your Java installation." fi else JAVACMD=java - which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + if ! command -v java >/dev/null 2>&1 + then + die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." + fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) + # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) + # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. + # shellcheck disable=SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac @@ -193,6 +198,10 @@ if "$cygwin" || "$msys" ; then done fi + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + # Collect all arguments for the java command; # * $DEFAULT_JVM_OPTS, $JAVA_OPTS, and $GRADLE_OPTS can contain fragments of # shell script including quotes and variable substitutions, so put them in @@ -205,6 +214,12 @@ set -- \ org.gradle.wrapper.GradleWrapperMain \ "$@" +# Stop when "xargs" is not available. +if ! command -v xargs >/dev/null 2>&1 +then + die "xargs is not available" +fi + # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. diff --git a/detox/test/android/gradlew.bat b/detox/test/android/gradlew.bat index aec99730b4..6689b85bee 100644 --- a/detox/test/android/gradlew.bat +++ b/detox/test/android/gradlew.bat @@ -1,4 +1,20 @@ -@if "%DEBUG%" == "" @echo off +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @@ -8,20 +24,24 @@ @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal -@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. -set DEFAULT_JVM_OPTS= - set DIRNAME=%~dp0 -if "%DIRNAME%" == "" set DIRNAME=. +if "%DIRNAME%"=="" set DIRNAME=. +@rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 -if "%ERRORLEVEL%" == "0" goto init +if %ERRORLEVEL% equ 0 goto execute echo. echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. @@ -35,7 +55,7 @@ goto fail set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe -if exist "%JAVA_EXE%" goto init +if exist "%JAVA_EXE%" goto execute echo. echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% @@ -45,44 +65,26 @@ echo location of your Java installation. goto fail -:init -@rem Get command-line arguments, handling Windowz variants - -if not "%OS%" == "Windows_NT" goto win9xME_args -if "%@eval[2+2]" == "4" goto 4NT_args - -:win9xME_args -@rem Slurp the command line arguments. -set CMD_LINE_ARGS= -set _SKIP=2 - -:win9xME_args_slurp -if "x%~1" == "x" goto execute - -set CMD_LINE_ARGS=%* -goto execute - -:4NT_args -@rem Get arguments from the 4NT Shell from JP Software -set CMD_LINE_ARGS=%$ - :execute @rem Setup the command line set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + @rem Execute Gradle -"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* :end @rem End local scope for the variables with windows NT shell -if "%ERRORLEVEL%"=="0" goto mainEnd +if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! -if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 -exit /b 1 +set EXIT_CODE=%ERRORLEVEL% +if %EXIT_CODE% equ 0 set EXIT_CODE=1 +if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% +exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal diff --git a/detox/test/android/settings.gradle b/detox/test/android/settings.gradle index ca5d4b00fc..118655f99b 100644 --- a/detox/test/android/settings.gradle +++ b/detox/test/android/settings.gradle @@ -1,7 +1,17 @@ +apply from: file("../../android/rninfo.gradle") rootProject.name = 'DetoxTest' include ':app' -includeBuild('../node_modules/react-native-gradle-plugin') +def rnMajorVer = getRnMajorVersion(rootDir) +println "[settings] RNInfo: detected React Native version: (major=$rnMajorVer)" + +if (rnMajorVer < 72) { + includeBuild('../node_modules/react-native-gradle-plugin') +} else { + includeBuild('../node_modules/@react-native/gradle-plugin') +} + + include ':detox' project(':detox').projectDir = new File(rootProject.projectDir, '../../android/detox') @@ -26,3 +36,6 @@ project(':react-native-datetimepicker').projectDir = new File(rootProject.projec include ':react-native-launcharguments' project(':react-native-launcharguments').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-launch-arguments/android') + +include ':react-native-permissions' +project(':react-native-permissions').projectDir = new File(rootProject.projectDir, '../node_modules/react-native-permissions/android') diff --git a/detox/test/e2e/01.sanity.test.js b/detox/test/e2e/01.sanity.test.js index d99fb8296a..dbd65f6126 100644 --- a/detox/test/e2e/01.sanity.test.js +++ b/detox/test/e2e/01.sanity.test.js @@ -1,3 +1,13 @@ +/** + * Sanity suite + * ------------ + * + * Tests in this suite ensure that the most basic functionality of Detox is + * working as expected: app launches, element matching, assertions, etc. + * + * @severity critical + * @tag sanity + */ describe('Sanity', () => { beforeEach(async () => { await device.reloadReactNative(); diff --git a/detox/test/e2e/03.actions-scroll.test.js b/detox/test/e2e/03.actions-scroll.test.js index e4cb32f444..6615d6ea4b 100644 --- a/detox/test/e2e/03.actions-scroll.test.js +++ b/detox/test/e2e/03.actions-scroll.test.js @@ -46,6 +46,32 @@ describe('Actions - Scroll', () => { await expect(element(by.text('HText1'))).toBeVisible(); }); + it('should scroll to edge from a custom start-position ratio', async () => { + await expect(element(by.text('Text12'))).not.toBeVisible(); + await element(by.id('toggleScrollOverlays')).tap(); + await element(by.id('ScrollView161')).scrollTo('bottom', 0.2, 0.4); + await element(by.id('toggleScrollOverlays')).tap(); + await expect(element(by.text('Text12'))).toBeVisible(); + + await element(by.id('toggleScrollOverlays')).tap(); + await element(by.id('ScrollView161')).scrollTo('top', 0.8, 0.6); + await element(by.id('toggleScrollOverlays')).tap(); + await expect(element(by.text('Text1'))).toBeVisible(); + }); + + it('should scroll to edge horizontally from a custom start-position ratio', async () => { + await expect(element(by.text('HText8'))).not.toBeVisible(); + await element(by.id('toggleScrollOverlays')).tap(); + await element(by.id('ScrollViewH')).scrollTo('right', 0.8, 0.6); + await element(by.id('toggleScrollOverlays')).tap(); + await expect(element(by.text('HText8'))).toBeVisible(); + + await element(by.id('toggleScrollOverlays')).tap(); + await element(by.id('ScrollViewH')).scrollTo('left',0.2, 0.4); + await element(by.id('toggleScrollOverlays')).tap(); + await expect(element(by.text('HText1'))).toBeVisible(); + }); + it('should scroll from a custom start-position ratio', async () => { await expect(element(by.text('Text12'))).not.toBeVisible(); await element(by.id('toggleScrollOverlays')).tap(); diff --git a/detox/test/e2e/03.actions.test.js b/detox/test/e2e/03.actions.test.js index 5ef6698e08..315db569f0 100644 --- a/detox/test/e2e/03.actions.test.js +++ b/detox/test/e2e/03.actions.test.js @@ -1,4 +1,5 @@ const driver = require('./drivers/actions-driver').actionsScreenDriver; +const custom = require('./utils/custom-it'); describe('Actions', () => { beforeEach(async () => { @@ -212,7 +213,7 @@ describe('Actions', () => { await expect(element(by.id('UniqueId007'))).toBeVisible(); }); - it('should adjust legacy slider and assert its value', async () => { + custom.it.skipFromRNVersion(71)('should adjust legacy slider and assert its value', async () => { const reactSliderId = 'legacySliderWithASimpleID'; await expect(element(by.id(reactSliderId))).toHaveSliderPosition(0.25); await element(by.id(reactSliderId)).adjustSliderToPosition(0.75); diff --git a/detox/test/e2e/13.permissions.test.js b/detox/test/e2e/13.permissions.test.js index 572057e7b0..f2b443e21c 100644 --- a/detox/test/e2e/13.permissions.test.js +++ b/detox/test/e2e/13.permissions.test.js @@ -1,14 +1,267 @@ +const { RESULTS } = require('react-native-permissions'); + +const BASIC_PERMISSIONS_TO_CHECK = [ + 'userTracking', + 'calendar', + 'camera', + 'contacts', + 'microphone', + 'reminders', + 'siri', + 'speech', + 'medialibrary' +]; + +const LOCATION_ALWAYS = 'location_always'; +const LOCATION_WHEN_IN_USE = 'location_when_in_use'; + +const PHOTO_LIBRARY = 'photo_library'; +const PHOTO_LIBRARY_ADD_ONLY = 'photo_library_add_only'; + describe(':ios: Permissions', () => { + BASIC_PERMISSIONS_TO_CHECK.forEach((name) => { + describe(name, () => { + const authorizationStatus = element(by.id(name)); - it('Permissions is granted', async () => { - await device.launchApp({permissions: {calendar: 'YES'}, newInstance: true}); - await element(by.text('Permissions')).tap(); - await expect(element(by.text('granted'))).toBeVisible(); + it('should find element with test-id: ' + name, async () => { + await device.launchApp({delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(authorizationStatus).toBeVisible(); + }); + + it('should show default permissions when undefined', async () => { + await device.launchApp({delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(authorizationStatus).toHaveText(RESULTS.DENIED); + }); + + it('should show default permissions when defined to `unset`', async () => { + const permissions = {[name]: 'unset'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(authorizationStatus).toHaveText(RESULTS.DENIED); + }); + + it('should grant permission', async () => { + const permissions = {[name]: 'YES'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(authorizationStatus).toHaveText('granted'); + }); + + it('should block permissions', async () => { + const permissions = {[name]: 'NO'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(authorizationStatus).toHaveText(RESULTS.BLOCKED); + }); + }); + }); + + describe("location", () => { + const locationAlways = element(by.id(LOCATION_ALWAYS)); + const locationInuse = element(by.id(LOCATION_WHEN_IN_USE)); + + it('should find status elements', async () => { + await device.launchApp({delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(locationAlways).toBeVisible(); + await expect(locationInuse).toBeVisible(); + }); + + it('should show default permissions when undefined', async () => { + await device.launchApp({delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(locationAlways).toHaveText(RESULTS.DENIED); + await expect(locationInuse).toHaveText(RESULTS.DENIED); + }); + + it('should show default permissions when defined to `unset`', async () => { + const permissions = {location: 'unset'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(locationAlways).toHaveText(RESULTS.DENIED); + await expect(locationInuse).toHaveText(RESULTS.DENIED); + }); + + it('should grant permission `inuse`', async () => { + const permissions = {location: 'inuse'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(locationAlways).toHaveText(RESULTS.BLOCKED); + await expect(locationInuse).toHaveText(RESULTS.GRANTED); + }); + + it('should grant permission `always`', async () => { + const permissions = {location: 'always'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(locationAlways).toHaveText(RESULTS.GRANTED); + await expect(locationInuse).toHaveText(RESULTS.GRANTED); + }); + + it('should block permissions', async () => { + const permissions = {location: 'never'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(locationAlways).toHaveText(RESULTS.BLOCKED); + await expect(locationInuse).toHaveText(RESULTS.BLOCKED); + }); }); - it('Permissions denied', async () => { - await device.launchApp({permissions: {calendar: 'NO'}, newInstance: true}); + describe("faceid", () => { + const faceid = element(by.id('faceid')); + + it('should find status elements', async () => { + await device.launchApp({ delete: true }); + await element(by.text('Permissions')).tap(); + + await expect(faceid).toBeVisible(); + }); + + it('should get unavailable status when biometrics are not enrolled', async () => { + await device.setBiometricEnrollment(false); + + await device.launchApp({ delete: true }); + await element(by.text('Permissions')).tap(); + + await expect(faceid).toHaveText(RESULTS.UNAVAILABLE); + }); + + describe("when biometrics are enrolled", () => { + beforeEach(async () => { + await device.setBiometricEnrollment(true); + }); + + it('should show default permissions when undefined', async () => { + await device.launchApp({ delete: true }); + await element(by.text('Permissions')).tap(); + + await expect(faceid).toHaveText(RESULTS.DENIED); + }); + + it('should show default permissions when defined to `unset`', async () => { + const permissions = { faceid: 'unset' }; + + await device.launchApp({ permissions, delete: true }); + await element(by.text('Permissions')).tap(); + + await expect(faceid).toHaveText(RESULTS.DENIED); + }); + + // todo: Skipped due to an error coming from react-native-permissions. Fix or implement a custom check. + it.skip('should grant permission', async () => { + const permissions = { faceid: 'YES' }; + + await device.launchApp({ permissions, delete: true }); + await element(by.text('Permissions')).tap(); + + await expect(faceid).toHaveText('granted'); + }); + + it('should block permissions', async () => { + const permissions = { faceid: 'NO' }; + + await device.launchApp({ permissions, delete: true }); + await element(by.text('Permissions')).tap(); + + await expect(faceid).toHaveText(RESULTS.BLOCKED); + }); + }); + }); + + describe("photos", () => { + const photoLibrary = element(by.id(PHOTO_LIBRARY)); + const photoLibraryAddOnly = element(by.id(PHOTO_LIBRARY_ADD_ONLY)); + + it('should find status elements', async () => { + await device.launchApp({delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(photoLibrary).toBeVisible(); + await expect(photoLibraryAddOnly).toBeVisible(); + }); + + it('should show default permissions when undefined', async () => { + await device.launchApp({delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(photoLibrary).toHaveText(RESULTS.DENIED); + await expect(photoLibraryAddOnly).toHaveText(RESULTS.DENIED); + }); + + it('should show default permissions when defined to `unset`', async () => { + const permissions = {photos: 'unset'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(photoLibrary).toHaveText(RESULTS.DENIED); + await expect(photoLibraryAddOnly).toHaveText(RESULTS.DENIED); + }); + + it('should grant permission `limited`', async () => { + const permissions = {photos: 'limited'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(photoLibrary).toHaveText(RESULTS.DENIED); + await expect(photoLibraryAddOnly).toHaveText(RESULTS.GRANTED); + }); + + it('should grant permission', async () => { + const permissions = {photos: 'YES'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(photoLibrary).toHaveText(RESULTS.GRANTED); + await expect(photoLibraryAddOnly).toHaveText(RESULTS.GRANTED); + }); + + it('should block permissions', async () => { + const permissions = {photos: 'NO'}; + + await device.launchApp({permissions, delete: true}); + await element(by.text('Permissions')).tap(); + + await expect(photoLibrary).toHaveText(RESULTS.BLOCKED); + await expect(photoLibraryAddOnly).toHaveText(RESULTS.DENIED); + }); + }); + + it('should grant or block multiple permissions', async () => { + const permissions = { + photos: 'YES', + camera: 'YES', + location: 'never' + }; + + await device.launchApp({permissions, delete: true}); await element(by.text('Permissions')).tap(); - await expect(element(by.text('denied'))).toBeVisible(); + + await expect(element(by.id('photo_library'))).toHaveText(RESULTS.GRANTED); + await expect(element(by.id('camera'))).toHaveText(RESULTS.GRANTED); + await expect(element(by.id(LOCATION_ALWAYS))).toHaveText(RESULTS.BLOCKED); }); -}); \ No newline at end of file +}); + diff --git a/detox/test/e2e/15.urls-and-launchArgs.test.js b/detox/test/e2e/15.urls-and-launchArgs.test.js new file mode 100644 index 0000000000..cd795dbcbe --- /dev/null +++ b/detox/test/e2e/15.urls-and-launchArgs.test.js @@ -0,0 +1,20 @@ +const { urlDriver } = require('./drivers/url-driver'); +const { launchArgsDriver } = require('./drivers/launch-args-driver'); + +describe(':android: Launch arguments while handing launch URLs', () => { + it('should pass user args in normally', async () => { + const userArgs = { + how: 'about some', + pie: '3.14', + }; + const detoxLaunchArgs = urlDriver.withDetoxArgs.andUserArgs(userArgs); + + await device.launchApp({ newInstance: true, ...detoxLaunchArgs }); + await urlDriver.navToUrlScreen(); + await urlDriver.assertUrl(detoxLaunchArgs.url); + + await device.reloadReactNative(); + await launchArgsDriver.navToLaunchArgsScreen(); + await launchArgsDriver.assertLaunchArgs(userArgs); + }); +}); diff --git a/detox/test/e2e/15.urls.test.js b/detox/test/e2e/15.urls.test.js index 70de0cfdfd..e36b5937e5 100644 --- a/detox/test/e2e/15.urls.test.js +++ b/detox/test/e2e/15.urls.test.js @@ -1,3 +1,5 @@ +const { urlDriver } = require('./drivers/url-driver'); + describe('Open URLs', () => { afterAll(async () => { await device.launchApp({ @@ -7,38 +9,31 @@ describe('Open URLs', () => { }); }); - const withDefaultArgs = () => ({ - url: 'detoxtesturlscheme://such-string?arg1=first&arg2=second', - launchArgs: undefined, - }); - - const withSingleInstanceActivityArgs = () => ({ - url: 'detoxtesturlscheme.singleinstance://such-string', - launchArgs: { detoxAndroidSingleInstanceActivity: true }, - }); - describe.each([ - ['(default)', withDefaultArgs()], - [':android: (single activity)', withSingleInstanceActivityArgs()], + ['(default)', urlDriver.withDetoxArgs.default()], + [':android: (single activity)', urlDriver.withDetoxArgs.forSingleInstanceActivityLaunch()], ])('%s', (_platform, {url, launchArgs}) => { it(`device.launchApp() with a URL and a fresh app should launch app and trigger handling open url handling in app`, async () => { await device.launchApp({newInstance: true, url, launchArgs}); - await expect(element(by.text(url))).toBeVisible(); + await urlDriver.navToUrlScreen(); + await urlDriver.assertUrl(url); }); it(`device.openURL() should trigger open url handling in app when app is in foreground`, async () => { await device.launchApp({newInstance: true, launchArgs}); - await expect(element(by.text(url))).not.toBeVisible(); + await urlDriver.navToUrlScreen(); + await urlDriver.assertNoUrl(url); await device.openURL({url}); - await expect(element(by.text(url))).toBeVisible(); + await urlDriver.assertUrl(url); }); it(`device.launchApp() with a URL should trigger url handling when app is in background`, async () => { await device.launchApp({newInstance: true, launchArgs}); - await expect(element(by.text(url))).not.toBeVisible(); + await urlDriver.navToUrlScreen(); + await urlDriver.assertNoUrl(url); await device.sendToHome(); await device.launchApp({newInstance: false, url}); - await expect(element(by.text(url))).toBeVisible(); + await urlDriver.assertUrl(url); }); }); }); diff --git a/detox/test/e2e/16.location.test.js b/detox/test/e2e/16.location.test.js index 39f5c5e472..7fc4e3225e 100644 --- a/detox/test/e2e/16.location.test.js +++ b/detox/test/e2e/16.location.test.js @@ -1,45 +1,91 @@ -const exec = require('child-process-promise').exec; - -//TODO: Ignoring the test in CI until fbsimctl supports Xcode 9 -async function isFbsimctlInstalled() { - try { - await exec(`which fbsimctl`); - return true; - } catch (e) { - console.log(`setLocation only works through fbsimctl currently`); - return false; +const LOCATION_SCREEN_BUTTON_TEXT = 'Location'; +const LOCATION_LATITUDE_TEST_ID = 'location_latitude'; +const LOCATION_LONGITUDE_TEST_ID = 'location_longitude'; +const LOCATION_ERROR_TEST_ID = 'location_error'; +const GET_LOCATION_BUTTON_TEST_ID = 'get_location_button'; + +const DUMMY_COORDINATE_1 = -80.125; +const DUMMY_COORDINATE_2 = 66.5; + +describe('set location', () => { + const enterLocationScreen = async (location) => { + await device.launchApp({ + delete: true, + ...(location !== undefined && { permissions: { location: location } }), + }); + + await element(by.text(LOCATION_SCREEN_BUTTON_TEXT)).tap(); } -} - -describe('location', () => { - const lat = -80.125; - const lon = 66.5; - - // Skipped on Android because there is no Android permissions support yet - it(':ios: Location should be unavailable', async () => { - if (!await isFbsimctlInstalled()) { - return; - } - await device.relaunchApp({ permissions: { location: 'never' } }); - await element(by.text('Location')).tap(); - await element(by.id('getLocationButton')).tap(); - await expect(element(by.id('error'))).toBeVisible(); + + const updateLocationInfo = async () => { + await element(by.id(GET_LOCATION_BUTTON_TEST_ID)).tap(); + } + + const expectLocationToAppear = async (latitude, longitude) => { + await waitFor(element(by.id(LOCATION_LATITUDE_TEST_ID))).toHaveText(`Latitude: ${latitude}`).withTimeout(3000); + await expect(element(by.id(LOCATION_LONGITUDE_TEST_ID))).toHaveText(`Longitude: ${longitude}`); + } + + const expectErrorToAppear = async () => { + await waitFor(element(by.id(LOCATION_ERROR_TEST_ID))).toBeVisible().withTimeout(3000); + await expect(element(by.id(LOCATION_LATITUDE_TEST_ID))).not.toBeVisible(); + await expect(element(by.id(LOCATION_LONGITUDE_TEST_ID))).not.toBeVisible(); + } + + describe(':android: permission granted in the app manifest', () => { + it('should set location', async () => { + await enterLocationScreen(); + + await device.setLocation(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + await updateLocationInfo(); + + await expectLocationToAppear(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + }); + + it('should set location multiple times', async () => { + await enterLocationScreen(); + + await device.setLocation(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + await device.setLocation(DUMMY_COORDINATE_2, DUMMY_COORDINATE_1); + await updateLocationInfo(); + + await expectLocationToAppear(DUMMY_COORDINATE_2, DUMMY_COORDINATE_1); + }); }); - it('Should accept a location', async () => { - const isIOS = device.getPlatform() === 'ios'; + describe(':ios: permission set on launch config', () => { + it('should show error when permission defined as `never`', async () => { + await enterLocationScreen('never'); + await updateLocationInfo(); + await expectErrorToAppear(); + }); + + it('should set location when permission defined as `inuse`', async () => { + await enterLocationScreen('inuse'); + + await device.setLocation(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + await updateLocationInfo(); + + await expectLocationToAppear(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + }); + + it('should set location when permission defined as `always`', async () => { + await enterLocationScreen('always'); + + await device.setLocation(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + await updateLocationInfo(); + + await expectLocationToAppear(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + }); - if (isIOS && !await isFbsimctlInstalled()) { - return; - } + it('should set location multiple times', async () => { + await enterLocationScreen('always'); - await device.relaunchApp({ permissions: { location: 'always' } }); - await device.setLocation(lat, lon); - await element(by.text('Location')).tap(); - await element(by.id('getLocationButton')).tap(); - await waitFor(element(by.text(`Latitude: ${lat}`))).toBeVisible().withTimeout(5500); + await device.setLocation(DUMMY_COORDINATE_1, DUMMY_COORDINATE_2); + await device.setLocation(DUMMY_COORDINATE_2, DUMMY_COORDINATE_1); + await updateLocationInfo(); - await expect(element(by.text(`Latitude: ${lat}`))).toBeVisible(); - await expect(element(by.text(`Longitude: ${lon}`))).toBeVisible(); + await expectLocationToAppear(DUMMY_COORDINATE_2, DUMMY_COORDINATE_1); + }); }); }); diff --git a/detox/test/e2e/18.user-activities.test.js b/detox/test/e2e/18.user-activities.test.js index 722a8809cc..c108ba2236 100644 --- a/detox/test/e2e/18.user-activities.test.js +++ b/detox/test/e2e/18.user-activities.test.js @@ -1,23 +1,27 @@ +const { urlDriver } = require('./drivers/url-driver'); const DetoxConstants = require('detox').DetoxConstants; describe(':ios: User Activity', () => { it('Init from browsing web', async () => { // await device.__debug_sleep(10000); await device.launchApp({newInstance: true, userActivity: userActivityBrowsingWeb}); - await expect(element(by.text('https://my.deeplink.dtx'))).toBeVisible(); + await urlDriver.navToUrlScreen(); + await urlDriver.assertUrl('https://my.deeplink.dtx'); }); it('Background searchable item', async () => { await device.launchApp({newInstance: true}); + await urlDriver.navToUrlScreen(); await device.sendToHome(); await device.launchApp({newInstance: false, userActivity: userActivitySearchableItem}); - await expect(element(by.text('com.test.itemId'))).toBeVisible(); + await urlDriver.assertUrl('com.test.itemId'); }); it('Foreground browsing web', async () => { await device.launchApp({newInstance: true}); + await urlDriver.navToUrlScreen(); await device.sendUserActivity(userActivityBrowsingWeb); - await expect(element(by.text('https://my.deeplink.dtx'))).toBeVisible(); + await urlDriver.assertUrl('https://my.deeplink.dtx'); }); }); @@ -31,4 +35,4 @@ const userActivitySearchableItem = { "activityType": DetoxConstants.userActivityTypes.searchableItem, "userInfo": {} }; -userActivitySearchableItem.userInfo[DetoxConstants.searchableItemActivityIdentifier] = "com.test.itemId" \ No newline at end of file +userActivitySearchableItem.userInfo[DetoxConstants.searchableItemActivityIdentifier] = "com.test.itemId" diff --git a/detox/test/e2e/19.crash-handling.test.js b/detox/test/e2e/19.crash-handling.test.js index 1912538afa..0c1f120215 100644 --- a/detox/test/e2e/19.crash-handling.test.js +++ b/detox/test/e2e/19.crash-handling.test.js @@ -51,12 +51,14 @@ describe('Crash Handling', () => { it(':android: Should throw a detailed error upon app bootstrap crash', async () => { const error = await expectToThrow( () => relaunchAppWithArgs({ detoxAndroidCrashingActivity: true }), - 'Failed to run application on the device'); + 'The app has crashed, see the details below:'); // It's important that the native-error message (containing the native stack-trace) would also // be included in the error's stack property, in order for Jest (specifically) to properly output all // of that into the shell, as we expect it to. - jestExpect(error.stack).toContain('Native stacktrace dump:\njava.lang.IllegalStateException: This is an intentional crash!'); - jestExpect(error.stack).toContain('\tat com.example.CrashingActivity.onResume'); + jestExpect(error.stack).toContain('java.lang.RuntimeException: Unable to resume activity'); + + // In particular, we want the original cause to be bundled in. + jestExpect(error.stack).toContain('Caused by: java.lang.IllegalStateException: This is an intentional crash!'); }, 60000); }); diff --git a/detox/test/e2e/21.artifacts.test.js b/detox/test/e2e/21.artifacts.test.js index ee13dd15e2..e92eb00491 100644 --- a/detox/test/e2e/21.artifacts.test.js +++ b/detox/test/e2e/21.artifacts.test.js @@ -1,6 +1,7 @@ const fs = require('fs'); const path = require('path'); +const jestExpect = require('expect').default; const { PNG } = require('pngjs'); const { @@ -83,11 +84,7 @@ describe('Artifacts', () => { it('should capture hierarchy upon multiple invocation failures', async () => { for (let i = 0; i < 2; i++) { - try { - await element(by.id('nonExistentId')).tap(); - fail('should have failed'); - } catch (e) { - } + await jestExpect(element(by.id('nonExistentId')).tap()).rejects.toThrow(); } }); @@ -104,12 +101,8 @@ describe('Artifacts', () => { describe('edge uninstall case', () => { it('should capture hierarchy regardless', async () => { - try { - await element(by.id('nonExistentId')).tap(); - fail('should have failed'); - } catch (e) { - await device.uninstallApp(); - } + await jestExpect(element(by.id('nonExistentId')).tap()).rejects.toThrow(); + await device.uninstallApp(); }); afterAll(async () => { diff --git a/detox/test/e2e/22.launch-args.test.js b/detox/test/e2e/22.launch-args.test.js index a87bc5ab03..c8921f5ac1 100644 --- a/detox/test/e2e/22.launch-args.test.js +++ b/detox/test/e2e/22.launch-args.test.js @@ -1,5 +1,4 @@ -/* global by, device, element */ -const _ = require('lodash'); +const { launchArgsDriver: driver } = require('./drivers/launch-args-driver'); // Note: Android-only as, according to Leo, on iOS there's no added value here compared to // existing tests that check deep-link URLs. Combined with the fact that we do not yet @@ -13,26 +12,27 @@ describe(':android: Launch arguments', () => { beforeEach(async () => { await device.selectApp('exampleWithArgs'); - assertPreconfiguredValues(device.appLaunchArgs.get(), defaultArgs); + driver.assertPreconfiguredValues(device.appLaunchArgs.get(), defaultArgs); }); it('should preserve a shared arg in spite of app reselection', async () => { const override = { ama: 'zed' }; try { - assertPreconfiguredValues(device.appLaunchArgs.get(), defaultArgs); - assertPreconfiguredValues(device.appLaunchArgs.shared.get(), {}); + driver.assertPreconfiguredValues(device.appLaunchArgs.get(), defaultArgs); + driver.assertPreconfiguredValues(device.appLaunchArgs.shared.get(), {}); device.appLaunchArgs.shared.modify(override); - assertPreconfiguredValues(device.appLaunchArgs.get(), { ...defaultArgs, ...override }); - assertPreconfiguredValues(device.appLaunchArgs.shared.get(), override); + driver.assertPreconfiguredValues(device.appLaunchArgs.get(), { ...defaultArgs, ...override }); + driver.assertPreconfiguredValues(device.appLaunchArgs.shared.get(), override); await device.selectApp('example'); - assertPreconfiguredValues(device.appLaunchArgs.get(), override); - assertPreconfiguredValues(device.appLaunchArgs.shared.get(), override); + driver.assertPreconfiguredValues(device.appLaunchArgs.get(), override); + driver.assertPreconfiguredValues(device.appLaunchArgs.shared.get(), override); await device.launchApp({ newInstance: true }); - await assertLaunchArgs(override); + await driver.navToLaunchArgsScreen(); + await driver.assertLaunchArgs(override); } finally { device.appLaunchArgs.shared.reset(); } @@ -46,7 +46,8 @@ describe(':android: Launch arguments', () => { }; await device.launchApp({ newInstance: true, launchArgs }); - await assertLaunchArgs(launchArgs); + await driver.navToLaunchArgsScreen(); + await driver.assertLaunchArgs(launchArgs); }); it('should handle complex args when used on-site', async () => { @@ -61,7 +62,8 @@ describe(':android: Launch arguments', () => { }; await device.launchApp({ newInstance: true, launchArgs }); - await assertLaunchArgs({ + await driver.navToLaunchArgsScreen(); + await driver.assertLaunchArgs({ complex: JSON.stringify(launchArgs.complex), complexlist: JSON.stringify(launchArgs.complexlist), }); @@ -75,7 +77,8 @@ describe(':android: Launch arguments', () => { }); await device.launchApp({ newInstance: true }); - await assertLaunchArgs({ + await driver.navToLaunchArgsScreen(); + await driver.assertLaunchArgs({ 'goo': 'gle!', 'ama': 'zon', 'micro': 'soft', @@ -93,7 +96,8 @@ describe(':android: Launch arguments', () => { }); await device.launchApp({ newInstance: true, launchArgs }); - await assertLaunchArgs({ anArg: 'aValue!' }); + await driver.navToLaunchArgsScreen(); + await driver.assertLaunchArgs({ anArg: 'aValue!' }); }); // Ref: https://developer.android.com/studio/test/command-line#AMOptionsSyntax @@ -106,33 +110,7 @@ describe(':android: Launch arguments', () => { }; await device.launchApp({ newInstance: true, launchArgs }); - await assertLaunchArgs({ hello: 'world' }, ['debug', 'log', 'size']); + await driver.navToLaunchArgsScreen(); + await driver.assertLaunchArgs({ hello: 'world' }, ['debug', 'log', 'size']); }); - - async function assertLaunchArgs(expected, notExpected) { - await element(by.text('Launch Args')).tap(); - - if (expected) { - for (const [key, value] of Object.entries(expected)) { - await expect(element(by.id(`launchArg-${key}.name`))).toBeVisible(); - await expect(element(by.id(`launchArg-${key}.value`))).toHaveText(`${value}`); - } - } - - if (notExpected) { - for (const key of notExpected) { - await expect(element(by.id(`launchArg-${key}.name`))).not.toBeVisible(); - } - } - } - - function assertPreconfiguredValues(initArgs, expectedInitArgs) { - if (!_.isEqual(initArgs, expectedInitArgs)) { - throw new Error( - `Precondition failure: Preconfigured launch arguments (in detox.config.js) do not match the expected value.\n` + - `Expected: ${JSON.stringify(expectedInitArgs)}\n` + - `Received: ${JSON.stringify(initArgs)}` - ); - } - } }); diff --git a/detox/test/e2e/28.drag-and-drop.test.js b/detox/test/e2e/28.drag-and-drop.test.js index f6a7b1442b..f326257a6e 100644 --- a/detox/test/e2e/28.drag-and-drop.test.js +++ b/detox/test/e2e/28.drag-and-drop.test.js @@ -3,7 +3,7 @@ describe(':ios: Drag And Drop', () => { await device.reloadReactNative(); await element(by.text('Drag And Drop')).tap(); }); - + afterEach(async () => { await element(by.id('closeButton')).tap(); }); @@ -14,20 +14,22 @@ describe(':ios: Drag And Drop', () => { await assertCellText(2, '10'); }); - it('should drag the second cell and drop before the ten cell position', async () => { - await assertCellText(9, '9'); - await element(by.id('cell2')).longPressAndDrag(1000, 0.9, NaN, element(by.id('cell10')), 0.9, 0.01, 'slow', 0); + it('should drag the second cell and drop on the ten cell position', async () => { + await assertCellText(2, '2'); await assertCellText(10, '10'); - //Because we used 0.001 as the drop Y point, the `cell2` actually landed at cell9, not cell10. - await assertCellText(9, '2'); + + await element(by.id('cell2')).longPressAndDrag(1000, 0.9, NaN, element(by.id('cell10')), 0.9, 0.01, 'slow', 0); + + await assertCellText(2, '3'); + await assertCellText(10, '2'); }); - + async function assertCellText(idx, value) { const attribs = await element(by.id('cellTextLabel')).getAttributes(); const cellStrings = attribs.elements.map(x => x.text); - + if(cellStrings[idx - 1] !== value) { throw new Error("Failed!"); } } -}); \ No newline at end of file +}); diff --git a/detox/test/e2e/30.custom-keyboard.test.js b/detox/test/e2e/30.custom-keyboard.test.js index 6e902c60ec..5eea6ab766 100644 --- a/detox/test/e2e/30.custom-keyboard.test.js +++ b/detox/test/e2e/30.custom-keyboard.test.js @@ -3,7 +3,7 @@ describe(':ios: Custom Keyboard', () => { await device.reloadReactNative(); await element(by.text('Custom Keyboard')).tap(); }); - + afterEach(async () => { await element(by.id('closeButton')).tap(); }); @@ -17,6 +17,6 @@ describe(':ios: Custom Keyboard', () => { it('should obscure elements at bottom of screen when visible', async () => { await expect(element(by.text('Obscured by keyboard'))).toBeVisible(); await element(by.id('textWithCustomInput')).tap(); - await expect(element(by.text('Obscured by keyboard'))).toBeNotVisible(); + await expect(element(by.text('Obscured by keyboard'))).not.toBeVisible(); }); }); diff --git a/detox/test/e2e/33.attributes.test.js b/detox/test/e2e/33.attributes.test.js index c1a278ee0e..2ddaf6b6e5 100644 --- a/detox/test/e2e/33.attributes.test.js +++ b/detox/test/e2e/33.attributes.test.js @@ -1,5 +1,6 @@ const { device, element, by } = require('detox'); const expect = require('expect').default; +const custom = require('./utils/custom-it'); describe('Attributes', () => { /** @type {Detox.IndexableNativeElement} */ @@ -156,7 +157,7 @@ describe('Attributes', () => { }); }); - describe('of a legacy slider', () => { + custom.describe.skipFromRNVersion(71)('of a legacy slider', () => { beforeAll(() => useMatcher(by.id('legacySliderId'))); it(':ios: should have a string percent .value, and .normalizedSliderPosition', () => { diff --git a/detox/test/e2e/assets/elementScreenshot.ios.horiz.png b/detox/test/e2e/assets/elementScreenshot.ios.horiz.png index 79874aa34f..062771b1e4 100644 Binary files a/detox/test/e2e/assets/elementScreenshot.ios.horiz.png and b/detox/test/e2e/assets/elementScreenshot.ios.horiz.png differ diff --git a/detox/test/e2e/assets/elementScreenshot.ios.vert.png b/detox/test/e2e/assets/elementScreenshot.ios.vert.png index caeacd1029..93fbc3953c 100644 Binary files a/detox/test/e2e/assets/elementScreenshot.ios.vert.png and b/detox/test/e2e/assets/elementScreenshot.ios.vert.png differ diff --git a/detox/test/e2e/customPathBuilder.js b/detox/test/e2e/customPathBuilder.js index f04b01aa77..bfa4932d49 100644 --- a/detox/test/e2e/customPathBuilder.js +++ b/detox/test/e2e/customPathBuilder.js @@ -3,7 +3,7 @@ module.exports = ({ rootDir }) => { const sanitize = require('sanitize-filename'); return { - buildPathForTestArtifact(artifactName, testSummary = null) { + buildPathForTestArtifact(artifactName, _testSummary) { return path.join(rootDir, sanitize(artifactName)); } }; diff --git a/detox/test/e2e/detox.config-android.js b/detox/test/e2e/detox.config-android.js index 99044dd83c..4e36353192 100644 --- a/detox/test/e2e/detox.config-android.js +++ b/detox/test/e2e/detox.config-android.js @@ -1,20 +1,12 @@ -const _ = require('lodash'); -const { rnVersion } = require('./utils/rn-consts'); - const capitalizeFirstLetter = (str) => str.charAt(0).toUpperCase() + str.slice(1); -const isLegacyRNVersion = (rnVersion.minor < 71); -const warnOnce = _.once((...args) => console.warn(...args)); function androidBaseAppConfig(buildType /* 'debug' | 'release' */) { - const warnRNLegacy = () => warnOnce(`โš ๏ธ Detected a legacy RN version (v${rnVersion.raw}) - Using legacy build-flavor for Android config! ๐Ÿค–๐Ÿ› \n`); - - const appFlavor = (isLegacyRNVersion ? warnRNLegacy() || 'rnLegacy' : 'rnDefault'); - const appFlavorUC = capitalizeFirstLetter(appFlavor); const buildTypeUC = capitalizeFirstLetter(buildType); + return { type: 'android.apk', - binaryPath: `android/app/build/outputs/apk/${appFlavor}/${buildType}/app-${appFlavor}-${buildType}.apk`, - build: `cd android && ./gradlew assemble${appFlavorUC}${buildTypeUC} assemble${appFlavorUC}${buildTypeUC}AndroidTest -DtestBuildType=${buildType} && cd ..`, + binaryPath: `android/app/build/outputs/apk/${buildType}/app-${buildType}.apk`, + build: `cd android && ./gradlew assemble${buildTypeUC} assemble${buildTypeUC}AndroidTest -DtestBuildType=${buildType} && cd ..`, }; } diff --git a/detox/test/e2e/detox.config.js b/detox/test/e2e/detox.config.js index bd6a5a4c57..e8762e625d 100644 --- a/detox/test/e2e/detox.config.js +++ b/detox/test/e2e/detox.config.js @@ -10,10 +10,12 @@ const launchArgs = { const config = { testRunner: { args: { - $0: 'nyc jest', + $0: process.env.CI ? 'nyc jest' : 'jest', config: 'e2e/jest.config.js', - _: ['e2e/'] + forceExit: process.env.CI ? true : undefined, + _: ['e2e/'], }, + detached: !!process.env.CI, retries: process.env.CI ? 1 : undefined, jest: { setupTimeout: +`${process.env.DETOX_JEST_SETUP_TIMEOUT || 300000}`, @@ -97,7 +99,8 @@ const config = { type: 'ios.simulator', headless: Boolean(process.env.CI), device: { - type: 'iPhone 12 Pro Max', + type: 'iPhone 15 Pro Max', + os: "17.2", }, }, diff --git a/detox/test/e2e/drivers/launch-args-driver.js b/detox/test/e2e/drivers/launch-args-driver.js new file mode 100644 index 0000000000..fcf3d168e7 --- /dev/null +++ b/detox/test/e2e/drivers/launch-args-driver.js @@ -0,0 +1,33 @@ +const _ = require("lodash"); +const driver = { + navToLaunchArgsScreen: () => element(by.text('Launch Args')).tap(), + + assertPreconfiguredValues: (initArgs, expectedInitArgs) => { + if (!_.isEqual(initArgs, expectedInitArgs)) { + throw new Error( + `Precondition failure: Preconfigured launch arguments (in detox.config.js) do not match the expected value.\n` + + `Expected: ${JSON.stringify(expectedInitArgs)}\n` + + `Received: ${JSON.stringify(initArgs)}` + ); + } + }, + + assertLaunchArgs: async (expected, notExpected) => { + if (expected) { + for (const [key, value] of Object.entries(expected)) { + await expect(element(by.id(`launchArg-${key}.name`))).toBeVisible(); + await expect(element(by.id(`launchArg-${key}.value`))).toHaveText(`${value}`); + } + } + + if (notExpected) { + for (const key of notExpected) { + await expect(element(by.id(`launchArg-${key}.name`))).not.toBeVisible(); + } + } + } +} + +module.exports = { + launchArgsDriver: driver, +}; diff --git a/detox/test/e2e/drivers/url-driver.js b/detox/test/e2e/drivers/url-driver.js new file mode 100644 index 0000000000..c41f084380 --- /dev/null +++ b/detox/test/e2e/drivers/url-driver.js @@ -0,0 +1,26 @@ +const driver = { + withDetoxArgs: { + default: () => ({ + url: 'detoxtesturlscheme://such-string?arg1=first&arg2=second', + launchArgs: undefined, + }), + + andUserArgs: (launchArgs) => ({ + url: 'detoxtesturlscheme', + launchArgs, + }), + + forSingleInstanceActivityLaunch: () => ({ + url: 'detoxtesturlscheme.singleinstance://such-string', + launchArgs: { detoxAndroidSingleInstanceActivity: true }, + }), + }, + + navToUrlScreen: () => element(by.text('Init URL')).tap(), + assertUrl: (url) => expect(element(by.text(url))).toBeVisible(), + assertNoUrl: (url) => expect(element(by.text(url))).not.toBeVisible(), +}; + +module.exports = { + urlDriver: driver, +}; diff --git a/detox/test/e2e/jest.config.js b/detox/test/e2e/jest.config.js index 4830228ff8..da69b592b5 100644 --- a/detox/test/e2e/jest.config.js +++ b/detox/test/e2e/jest.config.js @@ -15,6 +15,9 @@ module.exports = async () => { /** @type {import('jest-allure2-reporter').ReporterOptions} */ const jestAllure2ReporterOptions = { overwrite: !process.env.CI, + attachments: { + fileHandler: 'copy', + }, testCase: { labels: { package: ({ filePath }) => filePath.slice(1).join('/'), @@ -44,6 +47,13 @@ module.exports = async () => { return { 'rootDir': path.join(__dirname, '../..'), 'testEnvironment': './test/e2e/testEnvironment.js', + 'testEnvironmentOptions': { + 'eventListeners': [ + 'jest-metadata/environment-listener', + 'jest-allure2-reporter/environment-listener', + require.resolve('detox-allure2-adapter'), + ] + }, 'testRunner': './test/node_modules/jest-circus/runner', 'testMatch': [ '/test/e2e/**/*.test.{js,ts}', diff --git a/detox/test/e2e/utils/custom-it.js b/detox/test/e2e/utils/custom-it.js index 1b2037242d..6c625f2d9b 100644 --- a/detox/test/e2e/utils/custom-it.js +++ b/detox/test/e2e/utils/custom-it.js @@ -1,13 +1,18 @@ const _ = require('lodash'); -const rnMinorVer = require('./rn-consts').rnVersion.minor; +const rnMinorVer = require('../../../src/utils/rn-consts/rn-consts').rnVersion.minor; const _it = { withFailureIf: { android: (spec, specFn) => runOrExpectFailByPredicates(spec, specFn, platformIs('android')), iOSWithRNLessThan67: (spec, specFn) => runOrExpectFailByPredicates(spec, specFn, platformIs('ios'), rnVerLessThan(67)), }, + skipFromRNVersion: (version) => skipFromRNVersion(version), }; +const _describe = { + skipFromRNVersion: (version) => describeFromRNVersion(version), +} + function runOrExpectFailByPredicates(spec, specFn, ...predicateFuncs) { it(spec, async function() { if (allPredicatesTrue(predicateFuncs)) { @@ -18,6 +23,32 @@ function runOrExpectFailByPredicates(spec, specFn, ...predicateFuncs) { }); } + +/** + * Run the test only if the RN version is {version} or below. Otherwise, skip it. + * @returns it or it.skip functions + */ +function skipFromRNVersion(version) { + if (parseInt(rnMinorVer) <= version) { + return it; + } else { + return it.skip; + } +} + +/** + * Run the test only if the RN version is {version} or below. Otherwise, skip it. + * @param version + * @returns describe or describe.skip functions + */ +function describeFromRNVersion(version) { + if (parseInt(rnMinorVer) <= version) { + return describe; + } else { + return describe.skip; + } +} + const platformIs = (platform) => () => (device.getPlatform() === platform); const rnVerLessThan = (rnVer) => () => (rnMinorVer < rnVer); const allPredicatesTrue = (predicateFuncs) => _.reduce(predicateFuncs, (result, predicate) => (result && predicate()), true); @@ -36,4 +67,5 @@ const runSpec = (specFn) => specFn(); module.exports = { it: _it, + describe: _describe, }; diff --git a/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap index e99e840c78..bc04e54ad7 100644 --- a/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap +++ b/detox/test/integration/__snapshots__/timeline-artifact.test.js.snap @@ -35,7 +35,7 @@ exports[`Timeline integration test Flaky test should deterministically produce a "v": 0, }, "cat": "lifecycle,cli", - "name": "nyc jest --config integration/e2e/config.js flaky", + "name": "jest --config integration/e2e/config.js flaky", "ph": "B", "pid": 0, "tid": 0, @@ -432,7 +432,7 @@ Detox CLI is going to restart the test runner with those files... "v": 0, }, "cat": "lifecycle,cli", - "name": "nyc jest --config integration/e2e/config.js $CWD/integration/e2e/flaky.test.js", + "name": "jest --config integration/e2e/config.js $CWD/integration/e2e/flaky.test.js", "ph": "B", "pid": 0, "tid": 0, @@ -851,7 +851,7 @@ exports[`Timeline integration test Focused test should deterministically produce "v": 0, }, "cat": "lifecycle,cli", - "name": "nyc jest --config integration/e2e/config.js focused", + "name": "jest --config integration/e2e/config.js focused", "ph": "B", "pid": 0, "tid": 0, @@ -1250,7 +1250,7 @@ exports[`Timeline integration test Skipped test should deterministically produce "v": 0, }, "cat": "lifecycle,cli", - "name": "nyc jest --config integration/e2e/config.js skipped", + "name": "jest --config integration/e2e/config.js skipped", "ph": "B", "pid": 0, "tid": 0, diff --git a/detox/test/integration/e2e/focused.test.js b/detox/test/integration/e2e/focused.test.js index 474ff18003..f2dbb0af97 100644 --- a/detox/test/integration/e2e/focused.test.js +++ b/detox/test/integration/e2e/focused.test.js @@ -3,6 +3,7 @@ describe('Focused', () => { // Reproducing when hook_start is called after test_start }); + // eslint-disable-next-line jest/no-focused-tests it.only('Only test', async () => { // Checking that skipped tests are also traced }); diff --git a/detox/test/integration/e2e/passing-skipped.test.js b/detox/test/integration/e2e/passing-skipped.test.js index 5ac513a940..4c157f71d5 100644 --- a/detox/test/integration/e2e/passing-skipped.test.js +++ b/detox/test/integration/e2e/passing-skipped.test.js @@ -1,4 +1,5 @@ describe('Suite with skipped tests', () => { + // eslint-disable-next-line jest/no-disabled-tests it.skip('Skipped test', async () => { // Checking that skipped tests are also traced }); diff --git a/detox/test/integration/jest.config.js b/detox/test/integration/jest.config.js index 680626e0f2..019df7af49 100644 --- a/detox/test/integration/jest.config.js +++ b/detox/test/integration/jest.config.js @@ -1,3 +1,5 @@ +process.env.CI = ''; // disable CI-specific behavior for integration tests + module.exports = { "maxWorkers": 1, "testMatch": ["/*.test.js"], diff --git a/detox/test/integration/stub/StubRuntimeDriver.js b/detox/test/integration/stub/StubRuntimeDriver.js index 918ec3fab2..bfa400155b 100644 --- a/detox/test/integration/stub/StubRuntimeDriver.js +++ b/detox/test/integration/stub/StubRuntimeDriver.js @@ -47,7 +47,7 @@ class StubRuntimeDriver extends DeviceDriverBase { return process.pid; } - async deliverPayload(params) { + async deliverPayload(_params) { await sleepVeryLittle(); } diff --git a/detox/test/ios/Podfile b/detox/test/ios/Podfile index 1df16527c3..ed419b26f0 100644 --- a/detox/test/ios/Podfile +++ b/detox/test/ios/Podfile @@ -1,14 +1,52 @@ -require_relative '../node_modules/react-native/scripts/react_native_pods' -require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' +if ENV["REACT_NATIVE_VERSION"] && ENV["REACT_NATIVE_VERSION"].match(/0.(70|71).*/) + require_relative '../node_modules/react-native/scripts/react_native_pods' + require_relative '../node_modules/@react-native-community/cli-platform-ios/native_modules' + require_relative '../node_modules/react-native-permissions/scripts/setup' +else + # Resolve react_native_pods.rb with node to allow for hoisting + def node_require(script) + # Resolve script with node to allow for hoisting + require Pod::Executable.execute_command('node', ['-p', + "require.resolve( + '#{script}', + {paths: [process.argv[1]]}, + )", __dir__]).strip + end -platform :ios, '13.0' + node_require('react-native/scripts/react_native_pods.rb') + node_require('react-native-permissions/scripts/setup.rb') +end +platform :ios, min_ios_version_supported install! 'cocoapods', :deterministic_uuids => false +# Comment unwanted permissions +setup_permissions([ + 'AppTrackingTransparency', + 'Bluetooth', + 'Calendars', + 'Camera', + 'Contacts', + 'FaceID', + 'LocationAccuracy', + 'LocationAlways', + 'LocationWhenInUse', + 'MediaLibrary', + 'Microphone', + 'Motion', + 'Notifications', + 'PhotoLibrary', + 'PhotoLibraryAddOnly', + 'Reminders', + 'Siri', + 'SpeechRecognition', + 'StoreKit', +]) + def shared_pods config = use_native_modules! - if !ENV["REACT_NATIVE_VERSION"] || ENV["REACT_NATIVE_VERSION"].match(/0.(68|69|70).*/) + if ENV["REACT_NATIVE_VERSION"] && ENV["REACT_NATIVE_VERSION"].match(/0.(70).*/) # Flags change depending on the env values. flags = get_default_flags() @@ -31,7 +69,6 @@ end target 'example' do shared_pods pod 'react-native-slider', :path => '../node_modules/@react-native-community/slider' - end target 'example_ci' do @@ -66,19 +103,18 @@ post_install do |installer| __apply_update_deployment_target_workaround(installer) __apply_Xcode_15_post_install_workaround(installer) - if ENV["REACT_NATIVE_VERSION"] && ENV["REACT_NATIVE_VERSION"].match(/0.6[6,7,8,9].*/) - react_native_post_install(installer) - else - react_native_post_install( - installer, - # Set `mac_catalyst_enabled` to `true` in order to apply patches - # necessary for Mac Catalyst builds - :mac_catalyst_enabled => false - ) - end + config = use_native_modules! + + react_native_post_install( + installer, + config[:reactNativePath], + # Set `mac_catalyst_enabled` to `true` in order to apply patches + # necessary for Mac Catalyst builds + :mac_catalyst_enabled => false + ) # See https://github.com/wix/Detox/pull/3035#discussion_r774747705 - if !ENV["REACT_NATIVE_VERSION"] || ENV["REACT_NATIVE_VERSION"].match(/0.(66|67|68|69|70).*/) + if ENV["REACT_NATIVE_VERSION"] && ENV["REACT_NATIVE_VERSION"].match(/0.(70|72).*/) __apply_Xcode_12_5_M1_post_install_workaround(installer) end end diff --git a/detox/test/ios/example.xcodeproj/project.pbxproj b/detox/test/ios/example.xcodeproj/project.pbxproj index 1aeefcc485..f9bad7b6f6 100644 --- a/detox/test/ios/example.xcodeproj/project.pbxproj +++ b/detox/test/ios/example.xcodeproj/project.pbxproj @@ -7,7 +7,7 @@ objects = { /* Begin PBXBuildFile section */ - 0BA16EEC4E404A69C77F4E3B /* libPods-example_ci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = CD2D2A7D46E170EAE3BAD452 /* libPods-example_ci.a */; }; + 03815B1566F43B2C8ACBCCBB /* libPods-example_ci.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 6CEAF92A5314EC6824AB3DE3 /* libPods-example_ci.a */; }; 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 13B07FB01A68108700A75B9A /* AppDelegate.m */; }; 13B07FBD1A68108700A75B9A /* LaunchScreen.xib in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB11A68108700A75B9A /* LaunchScreen.xib */; }; 13B07FBF1A68108700A75B9A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 13B07FB51A68108700A75B9A /* Images.xcassets */; }; @@ -28,10 +28,8 @@ 39F5268E25C7429E00BA644D /* DragDropTableViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 39F5268C25C7429E00BA644D /* DragDropTableViewController.m */; }; 4FB97BDF2636490900B7B57C /* CustomKeyboardViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4FB97BDE2636490800B7B57C /* CustomKeyboardViewController.m */; }; 4FB97BE02636490900B7B57C /* CustomKeyboardViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 4FB97BDE2636490800B7B57C /* CustomKeyboardViewController.m */; }; - 672CB76AF6D68D56259F08FB /* libPods-example.a in Frameworks */ = {isa = PBXBuildFile; fileRef = D77962AECF29A300FFA668B4 /* libPods-example.a */; }; + A6246A8AF2D035D89BA61CA6 /* libPods-example.a in Frameworks */ = {isa = PBXBuildFile; fileRef = 86172A40F266BB07F101EB18 /* libPods-example.a */; }; CC17D3321D60A24300267B0C /* NativeModule.m in Sources */ = {isa = PBXBuildFile; fileRef = CC17D3311D60A24300267B0C /* NativeModule.m */; }; - E088C8F41F01585500CC48E9 /* CalendarManager.m in Sources */ = {isa = PBXBuildFile; fileRef = E088C8F31F01585500CC48E9 /* CalendarManager.m */; }; - E088C8F51F025DE900CC48E9 /* CalendarManager.m in Sources */ = {isa = PBXBuildFile; fileRef = E088C8F31F01585500CC48E9 /* CalendarManager.m */; }; /* End PBXBuildFile section */ /* Begin PBXContainerItemProxy section */ @@ -91,7 +89,6 @@ /* Begin PBXFileReference section */ 008F07F21AC5B25A0029DE68 /* main.jsbundle */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text; path = main.jsbundle; sourceTree = ""; }; - 0FCE2B17362E305AD63D208E /* Pods-example_ci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example_ci.debug.xcconfig"; path = "Target Support Files/Pods-example_ci/Pods-example_ci.debug.xcconfig"; sourceTree = ""; }; 13B07F961A680F5B00A75B9A /* example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = example.app; sourceTree = BUILT_PRODUCTS_DIR; }; 13B07FAF1A68108700A75B9A /* AppDelegate.h */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.h; name = AppDelegate.h; path = example/AppDelegate.h; sourceTree = ""; tabWidth = 4; }; 13B07FB01A68108700A75B9A /* AppDelegate.m */ = {isa = PBXFileReference; fileEncoding = 4; indentWidth = 4; lastKnownFileType = sourcecode.c.objc; name = AppDelegate.m; path = example/AppDelegate.m; sourceTree = ""; tabWidth = 4; usesTabs = 1; }; @@ -113,15 +110,16 @@ 39FC9D24202899F90033C11A /* src */ = {isa = PBXFileReference; lastKnownFileType = folder; name = src; path = ../src; sourceTree = ""; }; 4FB97BDD2636490800B7B57C /* CustomKeyboardViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; name = CustomKeyboardViewController.h; path = example/CustomKeyboardViewController.h; sourceTree = ""; }; 4FB97BDE2636490800B7B57C /* CustomKeyboardViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; name = CustomKeyboardViewController.m; path = example/CustomKeyboardViewController.m; sourceTree = ""; usesTabs = 1; }; - 66F0F062E8DEDED761A32E62 /* Pods-example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.release.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.release.xcconfig"; sourceTree = ""; }; - AA201D8508E7536D1B2419C4 /* Pods-example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.debug.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.debug.xcconfig"; sourceTree = ""; }; - AC4951A69652648DAD57681D /* Pods-example_ci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example_ci.release.xcconfig"; path = "Target Support Files/Pods-example_ci/Pods-example_ci.release.xcconfig"; sourceTree = ""; }; + 6027065D2B1DF4DD00CD52CF /* example.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; name = example.entitlements; path = example/example.entitlements; sourceTree = ""; }; + 6027065F2B1DF82400CD52CF /* example_ci.entitlements */ = {isa = PBXFileReference; lastKnownFileType = text.plist.entitlements; path = example_ci.entitlements; sourceTree = ""; }; + 6CEAF92A5314EC6824AB3DE3 /* libPods-example_ci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-example_ci.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + 86172A40F266BB07F101EB18 /* libPods-example.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-example.a"; sourceTree = BUILT_PRODUCTS_DIR; }; + A112C2B84196BF64AE1EFFA3 /* Pods-example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.debug.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.debug.xcconfig"; sourceTree = ""; }; + A398C1445E8C769F5903CD2D /* Pods-example_ci.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example_ci.debug.xcconfig"; path = "Target Support Files/Pods-example_ci/Pods-example_ci.debug.xcconfig"; sourceTree = ""; }; CC17D3301D60A24300267B0C /* NativeModule.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = NativeModule.h; path = example/NativeModule.h; sourceTree = ""; }; CC17D3311D60A24300267B0C /* NativeModule.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = NativeModule.m; path = example/NativeModule.m; sourceTree = ""; }; - CD2D2A7D46E170EAE3BAD452 /* libPods-example_ci.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-example_ci.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - D77962AECF29A300FFA668B4 /* libPods-example.a */ = {isa = PBXFileReference; explicitFileType = archive.ar; includeInIndex = 0; path = "libPods-example.a"; sourceTree = BUILT_PRODUCTS_DIR; }; - E088C8F21F01585500CC48E9 /* CalendarManager.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = CalendarManager.h; path = example/CalendarManager.h; sourceTree = ""; }; - E088C8F31F01585500CC48E9 /* CalendarManager.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = CalendarManager.m; path = example/CalendarManager.m; sourceTree = ""; }; + CC2D09EF9818766C1EE13DEE /* Pods-example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example.release.xcconfig"; path = "Target Support Files/Pods-example/Pods-example.release.xcconfig"; sourceTree = ""; }; + F08DEFD1BF85073C69F1A95E /* Pods-example_ci.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-example_ci.release.xcconfig"; path = "Target Support Files/Pods-example_ci/Pods-example_ci.release.xcconfig"; sourceTree = ""; }; /* End PBXFileReference section */ /* Begin PBXFrameworksBuildPhase section */ @@ -131,7 +129,7 @@ files = ( 3953CC0D229AA78F005DD98C /* JavaScriptCore.framework in Frameworks */, 39A34C791E30F3A000BEBB59 /* Detox.framework in Frameworks */, - 672CB76AF6D68D56259F08FB /* libPods-example.a in Frameworks */, + A6246A8AF2D035D89BA61CA6 /* libPods-example.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -140,7 +138,7 @@ buildActionMask = 2147483647; files = ( 39ED92302291643E005EDB56 /* JavaScriptCore.framework in Frameworks */, - 0BA16EEC4E404A69C77F4E3B /* libPods-example_ci.a in Frameworks */, + 03815B1566F43B2C8ACBCCBB /* libPods-example_ci.a in Frameworks */, ); runOnlyForDeploymentPostprocessing = 0; }; @@ -157,6 +155,7 @@ 13B07FAE1A68108700A75B9A /* example */ = { isa = PBXGroup; children = ( + 6027065D2B1DF4DD00CD52CF /* example.entitlements */, 008F07F21AC5B25A0029DE68 /* main.jsbundle */, 13B07FAF1A68108700A75B9A /* AppDelegate.h */, 13B07FB01A68108700A75B9A /* AppDelegate.m */, @@ -173,10 +172,10 @@ 160A423EA1148BC845CDF95E /* Pods */ = { isa = PBXGroup; children = ( - AA201D8508E7536D1B2419C4 /* Pods-example.debug.xcconfig */, - 66F0F062E8DEDED761A32E62 /* Pods-example.release.xcconfig */, - 0FCE2B17362E305AD63D208E /* Pods-example_ci.debug.xcconfig */, - AC4951A69652648DAD57681D /* Pods-example_ci.release.xcconfig */, + A112C2B84196BF64AE1EFFA3 /* Pods-example.debug.xcconfig */, + CC2D09EF9818766C1EE13DEE /* Pods-example.release.xcconfig */, + A398C1445E8C769F5903CD2D /* Pods-example_ci.debug.xcconfig */, + F08DEFD1BF85073C69F1A95E /* Pods-example_ci.release.xcconfig */, ); path = Pods; sourceTree = ""; @@ -211,6 +210,7 @@ 83CBB9F61A601CBA00E9B192 = { isa = PBXGroup; children = ( + 6027065F2B1DF82400CD52CF /* example_ci.entitlements */, 39B0445B1DAED76400431EC5 /* Detox.xcodeproj */, 13B07FAE1A68108700A75B9A /* example */, 39FC9CFD202899D10033C11A /* JS */, @@ -259,8 +259,6 @@ children = ( CC17D3301D60A24300267B0C /* NativeModule.h */, CC17D3311D60A24300267B0C /* NativeModule.m */, - E088C8F21F01585500CC48E9 /* CalendarManager.h */, - E088C8F31F01585500CC48E9 /* CalendarManager.m */, ); name = ReactModules; sourceTree = ""; @@ -269,8 +267,8 @@ isa = PBXGroup; children = ( 39ED920022916437005EDB56 /* JavaScriptCore.framework */, - D77962AECF29A300FFA668B4 /* libPods-example.a */, - CD2D2A7D46E170EAE3BAD452 /* libPods-example_ci.a */, + 86172A40F266BB07F101EB18 /* libPods-example.a */, + 6CEAF92A5314EC6824AB3DE3 /* libPods-example_ci.a */, ); name = Frameworks; sourceTree = ""; @@ -282,14 +280,13 @@ isa = PBXNativeTarget; buildConfigurationList = 13B07F931A680F5B00A75B9A /* Build configuration list for PBXNativeTarget "example" */; buildPhases = ( - 117E8226415EB19636A83DA4 /* [CP] Check Pods Manifest.lock */, + DC59FF1BA0BA7FA633F256AA /* [CP] Check Pods Manifest.lock */, 13B07F871A680F5B00A75B9A /* Sources */, 13B07F8C1A680F5B00A75B9A /* Frameworks */, 13B07F8E1A680F5B00A75B9A /* Resources */, 00DD1BFF1BD5951E006B06BC /* Bundle React Native code and images */, CC0F353E1D461097008BB94F /* Embed Frameworks */, - 29BF34EE90A5198E5DEDD118 /* [CP] Copy Pods Resources */, - 4794120EC455E744254DC38D /* [CP] Embed Pods Frameworks */, + 7E5379D0D68561A29BC0458B /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -305,14 +302,13 @@ isa = PBXNativeTarget; buildConfigurationList = 399B4E031ED587120098D2AC /* Build configuration list for PBXNativeTarget "example_ci" */; buildPhases = ( - 954B535809EF51A76B678921 /* [CP] Check Pods Manifest.lock */, + 5D18F0A8E13DBF942B818AE3 /* [CP] Check Pods Manifest.lock */, 399B4DEB1ED587120098D2AC /* Sources */, 399B4DEF1ED587120098D2AC /* Frameworks */, 399B4DFD1ED587120098D2AC /* Resources */, 399B4E001ED587120098D2AC /* Bundle React Native code and images */, 399B4E011ED587120098D2AC /* Embed Frameworks */, - C3511F28C7F23FC398E9CA11 /* [CP] Copy Pods Resources */, - DC43B0212C5980C7946317C0 /* [CP] Embed Pods Frameworks */, + 9C024938B44AFDD355A0B654 /* [CP] Copy Pods Resources */, ); buildRules = ( ); @@ -442,7 +438,21 @@ shellPath = /bin/sh; shellScript = "export NODE_BINARY=node\n../node_modules/react-native/scripts/react-native-xcode.sh"; }; - 117E8226415EB19636A83DA4 /* [CP] Check Pods Manifest.lock */ = { + 399B4E001ED587120098D2AC /* Bundle React Native code and images */ = { + isa = PBXShellScriptBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + inputPaths = ( + ); + name = "Bundle React Native code and images"; + outputPaths = ( + ); + runOnlyForDeploymentPostprocessing = 0; + shellPath = /bin/sh; + shellScript = "../node_modules/react-native/scripts/react-native-xcode.sh\n"; + }; + 5D18F0A8E13DBF942B818AE3 /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -457,14 +467,14 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-example-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-example_ci-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - 29BF34EE90A5198E5DEDD118 /* [CP] Copy Pods Resources */ = { + 7E5379D0D68561A29BC0458B /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -481,38 +491,24 @@ shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 399B4E001ED587120098D2AC /* Bundle React Native code and images */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputPaths = ( - ); - name = "Bundle React Native code and images"; - outputPaths = ( - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "../node_modules/react-native/scripts/react-native-xcode.sh\n"; - }; - 4794120EC455E744254DC38D /* [CP] Embed Pods Frameworks */ = { + 9C024938B44AFDD355A0B654 /* [CP] Copy Pods Resources */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( ); inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-frameworks-${CONFIGURATION}-input-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-resources-${CONFIGURATION}-input-files.xcfilelist", ); - name = "[CP] Embed Pods Frameworks"; + name = "[CP] Copy Pods Resources"; outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-frameworks-${CONFIGURATION}-output-files.xcfilelist", + "${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-resources-${CONFIGURATION}-output-files.xcfilelist", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example/Pods-example-frameworks.sh\"\n"; + shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-resources.sh\"\n"; showEnvVarsInLog = 0; }; - 954B535809EF51A76B678921 /* [CP] Check Pods Manifest.lock */ = { + DC59FF1BA0BA7FA633F256AA /* [CP] Check Pods Manifest.lock */ = { isa = PBXShellScriptBuildPhase; buildActionMask = 2147483647; files = ( @@ -527,47 +523,13 @@ outputFileListPaths = ( ); outputPaths = ( - "$(DERIVED_FILE_DIR)/Pods-example_ci-checkManifestLockResult.txt", + "$(DERIVED_FILE_DIR)/Pods-example-checkManifestLockResult.txt", ); runOnlyForDeploymentPostprocessing = 0; shellPath = /bin/sh; shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n # print error to STDERR\n echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n"; showEnvVarsInLog = 0; }; - C3511F28C7F23FC398E9CA11 /* [CP] Copy Pods Resources */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-resources-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Copy Pods Resources"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-resources-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-resources.sh\"\n"; - showEnvVarsInLog = 0; - }; - DC43B0212C5980C7946317C0 /* [CP] Embed Pods Frameworks */ = { - isa = PBXShellScriptBuildPhase; - buildActionMask = 2147483647; - files = ( - ); - inputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-frameworks-${CONFIGURATION}-input-files.xcfilelist", - ); - name = "[CP] Embed Pods Frameworks"; - outputFileListPaths = ( - "${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-frameworks-${CONFIGURATION}-output-files.xcfilelist", - ); - runOnlyForDeploymentPostprocessing = 0; - shellPath = /bin/sh; - shellScript = "\"${PODS_ROOT}/Target Support Files/Pods-example_ci/Pods-example_ci-frameworks.sh\"\n"; - showEnvVarsInLog = 0; - }; /* End PBXShellScriptBuildPhase section */ /* Begin PBXSourcesBuildPhase section */ @@ -577,7 +539,6 @@ files = ( CC17D3321D60A24300267B0C /* NativeModule.m in Sources */, 39F5268625C725BF00BA644D /* DragDropViewController.m in Sources */, - E088C8F41F01585500CC48E9 /* CalendarManager.m in Sources */, 13B07FBC1A68108700A75B9A /* AppDelegate.m in Sources */, 39F5268D25C7429E00BA644D /* DragDropTableViewController.m in Sources */, 4FB97BDF2636490900B7B57C /* CustomKeyboardViewController.m in Sources */, @@ -591,7 +552,6 @@ files = ( 399B4DEC1ED587120098D2AC /* NativeModule.m in Sources */, 39F5268725C725BF00BA644D /* DragDropViewController.m in Sources */, - E088C8F51F025DE900CC48E9 /* CalendarManager.m in Sources */, 399B4DED1ED587120098D2AC /* AppDelegate.m in Sources */, 39F5268E25C7429E00BA644D /* DragDropTableViewController.m in Sources */, 4FB97BE02636490900B7B57C /* CustomKeyboardViewController.m in Sources */, @@ -637,11 +597,12 @@ /* Begin XCBuildConfiguration section */ 13B07F941A680F5B00A75B9A /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AA201D8508E7536D1B2419C4 /* Pods-example.debug.xcconfig */; + baseConfigurationReference = A112C2B84196BF64AE1EFFA3 /* Pods-example.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; + CODE_SIGN_ENTITLEMENTS = example/example.entitlements; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; GCC_PREPROCESSOR_DEFINITIONS = ( @@ -667,11 +628,12 @@ }; 13B07F951A680F5B00A75B9A /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 66F0F062E8DEDED761A32E62 /* Pods-example.release.xcconfig */; + baseConfigurationReference = CC2D09EF9818766C1EE13DEE /* Pods-example.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO; CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = NO; + CODE_SIGN_ENTITLEMENTS = example/example.entitlements; DEVELOPMENT_TEAM = ""; GCC_PREPROCESSOR_DEFINITIONS = ( "EXTERNAL_LOGGER=1", @@ -696,10 +658,11 @@ }; 399B4E041ED587120098D2AC /* Debug */ = { isa = XCBuildConfiguration; - baseConfigurationReference = 0FCE2B17362E305AD63D208E /* Pods-example_ci.debug.xcconfig */; + baseConfigurationReference = A398C1445E8C769F5903CD2D /* Pods-example_ci.debug.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO; + CODE_SIGN_ENTITLEMENTS = example_ci.entitlements; DEAD_CODE_STRIPPING = NO; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/example/Info.plist"; @@ -721,10 +684,11 @@ }; 399B4E051ED587120098D2AC /* Release */ = { isa = XCBuildConfiguration; - baseConfigurationReference = AC4951A69652648DAD57681D /* Pods-example_ci.release.xcconfig */; + baseConfigurationReference = F08DEFD1BF85073C69F1A95E /* Pods-example_ci.release.xcconfig */; buildSettings = { ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = NO; + CODE_SIGN_ENTITLEMENTS = example_ci.entitlements; DEVELOPMENT_TEAM = ""; INFOPLIST_FILE = "$(SRCROOT)/example/Info.plist"; IPHONEOS_DEPLOYMENT_TARGET = 13.0; @@ -803,7 +767,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "c++17"; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -832,7 +796,7 @@ ENABLE_BITCODE = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; ENABLE_TESTABILITY = YES; - "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_DYNAMIC_NO_PIC = NO; GCC_NO_COMMON_BLOCKS = YES; @@ -840,6 +804,7 @@ GCC_PREPROCESSOR_DEFINITIONS = ( "DEBUG=1", "$(inherited)", + _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION, ); GCC_SYMBOLS_PRIVATE_EXTERN = NO; GCC_WARN_64_TO_32_BIT_CONVERSION = YES; @@ -851,8 +816,16 @@ IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = YES; ONLY_ACTIVE_ARCH = YES; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-Wl", + "-ld_classic", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; + USE_HERMES = false; }; name = Debug; }; @@ -861,7 +834,7 @@ buildSettings = { ALWAYS_SEARCH_USER_PATHS = NO; CLANG_ANALYZER_LOCALIZABILITY_NONLOCALIZED = YES; - CLANG_CXX_LANGUAGE_STANDARD = "c++17"; + CLANG_CXX_LANGUAGE_STANDARD = "c++20"; CLANG_CXX_LIBRARY = "libc++"; CLANG_ENABLE_MODULES = YES; CLANG_ENABLE_OBJC_ARC = YES; @@ -890,9 +863,13 @@ ENABLE_BITCODE = NO; ENABLE_NS_ASSERTIONS = NO; ENABLE_STRICT_OBJC_MSGSEND = YES; - "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = i386; + "EXCLUDED_ARCHS[sdk=iphonesimulator*]" = ""; GCC_C_LANGUAGE_STANDARD = gnu99; GCC_NO_COMMON_BLOCKS = YES; + GCC_PREPROCESSOR_DEFINITIONS = ( + "$(inherited)", + _LIBCPP_ENABLE_CXX17_REMOVED_UNARY_BINARY_FUNCTION, + ); GCC_WARN_64_TO_32_BIT_CONVERSION = YES; GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; GCC_WARN_UNDECLARED_SELECTOR = YES; @@ -901,8 +878,16 @@ GCC_WARN_UNUSED_VARIABLE = YES; IPHONEOS_DEPLOYMENT_TARGET = 13.0; MTL_ENABLE_DEBUG_INFO = NO; + OTHER_CFLAGS = "$(inherited)"; + OTHER_CPLUSPLUSFLAGS = "$(inherited)"; + OTHER_LDFLAGS = ( + "$(inherited)", + "-Wl", + "-ld_classic", + ); REACT_NATIVE_PATH = "${PODS_ROOT}/../../node_modules/react-native"; SDKROOT = iphoneos; + USE_HERMES = false; VALIDATE_PRODUCT = YES; }; name = Release; diff --git a/detox/test/ios/example/CalendarManager.h b/detox/test/ios/example/CalendarManager.h deleted file mode 100644 index 341669337a..0000000000 --- a/detox/test/ios/example/CalendarManager.h +++ /dev/null @@ -1,5 +0,0 @@ -#import - -@interface CalendarManager : NSObject - -@end diff --git a/detox/test/ios/example/CalendarManager.m b/detox/test/ios/example/CalendarManager.m deleted file mode 100644 index cf32792ccc..0000000000 --- a/detox/test/ios/example/CalendarManager.m +++ /dev/null @@ -1,23 +0,0 @@ -#import "CalendarManager.h" -#import - -@implementation CalendarManager -RCT_EXPORT_MODULE(); - -RCT_EXPORT_METHOD(getAuthorizationStatus:(RCTPromiseResolveBlock)resolve rejecter:(RCTPromiseRejectBlock)reject) -{ - EKAuthorizationStatus status = [EKEventStore authorizationStatusForEntityType:EKEntityTypeEvent]; - NSString* permission; - if (status == EKAuthorizationStatusAuthorized) - { - permission = @"granted"; - } - else - { - permission = @"denied"; - } - - resolve(permission); -} - -@end diff --git a/detox/test/ios/example/Info.plist b/detox/test/ios/example/Info.plist index 3a26ebfc8a..45242fcbe7 100644 --- a/detox/test/ios/example/Info.plist +++ b/detox/test/ios/example/Info.plist @@ -38,12 +38,46 @@ NSAppTransportSecurity NSAllowsArbitraryLoads + + NSAllowsLocalNetworking + NSAppleMusicUsageDescription + + NSBluetoothAlwaysUsageDescription + + NSBluetoothPeripheralUsageDescription + NSCalendarsUsageDescription + NSCameraUsageDescription + + NSContactsUsageDescription + + NSFaceIDUsageDescription + + NSLocationAlwaysAndWhenInUseUsageDescription + + NSLocationTemporaryUsageDescriptionDictionary + NSLocationWhenInUseUsageDescription + NSMicrophoneUsageDescription + + NSMotionUsageDescription + + NSPhotoLibraryAddUsageDescription + + NSPhotoLibraryUsageDescription + + NSRemindersUsageDescription + + NSSiriUsageDescription + + NSSpeechRecognitionUsageDescription + + NSUserTrackingUsageDescription + UIBackgroundModes fetch diff --git a/detox/test/ios/example/example.entitlements b/detox/test/ios/example/example.entitlements new file mode 100644 index 0000000000..21d95c45f3 --- /dev/null +++ b/detox/test/ios/example/example.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.siri + + + diff --git a/detox/test/ios/example_ci.entitlements b/detox/test/ios/example_ci.entitlements new file mode 100644 index 0000000000..21d95c45f3 --- /dev/null +++ b/detox/test/ios/example_ci.entitlements @@ -0,0 +1,8 @@ + + + + + com.apple.developer.siri + + + diff --git a/detox/test/metro.config.js b/detox/test/metro.config.js index 0970d061be..f90cae0692 100644 --- a/detox/test/metro.config.js +++ b/detox/test/metro.config.js @@ -10,7 +10,22 @@ try { } } + +const {getDefaultConfig, mergeConfig} = require('@react-native/metro-config'); + +/** + * Metro configuration + * https://facebook.github.io/metro/docs/configuration + * + * @type {import('metro-config').MetroConfig} + */ +const config = {}; +const baseConfig = mergeConfig(getDefaultConfig(__dirname), config); + + + module.exports = { + ...baseConfig, resolver: { blacklistRE: createBlacklist([/detox\/node_modules\/react-native\/.*/]), }, diff --git a/detox/test/package.json b/detox/test/package.json index 8d0e369991..eafa02ce07 100644 --- a/detox/test/package.json +++ b/detox/test/package.json @@ -1,9 +1,9 @@ { "name": "detox-test", - "version": "20.13.1", + "version": "20.18.1", "private": true, "engines": { - "node": ">=14.5.0" + "node": ">=18" }, "scripts": { "lint": "eslint .", @@ -32,46 +32,51 @@ }, "dependencies": { "@react-native-async-storage/async-storage": "^1.17.3", - "@react-native-community/checkbox": "0.5.12", + "@react-native-community/checkbox": "0.5.16", "@react-native-community/datetimepicker": "^6.7.1", "@react-native-community/geolocation": "^2.0.2", - "@react-native-community/slider": "4.2.4", + "@react-native-community/slider": "4.5.0", "@react-native-picker/picker": "^2.1.0", "@react-native-segmented-control/segmented-control": "2.3.0", "moment": "^2.24.0", "react": "18.2.0", - "react-native": "0.71.10", + "react-native": "0.73.2", "react-native-launch-arguments": "^4.0.0", + "react-native-permissions": "^4.0.2", "react-native-webview": "^11.18.1" }, "devDependencies": { "@babel/core": "^7.20.0", "@babel/preset-env": "^7.20.0", "@babel/runtime": "^7.20.0", - "@react-native-community/eslint-config": "^3.0.1", - "@tsconfig/react-native": "^2.0.2", - "@types/jest": "^29.2.1", - "@types/node": "^14.18.33", - "@types/react": "^18.0.24", - "@typescript-eslint/eslint-plugin": "^5.4.0", - "@typescript-eslint/parser": "^5.4.0", + "@react-native/eslint-config": "^0.73.2", + "@react-native/metro-config": "^0.73.3", + "@react-native/typescript-config": "0.73.1", + "@tsconfig/react-native": "^3.0.0", + "@types/jest": "^29.5.11", + "@types/node": "^16.18.68", + "@types/react": "^18.2.45", + "@typescript-eslint/eslint-plugin": "^6.16.0", + "@typescript-eslint/parser": "^6.16.0", "cross-env": "^7.0.3", - "detox": "^20.13.1", - "eslint": "^8.41.0", - "eslint-plugin-unicorn": "^47.0.0", + "detox": "^20.18.1", + "detox-allure2-adapter": "^1.0.0-alpha.3", + "eslint": "^8.56.0", + "eslint-plugin-unicorn": "^50.0.1", "execa": "^5.1.1", "express": "^4.15.3", "glob": "^7.2.0", - "jest": "^29.2.1", - "jest-allure2-reporter": "2.0.0-alpha.6", + "jest": "^29.6.3", + "jest-allure2-reporter": "^2.0.0-beta.9", "jest-junit": "^10.0.0", + "jest-metadata": "^1.3.1", "lodash": "^4.14.1", - "metro-react-native-babel-preset": "0.73.9", + "metro-react-native-babel-preset": "0.76.8", "nyc": "^15.1.0", "p-iteration": "^1.1.8", "pngjs": "^3.4.0", "react-native-codegen": "^0.0.8", - "typescript": "^4.1.3" + "typescript": "^5.3.3" }, "jest-junit": { "suiteName": "Detox E2E tests", diff --git a/detox/test/scripts/assert_timeout.js b/detox/test/scripts/assert_timeout.js index 1192fe61bb..8e4f851b6c 100644 --- a/detox/test/scripts/assert_timeout.js +++ b/detox/test/scripts/assert_timeout.js @@ -1,7 +1,7 @@ // eslint-disable no-process-exit const cp = require('child_process'); -const [_0, _1, command, ...args] = process.argv; +const [,, command, ...args] = process.argv; const testProcess = cp.spawn(command, args, { stdio: 'inherit' }); diff --git a/detox/test/scripts/postinstall.js b/detox/test/scripts/postinstall.js index d2282cd40f..c4df647ef1 100644 --- a/detox/test/scripts/postinstall.js +++ b/detox/test/scripts/postinstall.js @@ -1,44 +1,43 @@ const fs = require('fs-extra'); -const path = require('path'); -const semver = require('semver'); - -const rnVersion = function() { - const rnPackageJson = require('react-native/package.json'); - return rnPackageJson.version; -}(); - -function overrideGradleWrapperVersion() { - const gradleWrapperOld = - '#!!! Patched by post-install script !!!\n' + - '#!!! Do not commit !!!\n' + - 'distributionBase=GRADLE_USER_HOME\n' + - 'distributionPath=wrapper/dists\n' + - 'zipStoreBase=GRADLE_USER_HOME\n' + - 'zipStorePath=wrapper/dists\n' + - 'distributionUrl=https\\://services.gradle.org/distributions/gradle-6.9-all.zip\n' + - '#!!! Do not commit !!!\n'; - - const GRADLE_WRAPPER_PROPS_PATH = path.join('android', 'gradle', 'wrapper', 'gradle-wrapper.properties'); - - console.log('[POST-INSTALL] Patching gradle-wrapper.properties file back to gradle v6.9..'); - try { - fs.writeFileSync(GRADLE_WRAPPER_PROPS_PATH, gradleWrapperOld); - } catch (e) { - console.warn('[POST-INSTALL] Couldn\'t path the gradle-wrapper.properties file', e); +const cp = require('child_process'); +const { setGradleVersionByRNVersion } = require('../../scripts/updateGradle'); + +const patchBoostPodspec = () => { + const log = message => console.log(`[POST-INSTALL] ${message}`); + const boostPodspecPath = `${process.cwd()}/node_modules/react-native/third-party-podspecs/boost.podspec`; + const originalUrl = 'https://boostorg.jfrog.io/artifactory/main/release/1.76.0/source/boost_1_76_0.tar.bz2'; + const patchedUrl = 'https://archives.boost.io/release/1.76.0/source/boost_1_76_0.tar.bz2'; + + if (!fs.existsSync(boostPodspecPath)) { + log('boost.podspec does not exist, skipping patch...'); + return; } -} - -function run() { - console.log('[POST-INSTALL] Running Detox\'s test-app post-install script...'); - const version = semver.minor(rnVersion); + let boostPodspec = fs.readFileSync(boostPodspecPath, 'utf8'); - if (version < 68) { - console.log(`[POST-INSTALL] RN Version ${version} is lower than 68 - Applying dedicated patches...`); - overrideGradleWrapperVersion(); + if (!boostPodspec.includes(originalUrl)) { + log('boost.podspec is already patched or the URL is different, skipping patch...'); + return; } - console.log('[POST-INSTALL] Completed!'); + log('Applying boost.podspec patch...'); + boostPodspec = boostPodspec.replace(originalUrl, patchedUrl); + fs.writeFileSync(boostPodspecPath, boostPodspec, 'utf8'); +}; + +function podInstallIfRequired() { + if (process.platform === 'darwin' && !process.env.DETOX_DISABLE_POD_INSTALL) { + console.log('[POST-INSTALL] Running pod install...'); + patchBoostPodspec(); + + cp.execSync('pod install', { + cwd: `${process.cwd()}/ios`, + stdio: 'inherit' + }); + } } -run(); +console.log('[POST-INSTALL] Running Detox\'s test-app post-install script...'); +podInstallIfRequired(); +setGradleVersionByRNVersion() +console.log('[POST-INSTALL] Completed!'); diff --git a/detox/test/src/Screens/ActionsScreen.js b/detox/test/src/Screens/ActionsScreen.js index 8ebf46c415..ef8957c73e 100644 --- a/detox/test/src/Screens/ActionsScreen.js +++ b/detox/test/src/Screens/ActionsScreen.js @@ -9,13 +9,19 @@ import { Platform, Dimensions, StyleSheet, - Slider as LegacySlider, SafeAreaView, requireNativeComponent, } from 'react-native'; import TextInput from '../Views/TextInput'; import Slider from '@react-native-community/slider'; +let LegacySlider; +try { + LegacySlider = require('react-native').Slider; +} catch (e) { + // Ignore +} + const DoubleTapsText = requireNativeComponent('DetoxDoubleTapsTextView'); const SluggishTapsText = requireNativeComponent('DetoxSluggishTapsTextView'); @@ -174,9 +180,12 @@ export default class ActionsScreen extends Component { - - - + { + LegacySlider && + + + + } @@ -301,6 +310,6 @@ export default class ActionsScreen extends Component { backPressed: true }); return true; - }; + } } diff --git a/detox/test/src/Screens/AttributesScreen.js b/detox/test/src/Screens/AttributesScreen.js index 762fd1c746..94963253ca 100644 --- a/detox/test/src/Screens/AttributesScreen.js +++ b/detox/test/src/Screens/AttributesScreen.js @@ -4,12 +4,17 @@ import { Text, View, TextInput, - ScrollView, - Slider as LegacySlider, - DatePickerIOS // TODO: migrate to @react-native-community/datetimepicker + ScrollView } from 'react-native'; import CheckBox from '@react-native-community/checkbox'; import Slider from '@react-native-community/slider'; +import {default as DatePicker} from '@react-native-community/datetimepicker'; +let LegacySlider; +try { + LegacySlider = require('react-native').Slider; +} catch (e) { + // Ignore +} export default class AttributesScreen extends Component { constructor(props) { @@ -22,21 +27,22 @@ export default class AttributesScreen extends Component { render() { const datePicker = Platform.OS === 'ios' ? - () + () : undefined; return ( - + {datePicker} - + - - + + TextView - + Some inner text Some more inner text @@ -50,15 +56,29 @@ export default class AttributesScreen extends Component { /> - + {LegacySlider && } ); diff --git a/detox/test/src/Screens/DeviceScreen.js b/detox/test/src/Screens/DeviceScreen.js index ea5668d24b..c0643f9691 100644 --- a/detox/test/src/Screens/DeviceScreen.js +++ b/detox/test/src/Screens/DeviceScreen.js @@ -1,5 +1,5 @@ import React, { Component } from 'react'; -import { View, TouchableOpacity, StyleSheet, Text , findNodeHandle, UIManager} from 'react-native'; +import { View, TouchableOpacity, StyleSheet, Text } from 'react-native'; export default class DeviceScreen extends Component { diff --git a/detox/test/src/Screens/LaunchArgsScreen.js b/detox/test/src/Screens/LaunchArgsScreen.js index 905bf2263c..e14515c39d 100644 --- a/detox/test/src/Screens/LaunchArgsScreen.js +++ b/detox/test/src/Screens/LaunchArgsScreen.js @@ -1,4 +1,3 @@ -import React from 'react'; import {LaunchArguments} from 'react-native-launch-arguments'; import AbstractArgsListScreen from './AbstractArgsListScreen'; diff --git a/detox/test/src/Screens/LaunchNotificationScreen.js b/detox/test/src/Screens/LaunchNotificationScreen.js index df1914d765..8fe469a3e1 100644 --- a/detox/test/src/Screens/LaunchNotificationScreen.js +++ b/detox/test/src/Screens/LaunchNotificationScreen.js @@ -1,4 +1,3 @@ -import React from 'react'; import { NativeModules, } from 'react-native'; diff --git a/detox/test/src/Screens/LaunchUrlScreen.js b/detox/test/src/Screens/LaunchUrlScreen.js new file mode 100644 index 0000000000..a4f35dc967 --- /dev/null +++ b/detox/test/src/Screens/LaunchUrlScreen.js @@ -0,0 +1,45 @@ +import React, { Component } from 'react'; +import { + Linking, + Text, + View, +} from 'react-native'; + +export default class LaunchUrlScreen extends Component { + + constructor(props) { + super(props); + + Linking.addEventListener('url', (params) => this._handleOpenURL(params)); + + this.state = { + url: undefined, + } + } + + async componentDidMount() { + const url = await Linking.getInitialURL(); + this.setState({ + url, + }); + } + + renderText(text) { + return ( + + + {text} + + + ); + } + + render() { + return this.renderText(this.state.url); + } + + _handleOpenURL(params) { + console.log('App@handleOpenURL:', params); + this.setState({url: params.url}); + } +} diff --git a/detox/test/src/Screens/LocationScreen.js b/detox/test/src/Screens/LocationScreen.js index 21a9d9dfe9..e4546fb478 100644 --- a/detox/test/src/Screens/LocationScreen.js +++ b/detox/test/src/Screens/LocationScreen.js @@ -62,7 +62,7 @@ export default class LocationScreen extends Component { if (!this.state.locationRequested) { return ( -