diff --git a/.buildkite/jobs/pipeline.android_demo_app_rn_76.yml b/.buildkite/jobs/pipeline.android_demo_app_rn_76.yml new file mode 100644 index 000000000..905d2648e --- /dev/null +++ b/.buildkite/jobs/pipeline.android_demo_app_rn_76.yml @@ -0,0 +1,12 @@ + - label: ":android::react: RN .76 + Android: Demo app" + command: + - "nvm install" + - "./scripts/demo-projects.android.sh" + env: + REACT_NATIVE_VERSION: 0.76.3 + REACT_NATIVE_COMPAT_TEST: true # Only set 'true' in jobs with the latest supported RN + DETOX_DISABLE_POD_INSTALL: true + DETOX_DISABLE_POSTINSTALL: true + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.android_rn_73.yml b/.buildkite/jobs/pipeline.android_rn_73.yml new file mode 100644 index 000000000..4a0d7015f --- /dev/null +++ b/.buildkite/jobs/pipeline.android_rn_73.yml @@ -0,0 +1,13 @@ + - label: ":android::detox: (Old Arch) RN .73 + Android: Tests app" + command: + - "nvm install" + - "./scripts/ci.android.sh" + env: + REACT_NATIVE_VERSION: 0.73.2 + DETOX_DISABLE_POD_INSTALL: true + DETOX_DISABLE_POSTINSTALL: true + ENABLE_NEW_ARCH: false + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/**/allure-report-*.html" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.android_rn_76.yml b/.buildkite/jobs/pipeline.android_rn_76.yml new file mode 100644 index 000000000..bdbc79c0d --- /dev/null +++ b/.buildkite/jobs/pipeline.android_rn_76.yml @@ -0,0 +1,12 @@ + - label: ":android::detox: RN .76 + Android: Tests app" + command: + - "nvm install" + - "./scripts/ci.android.sh" + env: + REACT_NATIVE_VERSION: 0.76.3 + DETOX_DISABLE_POD_INSTALL: true + DETOX_DISABLE_POSTINSTALL: true + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/**/allure-report-*.html" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.android_rn_76_old_arch.yml b/.buildkite/jobs/pipeline.android_rn_76_old_arch.yml new file mode 100644 index 000000000..5a0a2c4cd --- /dev/null +++ b/.buildkite/jobs/pipeline.android_rn_76_old_arch.yml @@ -0,0 +1,13 @@ + - label: ":android::detox: (Old Arch) RN .76 + Android: Tests app" + command: + - "nvm install" + - "./scripts/ci.android.sh" + env: + REACT_NATIVE_VERSION: 0.75.4 + DETOX_DISABLE_POD_INSTALL: true + DETOX_DISABLE_POSTINSTALL: true + ENABLE_NEW_ARCH: false + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/**/allure-report-*.html" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.ios_demo_app_rn_76_new_arch.yml b/.buildkite/jobs/pipeline.ios_demo_app_rn_76_new_arch.yml new file mode 100644 index 000000000..9d7c67785 --- /dev/null +++ b/.buildkite/jobs/pipeline.ios_demo_app_rn_76_new_arch.yml @@ -0,0 +1,10 @@ + - label: ":ios::react: RN .76 + iOS: Demo app" + command: + - "nvm install" + - "./scripts/demo-projects.ios.sh" + env: + REACT_NATIVE_VERSION: 0.76.3 + RCT_NEW_ARCH_ENABLED: 1 + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.ios_rn_73.yml b/.buildkite/jobs/pipeline.ios_rn_73.yml new file mode 100644 index 000000000..a47cc4421 --- /dev/null +++ b/.buildkite/jobs/pipeline.ios_rn_73.yml @@ -0,0 +1,11 @@ + - label: ":ios::detox: (Old Arch) RN .73 + iOS: Tests app" + command: + - "nvm install" + - "./scripts/ci.ios.sh" + env: + REACT_NATIVE_VERSION: 0.73.2 + RCT_NEW_ARCH_ENABLED: 0 + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/**/allure-report-*.html" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.ios_rn_76.yml b/.buildkite/jobs/pipeline.ios_rn_76.yml new file mode 100644 index 000000000..a9827c4fe --- /dev/null +++ b/.buildkite/jobs/pipeline.ios_rn_76.yml @@ -0,0 +1,11 @@ + - label: ":ios::detox: (Old Arch) RN .76 + iOS: Tests app" + command: + - "nvm install" + - "./scripts/ci.ios.sh" + env: + REACT_NATIVE_VERSION: 0.76.3 + RCT_NEW_ARCH_ENABLED: 0 + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/**/allure-report-*.html" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/jobs/pipeline.ios_rn_76_new_arch.yml b/.buildkite/jobs/pipeline.ios_rn_76_new_arch.yml new file mode 100644 index 000000000..697e703ae --- /dev/null +++ b/.buildkite/jobs/pipeline.ios_rn_76_new_arch.yml @@ -0,0 +1,11 @@ + - label: ":ios::detox: RN .76 + iOS: Tests app" + command: + - "nvm install" + - "./scripts/ci.ios.sh" + env: + REACT_NATIVE_VERSION: 0.76.3 + RCT_NEW_ARCH_ENABLED: 1 + artifact_paths: + - "/Users/builder/uibuilder/work/coverage/**/*.lcov" + - "/Users/builder/uibuilder/work/**/allure-report-*.html" + - "/Users/builder/uibuilder/work/artifacts*.tar.gz" diff --git a/.buildkite/pipeline.debug.yml b/.buildkite/pipeline.debug.yml new file mode 100644 index 000000000..b5962cdf2 --- /dev/null +++ b/.buildkite/pipeline.debug.yml @@ -0,0 +1,27 @@ +steps: + - block: ":partyparrot: Enter the Debug Mode!" + prompt: "Detox your mind" + if: 'build.message =~ /^debug\$/i' + fields: + - select: "Ingridients" + key: "debug-builds" + multiple: true + hint: "hey Chef, what are we cooking today?" + options: + - label: "iOS RN 71" + value: "ios_rn_71" + - label: "iOS RN 73" + value: "ios_rn_73" + - label: "iOS Demo App RN 71" + value: "ios_demo_app_rn_71" + - label: "iOS Demo App RN 73" + value: "ios_demo_app_rn_73" + - label: "Android RN 71" + value: "android_rn_71" + - label: "Android RN 73" + value: "android_rn_73" + - label: "Android Demo App RN 73" + value: "android_demo_app_rn_73" + + - label: "Get Release Pipeline" + command: '.buildkite/pipeline.sh debug | buildkite-agent pipeline upload' diff --git a/.buildkite/pipeline.post_processing.yml b/.buildkite/pipeline.post_processing.yml new file mode 100644 index 000000000..8b205d439 --- /dev/null +++ b/.buildkite/pipeline.post_processing.yml @@ -0,0 +1,8 @@ + - wait: ~ + continue_on_failure: true + - label: "Post-processing: Test coverage" + command: + - "nvm install" + - "mkdir -p aggregated-coverage" + - "buildkite-agent artifact download '**/*.lcov' aggregated-coverage" + - "bash scripts/aggregate_coverage.sh" diff --git a/.buildkite/pipeline.release.fast.yml b/.buildkite/pipeline.release.fast.yml new file mode 100644 index 000000000..e1d5323cf --- /dev/null +++ b/.buildkite/pipeline.release.fast.yml @@ -0,0 +1,31 @@ + - wait + - label: ":ios: Package iOS" + key: 'ios_package' + command: + - "nvm install" + - "npm run package:ios" + artifact_paths: "/Users/builder/uibuilder/work/detox/*.tbz" + + - label: ":android: Package android" + key: 'android_package' + command: + - "nvm install" + - "npm install" + - "npm run package:android" + env: + DETOX_DISABLE_POSTINSTALL: true + DETOX_DISABLE_POD_INSTALL: true + JAVA_HOME: /opt/openjdk/openlogic-openjdk-17.0.9+9-mac-x64/jdk-17.0.9.jdk/Contents/Home + artifact_paths: "/Users/builder/uibuilder/work/detox/Detox-android/**/*" + + - label: ":shipit: Publish" + depends_on: + - 'android_package' + - 'ios_package' + command: + - "nvm install 20" + - "npm install" + - "npm run release" + env: + DETOX_DISABLE_POD_INSTALL: true + DETOX_DISABLE_POSTINSTALL: true diff --git a/.buildkite/pipeline.release.yml b/.buildkite/pipeline.release.yml new file mode 100644 index 000000000..3fbba2b67 --- /dev/null +++ b/.buildkite/pipeline.release.yml @@ -0,0 +1,65 @@ +steps: + - block: ":rocket: Release!" + prompt: "Fill out the details for release" + if: 'build.message =~ /^release\$/i' + fields: + - select: "IS PRE-RELEASE?" + key: "pre-release" + default: "true" + hint: "A corresponding pre-release prefix will be added to published version name, with the npm tag of this version (for instance, `19.5.0-preminor.0`)." + options: + - label: "Yes, this is a PRE-release version." + value: "true" + - label: "No, this is a REAL release version." + value: "false" + + - select: "VERSION TYPE" + key: "release-version-type" + hint: "A successful build will be released with a version update according to the selected type. `Bump pre-release version` will bump the pre-release version number (for instance, `19.5.0-preminor.1`)." + options: + - label: "Patch" + value: "patch" + - label: "Minor" + value: "minor" + - label: "Major" + value: "major" + - label: "Bump pre-release version" + value: "release" + + - select: "IS DRY RUN?" + key: "release-dry-run" + hint: "When set to `Yes`, don't actually release anything, just run the scripts." + default: "false" + options: + - label: "Yes" + value: "true" + - label: "No" + value: "false" + + - select: "SKIP NPM-PUBLISH?" + key: "release-skip-npm" + hint: "This is a hack to fix previously broken release jobs. You need to really know what you're doing." + default: "false" + options: + - label: "Yes" + value: "true" + - label: "No" + value: "false" + + - select: "DO FAST-RELEASE?" + key: "fast-release" + hint: "When set to `Yes`, skip building and testing. USE ONLY IF YOU REALLY REALLY HAVE TO!" + default: "false" + options: + - label: "Yes" + value: "true" + - label: "No" + value: "false" + + - text: "NPM TAG" + key: "release-npm-tag" + hint: "Leave `null` for default. For releases from master, the default is `latest`, unless this is a pre-release version, then uses `prerelease` as tag. For releases from other branches, uses `next` if from the `next` branch and `smoke` otherwise." + default: 'null' + + - label: "Get Release Pipeline" + command: '.buildkite/pipeline.sh release | buildkite-agent pipeline upload' diff --git a/.buildkite/pipeline.sh b/.buildkite/pipeline.sh new file mode 100755 index 000000000..ece5ad868 --- /dev/null +++ b/.buildkite/pipeline.sh @@ -0,0 +1,29 @@ +#!/bin/bash -e + +if [[ "$1" == 'start' ]];then + if [[ $(echo $BUILDKITE_MESSAGE | tr '[:upper:]' '[:lower:]') =~ ^release$ ]];then + cat .buildkite/pipeline.release.yml + elif [[ $(echo $BUILDKITE_MESSAGE | tr '[:upper:]' '[:lower:]') =~ ^debug$ ]];then + cat .buildkite/pipeline.debug.yml + else + .buildkite/pipeline_common.sh + fi + +elif [[ "$1" == 'release' ]];then + FAST_RELEASE=$(buildkite-agent meta-data get fast-release) + if [[ "$FAST_RELEASE" == 'true' ]];then + cat .buildkite/pipeline.release.fast.yml + else + .buildkite/pipeline_common.sh + cat .buildkite/pipeline.release.fast.yml + fi + +elif [[ "$1" == 'debug' ]];then + stepsList=$(buildkite-agent meta-data get debug-builds) + steps=($(echo $stepsList| tr ";" "\n")) + echo "steps:" + for step in "${steps[@]}" + do + cat .buildkite/jobs/pipeline.$step.yml + done +fi diff --git a/.buildkite/pipeline_common.sh b/.buildkite/pipeline_common.sh new file mode 100755 index 000000000..98d0b4b45 --- /dev/null +++ b/.buildkite/pipeline_common.sh @@ -0,0 +1,13 @@ +#!/bin/bash -e + +echo "steps:" + +cat .buildkite/jobs/pipeline.ios_rn_76_new_arch.yml +cat .buildkite/jobs/pipeline.ios_rn_76.yml +cat .buildkite/jobs/pipeline.ios_rn_73.yml +cat .buildkite/jobs/pipeline.ios_demo_app_rn_76_new_arch.yml +cat .buildkite/jobs/pipeline.android_rn_76.yml +cat .buildkite/jobs/pipeline.android_rn_76_old_arch.yml +cat .buildkite/jobs/pipeline.android_rn_73.yml +cat .buildkite/jobs/pipeline.android_demo_app_rn_76.yml +cat .buildkite/pipeline.post_processing.yml diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 000000000..ad7df8f92 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,35 @@ +root = true + +[*] +charset = utf-8 +end_of_line = lf +indent_size = 4 +indent_style = space +insert_final_newline = true +max_line_length = 120 +tab_width = 4 +trim_trailing_whitespace = true + +[{*.ats,*.cts,*.mts,*.ts}] +indent_size = 2 +tab_width = 2 + +[detox/index.d.ts] +indent_size = 4 +tab_width = 4 + +[{*.cjs,*.js,*.mjs}] +indent_size = 2 +tab_width = 2 + +[{*.md,*.mdx}] +indent_size = 2 +tab_width = 2 + +[{*.yaml,*.yml}] +indent_size = 2 +tab_width = 2 + +[{*.css,*.scss,*.har,*.jsb2,*.jsb3,*.json,.babelrc,.eslintrc,.prettierrc,.stylelintrc,bowerrc,jest.config}] +indent_size = 2 +tab_width = 2 diff --git a/.ghp/wix.json b/.ghp/wix.json new file mode 100644 index 000000000..dffc89691 --- /dev/null +++ b/.ghp/wix.json @@ -0,0 +1,13 @@ +{ + "name": "detox", + "title": "Detox", + "description": "Graybox E2E tests and automation library for mobile", + "github": "https://github.com/wix/detox", + "AndroidVideoUrl": "", + "IOSVideoUrl": "", + "IOSDemoAppLink": "", + "MacVideoUrl": "https://github.com/wix/react-native/blob/master/src/videos/detox.mp4?raw=true", + "image": "", + "size" : "big", + "AndroidDemoAppLink": "" +} diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..f31af735d --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +*.sh text eol=lf +/detox-cli/cli.js text eol=lf +/detox/local-cli/cli.js text eol=lf +/generation/index.js text eol=lf diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 000000000..2687b696f --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,29 @@ +* @d4vidi + +# Scripts + +*.js @d4vidi @noomorph +*.ts @d4vidi @noomorph +*.sh @d4vidi @noomorph + +# Android + +detox/android/* @d4vidi @jonathanmos +detox/test/android/* @d4vidi @jonathanmos +*.java @d4vidi @jonathanmos +*.kt @d4vidi @jonathanmos +*.gradle @d4vidi @jonathanmos + +# iOS + +detox/ios/* @asafkorem +detox/test/ios/* @asafkorem +*.h @asafkorem +*.m @asafkorem +*.mm @asafkorem +*.cpp @asafkorem +*.swift @asafkorem +*.pch @asafkorem +*.plist @asafkorem +*.xcodeproj @asafkorem +*.xcworkspace @asafkorem diff --git a/.github/ISSUE_TEMPLATE.md b/.github/ISSUE_TEMPLATE.md new file mode 100644 index 000000000..1e4fa9b2c --- /dev/null +++ b/.github/ISSUE_TEMPLATE.md @@ -0,0 +1 @@ +### IMPORTANT: DO NOT USE THIS FORM FOR SUBMITTING ISSUES. USE THE DESIGNATED ISSUE TEMPLATES ON GITHUB AND POPULATE ALL THE REQUIRED INFORMATION. diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT_ANDROID.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT_ANDROID.yml new file mode 100644 index 000000000..3ef5a08d5 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT_ANDROID.yml @@ -0,0 +1,69 @@ +name: "šŸž Bug Report - Android" +description: Report an Android-specific bug to help us improve Detox +labels: ["type: bug šŸž", "platform: android", "status: triage"] +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this bug report! + - type: textarea + id: description + attributes: + label: What happened? + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: expected-behaviour + attributes: + label: What was the expected behaviour? + description: A clear and concise description of what was expected to happen. + - type: checkboxes + id: tested-last-detox + attributes: + label: Was it tested on latest Detox? + description: Before reporting this issue, please make sure it can be reproduced on the latest released version of Detox, see our [releases](https://github.com/wix/Detox/releases). + options: + - label: I have tested this issue on the latest Detox release and it still reproduces. + required: true + - type: checkboxes + id: timeout + attributes: + label: Did your test throw out a timeout? + description: If this is the case, try going over our synchronization troubleshooting guide. + options: + - label: I have followed the instructions under [Identifying which synchronization mechanism causes us to wait too much](https://wix.github.io/Detox/docs/troubleshooting/synchronization#identifying-which-synchronization-mechanism-causes-us-to-wait-too-much). + - type: textarea + id: reproduction + attributes: + label: Help us reproduce this issue! + description: "Provide the steps necessary to reproduce the issue. If you are seeing a regression, try to provide the last known version where the issue did not reproduce.\n\n + In case of a vague bug or a crash, please create an example project that reproduces it by forking the ready-to-go [DetoxTemplate project](https://github.com/wix-incubator/DetoxTemplate) and applying the minimal changes required for it to reproduce (e.g. add 3rd party libraries / E2E tests). For complete information, please review the guidelines there." + - type: textarea + id: environment + attributes: + label: In what environment did this happen? + description: "Note: the test runner is Jest by default, unless overridden via testRunner property in your detox configuration file (e.g. package.json, detox.config)." + value: "Detox version: + \nReact Native version: + \nHas Fabric (React Native's new rendering system) enabled: (yes/no) + \nNode version: + \nDevice model: + \nAndroid version: + \nTest-runner (select one): jest / other" + - type: textarea + id: detox-logs + attributes: + label: Detox logs + description: Please run your tests using the `--loglevel trace` argument. See [artifacts documentation](https://wix.github.io/Detox/docs/config/artifacts). + value: "
Detox logs\n\n```\npaste logs here!\n```\n\n
" + - type: textarea + id: device-logs + attributes: + label: Device logs + description: Please run your tests using the `--record-logs all` argument and paste the `device.log` file. See [artifacts documentation](https://wix.github.io/Detox/docs/config/artifacts#launch-arguments). + value: "
Device logs\n\n```\npaste your device.log here!\n```\n\n
" + - type: textarea + id: test-artifacts + attributes: + label: More data, please! + description: Please provide any other relevant test artifacts (Screenshots, Videos..). See [artifacts documentation](https://wix.github.io/Detox/docs/api/artifacts). diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT_IOS.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT_IOS.yml new file mode 100644 index 000000000..a537a79ff --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT_IOS.yml @@ -0,0 +1,71 @@ +name: "šŸž Bug Report - iOS" +description: Report an iOS-specific bug to help us improve Detox +labels: ["type: bug šŸž", "platform: ios", "status: triage"] +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this bug report! + - type: textarea + id: description + attributes: + label: What happened? + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: expected-behaviour + attributes: + label: What was the expected behaviour? + description: A clear and concise description of what was expected to happen. + - type: checkboxes + id: tested-last-detox + attributes: + label: Was it tested on latest Detox? + description: Before reporting this issue, please make sure it can be reproduced on the latest released version of Detox, see our [releases](https://github.com/wix/Detox/releases). + options: + - label: I have tested this issue on the latest Detox release and it still reproduces. + required: true + - type: checkboxes + id: timeout + attributes: + label: Did your test throw out a timeout? + description: If this is the case, try going over our synchronization troubleshooting guide. + options: + - label: I have followed the instructions under [Identifying which synchronization mechanism causes us to wait too much](https://wix.github.io/Detox/docs/troubleshooting/synchronization#identifying-which-synchronization-mechanism-causes-us-to-wait-too-much). + - type: textarea + id: reproduction + attributes: + label: Help us reproduce this issue! + description: "Provide the steps necessary to reproduce the issue. If you are seeing a regression, try to provide the last known version where the issue did not reproduce.\n\n + In case of a vague bug or a crash, please create an example project that reproduces it by forking the ready-to-go [DetoxTemplate project](https://github.com/wix-incubator/DetoxTemplate) and applying the minimal changes required for it to reproduce (e.g. add 3rd party libraries / E2E tests). For complete information, please review the guidelines there." + - type: textarea + id: environment + attributes: + label: In what environment did this happen? + description: "Note: the test runner is Jest by default, unless overridden via testRunner property in your detox configuration file (e.g. package.json, detox.config)." + value: "Detox version: + \nReact Native version: + \nHas Fabric (React Native's new rendering system) enabled: (yes/no) + \nNode version: + \nDevice model: + \niOS version: + \nmacOS version: + \nXcode version: + \nTest-runner (select one): jest / other" + - type: textarea + id: detox-logs + attributes: + label: Detox logs + description: Please run your tests using the `--loglevel trace` argument. See [artifacts documentation](https://wix.github.io/Detox/docs/api/artifacts). + value: "
Detox logs\n\n```\npaste logs here!\n```\n\n
" + - type: textarea + id: device-logs + attributes: + label: Device logs + description: Please run your tests using the `--record-logs all` argument and paste the `device.log` file. See [artifacts documentation](https://wix.github.io/Detox/docs/config/artifacts#launch-arguments). + value: "
Device logs\n\n```\npaste your device.log here!\n```\n\n
" + - type: textarea + id: test-artifacts + attributes: + label: More data, please! + description: Please provide any other relevant test artifacts (Screenshots, Videos..). See [artifacts documentation](https://wix.github.io/Detox/docs/api/artifacts). diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT_OTHER.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT_OTHER.yml new file mode 100644 index 000000000..9778cf48e --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT_OTHER.yml @@ -0,0 +1,62 @@ +name: "šŸž Bug Report - Other" +description: Report a non-platform-specific bug to help us improve Detox +labels: ["type: bug šŸž", "status: triage"] +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this bug report! + - type: textarea + id: description + attributes: + label: What happened? + description: A clear and concise description of what the bug is. + validations: + required: true + - type: textarea + id: expected-behaviour + attributes: + label: What was the expected behaviour? + description: A clear and concise description of what was expected to happen. + - type: markdown + attributes: + value: "***If the bug occurred while running tests, please re-consider opening a platform-specific bug (iOS or Android), [here](https://github.com/wix/Detox/issues/new/choose). If this is not relevant for a specific platform, please fill in the following fields.***" + - type: checkboxes + id: tested-last-detox + attributes: + label: Was it tested on latest Detox? + description: Before reporting this issue, please make sure it can be reproduced on the latest released version of Detox, see our [releases](https://github.com/wix/Detox/releases). + options: + - label: I have tested this issue on the latest Detox release and it still reproduces. + - type: textarea + id: reproduction + attributes: + label: Help us reproduce this issue! + description: "Provide the steps necessary to reproduce the issue. If you are seeing a regression, try to provide the last known version where the issue did not reproduce.\n\n + In case of a vague bug or a crash, please create an example project that reproduces it by forking the ready-to-go [DetoxTemplate project](https://github.com/wix-incubator/DetoxTemplate) and applying the minimal changes required for it to reproduce (e.g. add 3rd party libraries / E2E tests). For complete information, please review the guidelines there." + - type: textarea + id: environment + attributes: + label: In what environment did this happen? + description: "Note: the test runner is Jest by default, unless overridden via testRunner property in your detox configuration file (e.g. package.json, detox.config)." + value: "Detox version: + \nReact Native version: + \nHas Fabric (React Native's new rendering system) enabled: (yes/no) + \nNode version: + \nTest-runner (select one): jest / other" + - type: textarea + id: detox-logs + attributes: + label: Detox logs + description: Please run your tests using the `--loglevel trace` argument. See [artifacts documentation](https://wix.github.io/Detox/docs/api/artifacts). + value: "
Detox logs\n\n```\npaste logs here!\n```\n\n
" + - type: textarea + id: device-logs + attributes: + label: Device logs + description: Please run your tests using the `--record-logs all` argument. See [artifacts documentation](https://wix.github.io/Detox/docs/api/artifacts). + value: "
Device logs\n\n```\npaste logs here!\n```\n\n
" + - type: textarea + id: test-artifacts + attributes: + label: More data, please! + description: Please provide any other relevant test artifacts (Screenshots, Videos..). See [artifacts documentation](https://wix.github.io/Detox/docs/api/artifacts). diff --git a/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml new file mode 100644 index 000000000..0728a7a6a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/ENHANCEMENT.yml @@ -0,0 +1,13 @@ +name: "āš”ļø Enhancement" +description: Suggest an idea for this project +labels: ["type: enhancement āš”ļø", "status: triage"] +body: + - type: markdown + attributes: + value: Thanks for taking the time to fill out this enhancement suggestion! + - type: textarea + id: idea-description + attributes: + label: Describe your idea + description: "A clear and concise description of what you want to happen.\n + If your feature request is related to a problem, please describe any other alternative solutions or features you’ve considered." diff --git a/.github/ISSUE_TEMPLATE/HELP_WITH_USING_DETOX.yml b/.github/ISSUE_TEMPLATE/HELP_WITH_USING_DETOX.yml new file mode 100644 index 000000000..944f825c4 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/HELP_WITH_USING_DETOX.yml @@ -0,0 +1,25 @@ +name: "šŸ›Ÿ I Need Help - Approach the Maintainers" +description: Ask a general how-to question. We recommend asking for help on Discord / Stack-Overflow, first! +labels: ["user: need help"] +body: + - type: markdown + attributes: + value: If you have issues running Detox or setting it up, please use one of the [bug templates](https://github.com/wix/Detox/issues/new/choose) instead. + - type: textarea + id: description + attributes: + label: Description + description: A clear and concise description of what you need help with. + validations: + required: true + - type: textarea + id: environment + attributes: + label: Your environment + description: "Note: the test runner is set in your detox configuration file (e.g. package.json, detox.config)." + value: "Detox version: + \nReact Native version: + \nNode version: + \nDevice model: + \nOS: + \nTest-runner (select one): jest / other" diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..50a6431ba --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,8 @@ +blank_issues_enabled: false +contact_links: + - name: "šŸ›Ÿ I Need Help - Join Our Discord Community" + url: https://discord.gg/CkD5QKheF5 + about: Get answers from the Detox community on Discord. + - name: "šŸ›Ÿ I Need Help - Post on Stack-Overflow" + url: https://stackoverflow.com/questions/tagged/detox + about: Look up or post your problem on Stack-Overflow (we have a dedicated tag!). \ No newline at end of file diff --git a/.github/lock.yml b/.github/lock.yml new file mode 100644 index 000000000..c9ec1a500 --- /dev/null +++ b/.github/lock.yml @@ -0,0 +1,11 @@ +# Number of days of inactivity before a closed issue or pull request is locked +daysUntilLock: 3 + +# Issues and pull requests with these labels will not be locked. Set to `[]` to disable +exemptLabels: ["šŸ”“ unlocked"] + +# Label to add before locking, such as `outdated`. Set to `false` to disable +lockLabel: false + +# Comment to post before locking. Set to `false` to disable +lockComment: false diff --git a/.github/no-response.yml b/.github/no-response.yml new file mode 100644 index 000000000..bfdba9e67 --- /dev/null +++ b/.github/no-response.yml @@ -0,0 +1,14 @@ +# Configuration for probot-no-response - https://github.com/probot/no-response + +# Number of days of inactivity before an Issue is closed for lack of response +daysUntilClose: 7 +# Label requiring a response +responseRequiredLabel: "user: more info needed" +# Comment to post when closing an Issue for lack of response. Set to `false` to disable +closeComment: > + This issue has been automatically closed because there has been no response + to our request for more information from the original author. + + + + For more information on bots in this reporsitory, read [this discussion](https://github.com/wix/Detox/issues/1305). diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 000000000..53fc22915 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,30 @@ +## Description + + + +- This pull request addresses the issue described here: # + + + +In this pull request, I have … + + + +--- + + +> _For features/enhancements:_ + - [ ] I have added/updated the relevant references in the [documentation](https://github.com/wix/Detox/tree/master/docs) files. + +> _For API changes:_ + - [ ] I have made the necessary changes in the [types index](https://github.com/wix/Detox/blob/master/detox/index.d.ts) file. diff --git a/.github/stale.yml b/.github/stale.yml new file mode 100644 index 000000000..c63095616 --- /dev/null +++ b/.github/stale.yml @@ -0,0 +1,67 @@ +# Configuration for probot-stale - https://github.com/probot/stale + +# Number of days of inactivity before an Issue or Pull Request becomes stale +daysUntilStale: 30 + +# Number of days of inactivity before a stale Issue or Pull Request is closed. +# Set to false to disable. If disabled, issues still need to be closed manually, but will remain marked as stale. +daysUntilClose: 7 + +# Issues or Pull Requests with these labels will never be considered stale. Set to `[]` to disable +exemptLabels: + - "status: accepted" + - "user: looking for contributors" + - "šŸ“Œ pinned" + +# Set to true to ignore issues in a project (defaults to false) +exemptProjects: false + +# Set to true to ignore issues in a milestone (defaults to false) +exemptMilestones: false + +# Label to use when marking as stale +staleLabel: "šŸš stale" + +# Comment to post when marking as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. + + If you believe the issue is still relevant, please test on the latest Detox and report back. + + + + Thank you for your contributions! + + + + For more information on bots in this repository, read [this discussion](https://github.com/wix/Detox/issues/1305). + +# Comment to post when removing the stale label. +# unmarkComment: > +# Your comment here. + +# Comment to post when closing a stale Issue or Pull Request. +closeComment: > + The issue has been closed for inactivity. + +# Limit the number of actions per hour, from 1-30. Default is 30 +limitPerRun: 30 + +# Limit to only `issues` or `pulls` +# only: issues + +# Optionally, specify configuration settings that are specific to just 'issues' or 'pulls': +pulls: + daysUntilStale: 30 + markComment: > + This pull request has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. + + Thank you for your contributions. + + For more information on bots in this repository, read [this discussion](https://github.com/wix/Detox/issues/1305). + +# issues: +# exemptLabels: +# - confirmed diff --git a/.github/workflows/Semgrep.yml b/.github/workflows/Semgrep.yml deleted file mode 100644 index 5398af9f4..000000000 --- a/.github/workflows/Semgrep.yml +++ /dev/null @@ -1,49 +0,0 @@ -# Name of this GitHub Actions workflow. -name: Semgrep - -on: - # Scan changed files in PRs (diff-aware scanning): - # The branches below must be a subset of the branches above - pull_request: - branches: ["master", "main"] - push: - branches: ["master", "main"] - schedule: - - cron: '0 6 * * *' - - -permissions: - contents: read - -jobs: - semgrep: - # User definable name of this GitHub Actions job. - permissions: - contents: read # for actions/checkout to fetch code - security-events: write # for github/codeql-action/upload-sarif to upload SARIF results - name: semgrep/ci - # If you are self-hosting, change the following `runs-on` value: - runs-on: ubuntu-latest - - container: - # A Docker image with Semgrep installed. Do not change this. - image: returntocorp/semgrep - - # Skip any PR created by dependabot to avoid permission issues: - if: (github.actor != 'dependabot[bot]') - - steps: - # Fetch project source with GitHub Actions Checkout. - - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 - # Run the "semgrep ci" command on the command line of the docker image. - - run: semgrep ci --sarif --output=semgrep.sarif - env: - # Add the rules that Semgrep uses by setting the SEMGREP_RULES environment variable. - SEMGREP_RULES: p/default # more at semgrep.dev/explore - - - name: Upload SARIF file for GitHub Advanced Security Dashboard - uses: github/codeql-action/upload-sarif@6c089f53dd51dc3fc7e599c3cb5356453a52ca9e # v2.20.0 - with: - sarif_file: semgrep.sarif - if: always() - diff --git a/.gitignore b/.gitignore new file mode 100644 index 000000000..04bcacd41 --- /dev/null +++ b/.gitignore @@ -0,0 +1,243 @@ +############ +# Node +############ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Allure reports +allure-report-*.html +allure-report +allure-results +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +################ +# JetBrains +# IDE +################ +.idea +.vscode + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + +# Custom runners +.run/ + +############ +# iOS +############ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +*/*/ios/build/ +*/*/ios/DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +*/*/ios/xcuserdata/ + +## Other +*.moved-aside +*.xcuserstate +*/*/ios/.xcode.env.local + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# Ruby / CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +*/*/ios/Pods/ +*/*/ios/Podfile.lock +*/*/vendor/bundle/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +# fastlane specific +**/fastlane/report.xml + +# deliver temporary files +**/fastlane/Preview.html + +# snapshot generated screenshots +**/fastlane/screenshots + +# scan temporary files +**/fastlane/test_output + + +############ +# Android +############ +# Built application files +*.apk +*.ap_ + +# Files for the Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +android/bin/ +android/gen/ +android/out/ + +# Gradle files +android/.gradle/ +android/build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +android/proguard/ + +# Android Studio Navigation editor temp files +android/.navigation/ + +# Android Studio captures folder +android/captures/ + +# Intellij +*.iml + +################## +# React-Native +################## +# OSX +# +.DS_Store + +# Xcode +# +build/ +xcuserdata +*.xccheckout +DerivedData +project.xcworkspace + +# Android/IJ +# +.gradle +.cxx + +# node.js +# +node_modules/ +npm-debug.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore +ios.tar +demo-native-ios/ModuleCache +detox/ios/DetoxBuild +Detox.framework.tbz + +demo-native-ios/Build +detox/DetoxBuild +examples/demo-native-ios/Build +examples/demo-native-ios/ModuleCache + +yarn.lock +detox/ios_src +package-lock.json +/detox/src/devices/detox/notifications/notification.json +detox/test/artifacts +/examples/demo-react-native/artifacts +/examples/demo-react-native-jest/artifacts + +## Final outputs + +Detox-android/ +Detox-ios-src.tbz +Detox-ios.tbz + + +# Ignore .env files across the project +**/.env + +#ignore yalc +.yalc/ +yalc.lock diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 000000000..ab3868939 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,12 @@ +[submodule "detox/ios/COSTouchVisualizer"] + path = detox/ios/COSTouchVisualizer + url = https://github.com/wix-incubator/COSTouchVisualizer +[submodule "detox/ios/DTXLoggingInfra"] + path = detox/ios/DTXLoggingInfra + url = https://github.com/wix/DTXLoggingInfra.git +[submodule "detox/ios/DetoxSync"] + path = detox/ios/DetoxSync + url = git@github.com:wix-incubator/DetoxSync.git +[submodule "detox/ios/LNViewHierarchyDumper"] + path = detox/ios/LNViewHierarchyDumper + url = https://github.com/wix-playground/LNViewHierarchyDumper.git diff --git a/.markdownlintrc b/.markdownlintrc new file mode 100644 index 000000000..b9d76e947 --- /dev/null +++ b/.markdownlintrc @@ -0,0 +1,9 @@ +{ + "first-line-heading": { "level": 1 }, + "heading-style": { "style": "atx" }, + "hr-style": { "style": "---" }, + "ol-prefix": { "style": "one_or_ordered" }, + "ul-style": { "style": "dash" }, + "line-length": 0, + "no-inline-html": 0 +} diff --git a/.npmrc b/.npmrc new file mode 100644 index 000000000..bb57d0fc2 --- /dev/null +++ b/.npmrc @@ -0,0 +1,3 @@ +legacy-peer-deps=true +package-lock=false +workspaces=false diff --git a/.nvmrc b/.nvmrc new file mode 100644 index 000000000..9de225682 --- /dev/null +++ b/.nvmrc @@ -0,0 +1 @@ +lts/iron diff --git a/.prettierrc b/.prettierrc new file mode 100644 index 000000000..1f17c1c22 --- /dev/null +++ b/.prettierrc @@ -0,0 +1,9 @@ +{ + "arrowParens": "always", + "bracketSameLine": true, + "printWidth": 140, + "singleQuote": true, + "tabWidth": 2, + "trailingComma": "none", + "useTabs": false +} diff --git a/.remarkignore b/.remarkignore new file mode 100644 index 000000000..24cb7d6c5 --- /dev/null +++ b/.remarkignore @@ -0,0 +1,5 @@ +node_modules +Pods +/detox/ios +/detox/test/ios +/website diff --git a/.remarkrc.mjs b/.remarkrc.mjs new file mode 100644 index 000000000..6e66bfb4b --- /dev/null +++ b/.remarkrc.mjs @@ -0,0 +1,74 @@ +import fs from 'fs'; + +import dictionary_en from 'dictionary-en'; +import remark_frontmatter from 'remark-frontmatter'; +import remark_gfm from 'remark-gfm'; +import remark_github from 'remark-github'; +import remark_retext from 'remark-retext'; +import remark_validate_links from 'remark-validate-links'; +import retext_contractions from 'retext-contractions'; +import retext_diacritics from 'retext-diacritics'; +import retext_english from 'retext-english'; +import retext_indefinite_article from 'retext-indefinite-article'; +import retext_profanities from 'retext-profanities'; +import retext_redundant_acronyms from 'retext-redundant-acronyms'; +import retext_repeated_words from 'retext-repeated-words'; +import retext_sentence_spacing from 'retext-sentence-spacing'; +import retext_spell from 'retext-spell'; +import retext_syntax_mentions from 'retext-syntax-mentions'; +import retext_syntax_urls from 'retext-syntax-urls'; +import {unified} from 'unified'; + +export default { + frail: true, + silentlyIgnore: true, + settings: { + bullet: '-', + bulletOther: '*', + bulletOrdered: '.', + closeAtx: false, + emphasis: '_', + fence: '`', + fences: true, + incrementListMarker: false, + listItemIndent: 1, + quote: '"', + resourceLink: false, + rule: '-', + ruleRepetition: 3, + ruleSpaces: false, + setext: false, + strong: '*' + }, + plugins: [ + [remark_frontmatter, { + type: 'yaml', + marker: '-', + }], + // GitHub and its flavored markdown integration + [remark_gfm, { + tablePipeAlign: true, + }], + remark_github, + // Links integrity. + remark_validate_links, // TODO: check how to validate footnotes + // Spelling and style. + [ remark_retext, + unified() + .use(retext_english) + .use(retext_syntax_mentions) + .use(retext_syntax_urls) + .use(retext_spell, { + dictionary: dictionary_en, + personal: fs.readFileSync('.retext-spell.dic'), + }) + .use(retext_contractions) + .use(retext_diacritics) + .use(retext_indefinite_article) + .use(retext_profanities, { sureness: 1, ignore: ['black'] }) + .use(retext_redundant_acronyms) + .use(retext_repeated_words) + .use(retext_sentence_spacing) + ], + ], +}; diff --git a/.remarkrc.nightly.mjs b/.remarkrc.nightly.mjs new file mode 100644 index 000000000..e8ea71dbe --- /dev/null +++ b/.remarkrc.nightly.mjs @@ -0,0 +1,13 @@ +import preset from './.remarkrc.mjs'; +import remark_lint_no_dead_urls from 'remark-lint-no-dead-urls'; + +export default { + ...preset, + + plugins: [ + [remark_lint_no_dead_urls, { + gotOptions: { concurrency: 2 }, + skipUrlPatterns: [/^https:\/\/developer\.android\.com(?:\/.*)?/], + }] + ] +}; diff --git a/.retext-spell.dic b/.retext-spell.dic new file mode 100644 index 000000000..463ee22e7 --- /dev/null +++ b/.retext-spell.dic @@ -0,0 +1,166 @@ +AAPT/M +AAR/M +ANR/M +AOSP/M +API/M +APK/M +AST/M +AVD/M +Android-ish +AppleSimUtils +Applitools +Bitrise +C5 +CLI +CMD +CocoaPods +CommonJS +Detox +Docusaurus +EAS/M +E2E/M +ES5 +Genymotion +GitHub +GitLab +Gradle +HTTPS +Homebrew +IPA/M +JS +JSON +JavaScript +KVM +Kotlin +M1 +MacOS +NPM +OpenJDK +POJO +PR/M +ProGuard +README/M +RN +ROI +SDK/M +SWUbanner +Siri +TCP +TLS +TeamCity +TypeScript +UDID/M +UUID/M +UI/M +UML/M +Vitest +WebDriver +Wix +XCTest +Xcode +accessibilityLabels +afterAll +afterEach +arg/M +arm64 +async +autogenerate/M +autogenerated +autostarting +beforeAll +beforeEach +biometric +blob/M +blogpost/M +breakpoint/M +bundler +bunyan +cancelled +cheatsheet/M +codebase/M +config/M +customization/M +de +deallocate/M +debuggable +dedupe +destructure/M +devDependencies +dialogs +env/M +facto +futurewise +global/M +graybox/M +hittable +ing +init/M +initializations +inlined +integrations +jure +keychain/M +lifecycle/M +localhost +lockfile/M +macOS +matcher/M +minification/M +minified +minSdkVersion +monorepo +multistep +natively +npm +openjdk +orchestrator/M +param/M +parsable +postbuild/M +pre-set +prebaked +prebuild +prebuilt +precompilation/M +precompile +precompiled +precompiler/M +preconfigure +preconfigured +predownloaded +preinstall +preinstalling +profiler/M +repackage +repackager/M +repo/M +responder/M +roadmap/M +runtimes +se +snapshotting +subdirectories +subdirectory/M +submodule/M +subprocess +subprocesses +subproject/M +subresponsibilies +subview/M +teardown/M +testID/M +typing/M +unbreak/M +unencrypted +uninstallation +unminified +unobfuscated +unregister/M +util/M +v12.x +v20 +webhook/M +websocket/M +xcodebuild +xcpretty +ᵃ diff --git a/.xcoderc b/.xcoderc new file mode 100644 index 000000000..c32b0ec5a --- /dev/null +++ b/.xcoderc @@ -0,0 +1 @@ +16.1 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 000000000..b81d78fda --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,5 @@ +# Contributing to Detox + +'So thrilled to know you want to contribute to Detox! šŸ’™ + +Please refer to our [contribution guide!](https://wix.github.io/Detox/docs/contributing). diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..cf5c07baa --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +The MIT License (MIT) + +Copyright (c) Wix.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. + diff --git a/README.md b/README.md index 32beb2ca6..62887c8ea 100644 --- a/README.md +++ b/README.md @@ -1,2 +1,99 @@ -# cloud_detox_support -Public Cloud Driver Support for Detox + + +[![SWUbanner](https://raw.githubusercontent.com/vshymanskyy/StandWithUkraine/main/banner-direct.svg)](https://stand-with-ukraine.pp.ua) + +

+ Detox +

+

+ Detox +

+

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

+

+Demo +

+

+ + [![NPM Version](https://img.shields.io/npm/v/detox.svg?style=flat)](https://www.npmjs.com/package/detox) [![NPM Downloads](https://img.shields.io/npm/dm/detox.svg?style=flat)](https://www.npmjs.com/package/detox) [![Build status](https://badge.buildkite.com/39afde30a964a6763de9753762bc80264ba141e1c1f41fc878.svg)](https://buildkite.com/wix-mobile-oss/detox) [![Coverage Status](https://coveralls.io/repos/github/wix/Detox/badge.svg?branch=master)](https://coveralls.io/github/wix/Detox?branch=master) [![Detox is released under the MIT license](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [![PR's welcome!](https://img.shields.io/badge/PRs-welcome-brightgreen.svg)](https://wix.github.io/Detox/docs/contributing) [![Discord](https://img.shields.io/discord/957617863550697482?color=%235865F2\&label=discord)](https://discord.gg/CkD5QKheF5) [![Twitter Follow](https://img.shields.io/twitter/follow/detoxe2e?label=Follow\&style=social)](https://twitter.com/detoxe2e) + +## What Does a Detox Test Look Like? + +This is a test for a login screen, it runs on a device/simulator like an actual user: + +```js +describe('Login flow', () => { + 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'); + + const loginButton = element(by.text('Login')); + await loginButton.tap(); + + await expect(loginButton).not.toExist(); + await expect(element(by.label('Welcome'))).toBeVisible(); + }); +}); +``` + +[Get started with Detox now!](https://wix.github.io/Detox/docs/introduction/getting-started) + +## About + +High velocity native mobile development requires us to adopt continuous integration workflows, which means our reliance on manual QA has to drop significantly. Detox tests your mobile app while it’s running in a real device/simulator, interacting with it just like a real user. + +The most difficult part of automated testing on mobile is the tip of the testing pyramid - E2E. The core problem with E2E tests is flakiness - tests are usually not deterministic. We believe the only way to tackle flakiness head on is by moving from black box testing to gray box testing. That’s where Detox comes into play. + +- **Cross Platform:** Write end-to-end tests in JavaScript for React Native apps (Android & iOS). +- **Debuggable:** Modern async-await API allows breakpoints in asynchronous tests to work as expected. +- **Automatically Synchronized:** Stops flakiness at the core by monitoring asynchronous operations in your app. +- **Made For CI:** Execute your E2E tests on CI platforms like Travis CI, Circle CI or Jenkins without grief. +- **Runs on Devices:** Gain confidence to ship by testing your app on a device/simulator just like a real user (not yet supported on iOS). +- **Test Runner Agnostic:** Detox provides a set of APIs to use with any test runner without it. It comes with [Jest](https://jestjs.io) integration out of the box. + +## Supported React Native Versions + +Detox was built from the ground up to support React Native projects. + +While Detox should work out of the box with almost any React Native version of the latest minor releases, official support is provided for React Native versions `0.73.x`, `0.74.x`, `0.75.x` and `0.76.x`, including React Native's ["New Architecture"](https://reactnative.dev/docs/the-new-architecture/landing-page). + +Newer versions may work with Detox but have not been thoroughly tested by the Detox team. + +Although we do not officially support older React Native versions, we do our best to keep Detox compatible with them. + +Also, in case of a problem with an unsupported version of React Native, please [submit an issue](https://github.com/wix/Detox/issues/new/choose) or write us in our [Discord server](https://discord.gg/CkD5QKheF5) and we will do our best to help out. + +### Known Issues with React Native + +- Visibility edge-case on Android: see this [RN issue](https://github.com/facebook/react-native/issues/23870). + +## Get Started with Detox + +Read the [Getting Started Guide](https://wix.github.io/Detox/docs/introduction/getting-started) to get Detox running on your app in less than 10 minutes. + +## Documents Site + +Explore further about using Detox from our new **[website](https://wix.github.io/Detox/)**. + +## Core Principles + +We believe that the only way to address the core difficulties with mobile end-to-end testing is by rethinking some of the principles of the entire approach. See what Detox [does differently](https://wix.github.io/Detox/docs/articles/design-principles). + +## Contributing to Detox + +Detox has been open-source from the first commit. If you’re interested in helping out with our roadmap, please see issues tagged with the [](https://github.com/wix/Detox/labels/user%3A%20looking%20for%20contributors) label. If you have encountered a bug or would like to suggest a new feature, please open an issue. + +Dive into Detox core by reading the [Detox Contribution Guide](https://wix.github.io/Detox/docs/contributing). + +## License + +- Detox is licensed under the [MIT License](LICENSE) + +## Non-English Resources (Community) + +- [Getting Started (Brazilian Portuguese)](https://medium.com/quia-digital/iniciando-com-detox-framework-1-4-ce31ad7ae812) diff --git a/detox-cli/cli.js b/detox-cli/cli.js new file mode 100755 index 000000000..578872f1b --- /dev/null +++ b/detox-cli/cli.js @@ -0,0 +1,77 @@ +#!/usr/bin/env node +const cp = require('child_process'); +const fs = require('fs'); +const path = require('path'); +const chalk = require('chalk'); + +/** + * @param {string} msg + */ +function log(msg) { + console.error(chalk.red(msg)); +} + +function main([_$0, _detox, ...cliArgs]) { + const [command] = cliArgs; + + if (command === 'recorder' && process.platform === 'darwin') { + return spawnRecorder(cliArgs); + } else { + return spawnDetoxBinary(cliArgs); + } +} + +function spawnDetoxBinary(cliArgs) { + const isWin32 = process.platform === 'win32'; + const nodeBinariesPath = path.join(process.cwd(), 'node_modules/.bin'); + const binaryPath = path.join(nodeBinariesPath, `detox${isWin32 ? '.cmd' : ''}`); + + if (!fs.existsSync(binaryPath)) { + log(`Failed to find Detox executable at path: ${binaryPath}`); + log(`\nPossible solutions:`); + log(`1. Make sure your current working directory is correct.`); + log(`2. Run "npm install" to ensure your "node_modules" directory is up-to-date.`); + log(`3. Run "npm install detox --save-dev" for the fresh Detox installation in your project.\n`); + + return 1; + } + + const PATH = isWin32 ? findPathKey() : 'PATH'; + const spawnOptions = { + stdio: 'inherit', + env: { + ...process.env, + [PATH]: [nodeBinariesPath, process.env.PATH].join(path.delimiter), + } + }; + + const result = isWin32 + // { shell: true } option seems to break quoting on windows? Otherwise this would be much simpler. + ? cp.spawnSync('cmd', ['/c', binaryPath, ...cliArgs], spawnOptions) + : cp.spawnSync(binaryPath, cliArgs, spawnOptions); + + return result.status; +} + +function spawnRecorder([_recorder, ...recorderArgs]) { + const detoxRecorderPath = path.join(process.cwd(), 'node_modules/detox-recorder'); + const detoxRecorderCLIPath = path.join(detoxRecorderPath, 'DetoxRecorderCLI'); + + if (!fs.existsSync(detoxRecorderCLIPath)) { + log(`Detox Recorder is not installed in this directory: ${detoxRecorderPath}`); + return 1; + } + + const result = cp.spawnSync(detoxRecorderCLIPath, recorderArgs, { stdio: 'inherit' }); + return result.status; +} + +function findPathKey() { + return Object.keys(process.env).find(isCaseInsensitivePath); +} + +function isCaseInsensitivePath(key) { + return key.toLowerCase() === 'path'; +} + +process.exit(main(process.argv)); diff --git a/detox-cli/package.json b/detox-cli/package.json new file mode 100644 index 000000000..f3a0ab566 --- /dev/null +++ b/detox-cli/package.json @@ -0,0 +1,29 @@ +{ + "name": "detox-cli", + "version": "20.26.0", + "description": "Optional wrapper for Detox CLI, meant to be installed globally", + "main": "cli.js", + "scripts": { + "test": ":" + }, + "bin": { + "detox": "./cli.js" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/wix/detox.git" + }, + "keywords": [ + "detox", + "cli" + ], + "author": "Yaroslav Serhieiev ", + "license": "MIT", + "bugs": { + "url": "https://github.com/wix/Detox/issues" + }, + "dependencies": { + "chalk": "^4.0.0" + }, + "homepage": "https://github.com/wix/Detox" +} diff --git a/detox/.eslintignore b/detox/.eslintignore new file mode 100644 index 000000000..1f41a35cb --- /dev/null +++ b/detox/.eslintignore @@ -0,0 +1,8 @@ +*.d.ts +/src/android/espressoapi/**/*.js +/coverage +/ios +/android +/test +/allure-* +/artifacts diff --git a/detox/.eslintrc.js b/detox/.eslintrc.js new file mode 100644 index 000000000..7ad888d9d --- /dev/null +++ b/detox/.eslintrc.js @@ -0,0 +1,88 @@ +module.exports = { + root: true, + extends: [ + 'eslint:recommended', + 'plugin:import/recommended', + 'plugin:node/recommended', + 'plugin:ecmascript-compat/recommended', + ], + parser: '@typescript-eslint/parser', + plugins: [ + 'unicorn', + 'import', + 'node', + '@typescript-eslint/eslint-plugin', + ], + env: { + node: true + }, + rules: { + '@typescript-eslint/no-unused-vars': [ + 'warn', + { argsIgnorePattern: '^_' }, + ], + 'array-bracket-spacing': [ + 'error', + 'never' + ], + 'computed-property-spacing': [ + 'error', + 'never' + ], + 'import/order': [ + 'error', + { + 'alphabetize': { + 'order': 'asc' + }, + 'newlines-between': 'always' + } + ], + 'no-case-declarations': 'off', + 'no-debugger': 'error', + 'no-empty': 'off', + 'no-mixed-spaces-and-tabs': 'error', + 'no-multiple-empty-lines': [ + 'error', + { + 'max': 2, + 'maxBOF': 1 + } + ], + 'no-prototype-builtins': 'off', + 'no-unused-vars': 'off', + 'node/no-unpublished-require': 'warn', + 'object-curly-spacing': [ + 'error', + 'always' + ], + 'semi': [ + 'error', + 'always' + ], + 'quotes': ['error', 'single', { + 'avoidEscape': true, + 'allowTemplateLiterals': true + }], + 'unicorn/expiring-todo-comments': ['warn', + { + allowWarningComments: false, + } + ], + }, + + overrides: [ + { + files: ['*.test.{js,ts}', '**/{__mocks__,__tests__}/*.{js, ts}'], + plugins: [ + 'no-only-tests', + ], + env: { + jest: true + }, + rules: { + 'no-only-tests/no-only-tests': 'error', + } + } + ] +}; diff --git a/detox/.gitignore b/detox/.gitignore new file mode 100644 index 000000000..c85d804ff --- /dev/null +++ b/detox/.gitignore @@ -0,0 +1,215 @@ +Detox.framework +Detox.framework.tar +detox-*.aar + +lib + +############ +# Node +############ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +################ +# JetBrains +################ +.idea + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + +############ +# iOS +############ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +ios/build/ +ios/DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +ios/xcuserdata/ + +## Other +*.moved-aside +*.xcuserstate + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +ios/Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +fastlane/report.xml +fastlane/screenshots + + +############ +# Android +############ +# Built application files +*.apk +*.ap_ + +# Files for the Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +android/bin/ +android/gen/ +android/out/ + +# Gradle files +android/.gradle/ +android/build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +android/proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +android/.navigation/ + +# Android Studio captures folder +android/captures/ + +# Intellij +*.iml + + +################## +# React-Native +################## +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +.idea +.gradle +local.properties + +# node.js +# +node_modules/ +npm-debug.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore diff --git a/detox/.npmignore b/detox/.npmignore new file mode 100644 index 000000000..bdf03f3f5 --- /dev/null +++ b/detox/.npmignore @@ -0,0 +1,238 @@ +test +/ios/ + +build/ +DerivedData/ +DetoxBuild/ +Detox.framework/ + +.eslintrc +.nvmrc +.vscode +jsconfig.json +wallaby.js + +__tests__ +__mocks__ +__snapshots__ +**/*.test.js +**.mock.js +**.mock.json + +ios_src + +################# +# from .gitignore +################# + +############ +# Node +############ +# Logs +logs +*.log +npm-debug.log* + +# Runtime data +pids +*.pid +*.seed + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (http://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules +jspm_packages + +# Optional npm cache directory +.npm + +# Optional REPL history +.node_repl_history + +################ +# JetBrains +################ +.idea + +## File-based project format: +*.iws + +## Plugin-specific files: + +# IntelliJ +/out/ + +# mpeltonen/sbt-idea plugin +.idea_modules/ + +# JIRA plugin +atlassian-ide-plugin.xml + +# Crashlytics plugin (for Android Studio and IntelliJ) +com_crashlytics_export_strings.xml +crashlytics.properties +crashlytics-build.properties +fabric.properties + + +############ +# iOS +############ +# Xcode +# +# gitignore contributors: remember to update Global/Xcode.gitignore, Objective-C.gitignore & Swift.gitignore + +## Build generated +ios/build/ +ios/DerivedData/ + +## Various settings +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +ios/xcuserdata/ + +## Other +*.moved-aside +*.xcuserstate + +## Obj-C/Swift specific +*.hmap +*.ipa +*.dSYM.zip +*.dSYM + +# CocoaPods +# +# We recommend against adding the Pods directory to your .gitignore. However +# you should judge for yourself, the pros and cons are mentioned at: +# https://guides.cocoapods.org/using/using-cocoapods.html#should-i-check-the-pods-directory-into-source-control +# +ios/Pods/ + +# Carthage +# +# Add this line if you want to avoid checking in source code from Carthage dependencies. +# Carthage/Checkouts + +Carthage/Build + +# fastlane +# +# It is recommended to not store the screenshots in the git repo. Instead, use fastlane to re-generate the +# screenshots whenever they are needed. +# For more information about the recommended setup visit: +# https://github.com/fastlane/fastlane/blob/master/fastlane/docs/Gitignore.md + +fastlane/report.xml +fastlane/screenshots + + +############ +# Android +############ +# Built application files +*.apk +*.ap_ + +# Files for the Dalvik VM +*.dex + +# Java class files +*.class + +# Generated files +android/bin/ +android/gen/ +android/out/ + +# Gradle files +android/.gradle/ +android/build/ + +# Local configuration file (sdk path, etc) +local.properties + +# Proguard folder generated by Eclipse +android/proguard/ + +# Log Files +*.log + +# Android Studio Navigation editor temp files +android/.navigation/ + +# Android Studio captures folder +android/captures/ + +# Intellij +*.iml + +# Keystore files +*.jks + +################## +# React-Native +################## +# OSX +# +.DS_Store + +# Xcode +# +build/ +*.pbxuser +!default.pbxuser +*.mode1v3 +!default.mode1v3 +*.mode2v3 +!default.mode2v3 +*.perspectivev3 +!default.perspectivev3 +xcuserdata +*.xccheckout +*.moved-aside +DerivedData +*.hmap +*.ipa +*.xcuserstate +project.xcworkspace + +# Android/IJ +# +.idea +.gradle +local.properties + +# node.js +# +node_modules/ +npm-debug.log + +# BUCK +buck-out/ +\.buckd/ +android/app/libs +android/keystores/debug.keystore diff --git a/detox/.vscode/launch.json b/detox/.vscode/launch.json new file mode 100644 index 000000000..d57b1bb98 --- /dev/null +++ b/detox/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + // Use IntelliSense to learn about possible Node.js debug attributes. + // Hover to view descriptions of existing attributes. + // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 + "version": "0.2.0", + "configurations": [ + { + "name": "mocha", + "type": "node", + "request": "launch", + "program": "${workspaceRoot}/test/node_modules/mocha/bin/_mocha", + "stopOnEntry": false, + "args": [ + "test/e2e", + "--opts", + "${workspaceRoot}/test/e2e/mocha.opts", + "--no-timeouts", + "--colors" + ], + "cwd": "${workspaceRoot}", + "runtimeExecutable": null, + "env": { + "NODE_ENV": "testing" + } + } + ] +} \ No newline at end of file diff --git a/detox/.vscode/settings.json b/detox/.vscode/settings.json new file mode 100644 index 000000000..187a47f79 --- /dev/null +++ b/detox/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "eslint.enable": false, + "git.ignoreLimitWarning": true +} \ No newline at end of file diff --git a/detox/LICENSE b/detox/LICENSE new file mode 100644 index 000000000..1fc000867 --- /dev/null +++ b/detox/LICENSE @@ -0,0 +1,20 @@ +The MIT License (MIT) + +Copyright (c) Wix.com + +Permission is hereby granted, free of charge, to any person obtaining a copy of +this software and associated documentation files (the "Software"), to deal in +the Software without restriction, including without limitation the rights to +use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of +the Software, and to permit persons to whom the Software is furnished to do so, +subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS +FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR +COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER +IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN +CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. diff --git a/detox/README.md b/detox/README.md new file mode 100644 index 000000000..59b4f6d57 --- /dev/null +++ b/detox/README.md @@ -0,0 +1,9 @@ +# Detox + +Graybox End-to-End Tests and Automation Library for Mobile + +[![NPM Version](https://img.shields.io/npm/v/detox.svg?style=flat)](https://www.npmjs.com/package/detox) +[![Build Status](https://img.shields.io/jenkins/s/http/jenkins-oss.wixpress.com:8080/job/multi-detox-master.svg)](https://jenkins-oss.wixpress.com/job/multi-detox-master/) +[![NPM Downloads](https://img.shields.io/npm/dm/detox.svg?style=flat)](https://www.npmjs.com/package/detox) + +For more information [Read The Docs](https://wix.github.io/Detox/docs). diff --git a/detox/__tests__/helpers.js b/detox/__tests__/helpers.js new file mode 100644 index 000000000..397ff89fe --- /dev/null +++ b/detox/__tests__/helpers.js @@ -0,0 +1,93 @@ +const path = require('path'); + +const fs = require('fs-extra'); +const _ = require('lodash'); +const tempfile = require('tempfile'); +const yargs = require('yargs'); + +function callCli(modulePath, cmd) { + return new Promise((resolve, reject) => { + const originalModule = require(path.join(__dirname, '../local-cli', modulePath)); + const originalHandler = originalModule.handler; + const spiedModule = { + ...originalModule, + handler: async program => { + try { + return await originalHandler(program); + } catch (e) { + reject(e); + } finally { + resolve(); + } + } + }; + + return yargs + .scriptName('detox') + .parserConfiguration({ + 'boolean-negation': true, + 'camel-case-expansion': false, + 'dot-notation': false, + 'duplicate-arguments-array': false, + 'populate--': true, + }) + .command(spiedModule) + .wrap(null) + .exitProcess(false) + .fail((msg, err) => reject(err || msg)) + .parse(cmd, (err) => err && reject(err)); + }); +} + +function buildMockCommand(options = {}) { + if (!options.stdout) { + options.stdout = tempfile('.txt'); + } + + return { + get cmd() { + const env = [ + options.exitCode ? `CLI_EXIT_CODE=${options.exitCode}` : undefined, + options.sleep ? `CLI_SLEEP=${options.sleep}` : undefined, + `CLI_TEST_STDOUT=${options.stdout}`, + ].filter(Boolean).join(' '); + + return `cross-env ${env} node ${path.join(__dirname, '../local-cli/__mocks__/executable')}`; + }, + + options, + + _calls: undefined, + + get calls() { + if (this._calls === undefined) { + if (fs.existsSync(options.stdout)) { + this._calls = fs.readFileSync(options.stdout, 'utf8') + .trim() + .split('\n') + .map(c => JSON.parse(c)); + } else { + this._calls = []; + } + } + + return this._calls; + }, + + async clean() { + await fs.remove(options.stdout); + }, + }; +} + +exports.buildMockCommand = buildMockCommand; +exports.callCli = callCli; +exports.latestInstanceOf = (clazz) => _.last(clazz.mock.instances); +exports.lastCallTo = (mocked) => _.last(mocked.mock.calls); +exports.backupProcessEnv = () => { + /** @type {NodeJS.ProcessEnv} */ + let environmentCopy; + beforeEach(() => environmentCopy = process.env); + afterEach(() => process.env = { ...environmentCopy }); +}; + diff --git a/detox/__tests__/setupJest.js b/detox/__tests__/setupJest.js new file mode 100644 index 000000000..277f99578 --- /dev/null +++ b/detox/__tests__/setupJest.js @@ -0,0 +1,3 @@ +jest.mock('proper-lockfile'); +jest.mock('signal-exit', () => jest.fn(() => () => {})); +jest.mock('../src/logger/DetoxLogger'); diff --git a/detox/android/.gitignore b/detox/android/.gitignore new file mode 100644 index 000000000..39fb081a4 --- /dev/null +++ b/detox/android/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/detox/android/build.gradle b/detox/android/build.gradle new file mode 100644 index 000000000..9cddf0cdc --- /dev/null +++ b/detox/android/build.gradle @@ -0,0 +1,56 @@ +buildscript { + apply from: './rninfo.gradle' + + ext { + isOfficialDetoxLib = true + kotlinVersion = '1.9.24' + dokkaVersion = '1.9.10' + buildToolsVersion = '35.0.0' + compileSdkVersion = 35 + targetSdkVersion = 35 + minSdkVersion = 24 + } + ext.detoxKotlinVersion = ext.kotlinVersion + + repositories { + google() + mavenCentral() + } + dependencies { + if (!rnInfo.isRN71OrNewer) { + classpath "com.facebook.react:react-native-gradle-plugin" + } + classpath 'com.android.tools.build:gradle' + classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" + classpath "org.jetbrains.dokka:dokka-gradle-plugin:$dokkaVersion" + + // Needed by Spek (https://spekframework.org/setup-android) + classpath 'de.mannodermaus.gradle.plugins:android-junit5:1.7.1.1' + } +} + +allprojects { + repositories { + google() + mavenCentral() + mavenLocal() + + // In RN's below 71, the native code comes from within node_modules/ rather + // than from maven-central. + if (!rnInfo.isRN71OrNewer) { + maven { + url "$projectDir/../../node_modules/react-native/android" + } + } + } +} + +subprojects { + afterEvaluate { p -> + if (p.hasProperty('android')) { + android { + buildToolsVersion rootProject.ext.buildToolsVersion + } + } + } +} diff --git a/detox/android/detox/.gitignore b/detox/android/detox/.gitignore new file mode 100644 index 000000000..39fb081a4 --- /dev/null +++ b/detox/android/detox/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle +/local.properties +/.idea/workspace.xml +/.idea/libraries +.DS_Store +/build +/captures +.externalNativeBuild diff --git a/detox/android/detox/build.gradle b/detox/android/detox/build.gradle new file mode 100644 index 000000000..0f034c600 --- /dev/null +++ b/detox/android/detox/build.gradle @@ -0,0 +1,215 @@ +apply plugin: 'com.android.library' +apply plugin: 'kotlin-android' +apply from: '../rninfo.gradle' + +def _kotlinMinVersion = '1.8.0' +def _materialMinVersion = '1.11.0' + +def _ext = rootProject.ext +def _compileSdkVersion = _ext.has('compileSdkVersion') ? _ext.compileSdkVersion : 31 +def _targetSdkVersion = _ext.has('targetSdkVersion') ? _ext.targetSdkVersion : 31 +def _buildToolsVersion = _ext.has('buildToolsVersion') ? _ext.buildToolsVersion : '31.0.0' +def _minSdkVersion = _ext.has('minSdkVersion') ? _ext.minSdkVersion : 21 +def _kotlinVersion = _ext.has('detoxKotlinVersion') ? _ext.detoxKotlinVersion : _kotlinMinVersion +def _kotlinStdlib = _ext.has('detoxKotlinStdlib') ? _ext.detoxKotlinStdlib : 'kotlin-stdlib-jdk8' + +// RN native code comes from either maven-central (in which case, need the *exact* version), +// or otherwise from node_modules/, where the version is already aligned, by definition. +// noinspection GradleDynamicVersion +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 { + 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 + targetSdkVersion _targetSdkVersion + versionCode 1 + versionName '1.0' + + consumerProguardFiles 'proguard-rules.pro' + } + + flavorDimensions = ['detox'] + productFlavors { + full { + dimension 'detox' + } + coreNative { + dimension 'detox' + } + } + + testOptions { + unitTests.includeAndroidResources = true + unitTests.returnDefaultValues = true + unitTests.all { t -> + reports { + html.required = true + } + testLogging { + events "passed", "skipped", "failed", "standardOut", "standardError" + } + afterSuite { desc, result -> + if (!desc.parent) { // will match the outermost suite + def output = " ${result.resultType} (${result.successfulTestCount} successes, ${result.failedTestCount} failures, ${result.skippedTestCount} skipped) " + def repeatLength = output.length() + println '\n' + ('-' * repeatLength) + '\n' + output + '\n' + ('-' * repeatLength) + '\n' + + println "see report at file://${t.reports.html.outputLocation}/index.html" + } + } + } + } + + packagingOptions { + exclude 'META-INF/DEPENDENCIES' + exclude 'META-INF/NOTICE' + exclude 'META-INF/LICENSE' + exclude 'META-INF/LICENSE.txt' + exclude 'META-INF/NOTICE.txt' + } + + 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: +// "The api configuration should be used to declare dependencies which are exported by the library API, whereas the +// implementation configuration should be used to declare dependencies which are internal to the component." +// --> https://docs.gradle.org/5.5/userguide/java_library_plugin.html + +// Fundamental deps. +dependencies { + api "org.jetbrains.kotlin:$_kotlinStdlib:$_kotlinVersion" + + compileOnly "${_rnNativeArtifact}" +} + +// androidx.test deps. +// All are aligned with this release: https://developer.android.com/jetpack/androidx/releases/test#1.4.0 +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.6.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.6.1') { + because 'Web-View testing' + } + api('androidx.test.espresso:espresso-contrib:3.6.1') { + because 'Android datepicker support' + exclude group: "org.checkerframework", module: "checker" + } + api('org.hamcrest:hamcrest:2.2') { + because 'See https://github.com/wix/Detox/issues/3920. Need to force hamcrest 2.2 win in battle of 2.2 vs. 1.3 (specified by Espresso).' + } + api('androidx.test:rules:1.6.1') { + because 'of ActivityTestRule. Needed by users *and* internally used by Detox.' + } + api('androidx.test.ext:junit:1.2.1') { + because 'Needed so as to seamlessly provide AndroidJUnit4 to Detox users. Depends on junit core.' + } + // Version is the latest; Cannot sync with the Github repo (e.g. android/android-test) because the androidx + // packaging version of associated classes is simply not there... + api('androidx.test.uiautomator:uiautomator:2.2.0') { + because 'Needed by Detox but also makes UIAutomator seamlessly provided to Detox users with hybrid apps/E2E-tests.' + } + api('androidx.test:core-ktx:1.6.1') { + because 'Needed by Detox but also makes AndroidX test core seamlessly provided to Detox users with hybrid apps/E2E-tests.' + } + implementation("org.jetbrains.kotlin:kotlin-reflect:$_kotlinVersion") { + because('Needed by Detox for kotlin reflection') + } +} + +// Third-party/extension deps. +dependencies { + implementation('org.apache.commons:commons-lang3:3.7') { + because 'Needed by invoke. Warning: Upgrading to newer versions is not seamless.' + } + implementation 'com.github.anrwatchdog:anrwatchdog:1.4.0' + + implementation fileTree(dir: '../libs', includes: ['*.jar']) // Includes: Genymotion SDK +} + +// Unit-testing deps. +dependencies { + testImplementation "${_rnNativeArtifact}" + testImplementation 'org.json:json:20230227' + +// https://github.com/spekframework/spek/issues/232#issuecomment-610732158 + testRuntimeOnly 'org.junit.vintage:junit-vintage-engine:5.7.2' + + 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: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)' + } +} + +// Spek (https://spekframework.org/setup-android) +if (rootProject.hasProperty('isOfficialDetoxLib') || + rootProject.hasProperty('isOfficialDetoxApp')) { + + apply plugin: 'de.mannodermaus.android-junit5' + + android { + testOptions { + junitPlatform { + filters { + engines { + include 'spek2', 'junit-vintage' + } + } + } + } + } + + dependencies { + testImplementation 'org.spekframework.spek2:spek-dsl-jvm:2.0.15' + testImplementation 'org.spekframework.spek2:spek-runner-junit5:2.0.15' + } +} + +// Enable publishing +if (rootProject.hasProperty('isOfficialDetoxLib')) { + apply from: './publishing.gradle' +} + diff --git a/detox/android/detox/proguard-rules-app.pro b/detox/android/detox/proguard-rules-app.pro new file mode 100644 index 000000000..b76428a6b --- /dev/null +++ b/detox/android/detox/proguard-rules-app.pro @@ -0,0 +1,49 @@ +-keepattributes InnerClasses, Exceptions + +-keep class com.facebook.react.fabric.FabricUIManager { *; } +-keep class com.facebook.react.fabric.mounting.MountItemDispatcher { *; } +-keep class com.facebook.react.modules.** { *; } +-keep class com.facebook.react.uimanager.** { *; } +-keep class com.facebook.react.animated.** { *; } +-keep class com.facebook.react.ReactApplication { *; } +-keep class com.facebook.react.ReactNativeHost { *; } +-keep class com.facebook.react.ReactHost { *; } +-keep class com.facebook.react.runtime.ReactHostImpl { *; } +-keep class com.facebook.react.runtime.BridgelessReactContext { *; } +-keep class com.facebook.react.runtime.ReactInstance { *; } +-keep class com.facebook.react.modules.core.JavaTimerManager { *; } +-keep class com.facebook.react.defaults.DefaultNewArchitectureEntryPoint { *; } + +-keep class com.facebook.react.ReactInstanceManager { *; } +-keep class com.facebook.react.ReactInstanceManager** { *; } +-keep class com.facebook.react.ReactInstanceEventListener { *; } +-keep class com.facebook.react.soloader.OpenSourceMergedSoMapping { *; } +-keep class com.facebook.soloader.SoLoader { *; } +-keep class com.facebook.soloader.ExternalSoMapping { *; } + +-keep class com.facebook.react.views.slider.** { *; } +-keep class com.google.android.material.slider.** { *; } +-keep class com.reactnativecommunity.slider.** { *; } +-keep class com.reactnativecommunity.asyncstorage.** { *; } + +-keep class kotlin.reflect.** { *; } +-keep class kotlin.KotlinVersion { *; } +-keep class kotlin.sequences.** { *; } +-keep class kotlin.Triple { *; } +-keep class kotlin.properties.** { *; } +-keep class kotlin.coroutines.CoroutineDispatcher { *; } +-keep class kotlin.coroutines.CoroutineScope { *; } +-keep class kotlin.coroutines.CoroutineContext { *; } +-keep class kotlinx.coroutines.BuildersKt { *; } +-keep class kotlin.jvm.** { *; } +-keep class kotlin.collections.** { *; } +-keep class kotlin.text.** { *; } +-keep class kotlin.io.** { *; } +-keep class okhttp3.** { *; } +-keep class kotlin.LazyKt { *; } + +-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 new file mode 100644 index 000000000..8bef01807 --- /dev/null +++ b/detox/android/detox/proguard-rules.pro @@ -0,0 +1,25 @@ +-dontwarn org.xmlpull.** +-dontwarn sun.misc.** + +-dontnote android.** +-dontnote androidx.** +-dontnote java.** +-dontnote javax.** +-dontnote kotlin.** +-dontnote org.apache.** +-dontnote junit.** +-dontnote org.junit.** +-dontnote org.joor.** +-dontnote org.hamcrest.** +-dontnote com.facebook.** + +-keep class org.apache.commons.lang3.** { *; } +-keep class org.apache.commons.io.** { *; } + +# Detox profiler (optional) + +-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/publish-pom.gradle b/detox/android/detox/publish-pom.gradle new file mode 100644 index 000000000..ae3f2a5ab --- /dev/null +++ b/detox/android/detox/publish-pom.gradle @@ -0,0 +1,43 @@ +// Based on https://stackoverflow.com/a/42160584/453052 + +project.ext.buildPomXmlDependencies = { pom, configurations -> + pom.withXml { + final rootNode = asNode().appendNode('dependencies') + addConfigurationDependencies(rootNode, configurations.api, 'compile') + addConfigurationDependencies(rootNode, configurations.implementation, 'runtime') + + // Legacy syntax + if (configurations.hasProperty('compile')) { + addConfigurationDependencies(rootNode, configurations.compile, 'compile') + } + } +} + +private static def addConfigurationDependencies(rootNode, Configuration configuration, String scope) { + configuration.dependencies.each { dep -> addChildDependency(rootNode, dep, scope) } +} + +private static def addChildDependency(rootNode, Dependency dep, String scope) { + if (dep.group == null || dep.version == null || dep.name == null || dep.name == "unspecified") + return + + final childNode = rootNode.appendNode('dependency') + childNode.appendNode('groupId', dep.group) + childNode.appendNode('artifactId', dep.name) + childNode.appendNode('version', dep.version) + childNode.appendNode('scope', scope) + + if (!dep.transitive) { + // If this dependency is transitive, we should force exclude all its dependencies them from the POM + final exclusionNode = childNode.appendNode('exclusions').appendNode('exclusion') + exclusionNode.appendNode('groupId', '*') + exclusionNode.appendNode('artifactId', '*') + } else if (!dep.properties.excludeRules.empty) { + // Otherwise add specified exclude rules + final exclusionNode = childNode.appendNode('exclusions').appendNode('exclusion') + dep.properties.excludeRules.each { ExcludeRule rule -> + exclusionNode.appendNode('groupId', rule.group ?: '*') + exclusionNode.appendNode('artifactId', rule.module ?: '*') + } + } +} diff --git a/detox/android/detox/publishing.gradle b/detox/android/detox/publishing.gradle new file mode 100644 index 000000000..20927d8c4 --- /dev/null +++ b/detox/android/detox/publishing.gradle @@ -0,0 +1,296 @@ +apply plugin: 'maven-publish' +apply plugin: 'kotlin-android' +apply plugin: 'org.jetbrains.dokka' +apply plugin: 'signing' +apply from: './publish-pom.gradle' +apply from: '../rninfo.gradle' + +String TARGET_LOCAL_DIR = "$buildDir/../../../Detox-android" +String TARGET_MAVEN_CENTRAL_URL = 'https://oss.sonatype.org/service/local/staging/deploy/maven2' + +String PUB_FLAVOUR_FULL_DETOX = 'full' +String PUB_FLAVOUR_DETOX_NATIVE = 'coreNative' + +def DEVELOPERS = [ + [name: 'WixMobile', email: 'mobile1@wix.com'], + [name: 'd4vidi', email: 'amitd@wix.com'], +] + +String _versionName = System.getProperty('version') +String _flavour = System.getProperty('buildFlavour', PUB_FLAVOUR_FULL_DETOX) +Boolean _forceLocal = System.getProperty('forceLocal', 'false').toBoolean() +Boolean _forceSign = System.getProperty('forceSign', 'false').toBoolean() + +String _mavenRepoUrl +Map _mavenCredentials +def _shouldSignArtifacts = false + +def _selectedVariant + +def initLocalPublishing = { + _mavenRepoUrl = TARGET_LOCAL_DIR + _mavenCredentials = null + _shouldSignArtifacts = _forceSign +} + +def initMavenPublishing = { + _mavenRepoUrl = TARGET_MAVEN_CENTRAL_URL + _mavenCredentials = [ + // This should come from ~/.gradle.properties + username: sonatypeUsername, + password: sonatypePassword, + ] + _shouldSignArtifacts = true +} + +def initPublishing = { + switch (_flavour) { + case PUB_FLAVOUR_FULL_DETOX: + initLocalPublishing() + break + + case PUB_FLAVOUR_DETOX_NATIVE: + if (_forceLocal) { + initLocalPublishing() + } else { + initMavenPublishing() + } + break + + default: + assertNull(_flavour, "Don\'t know how to publish by flavour '${_flavour}'. Try '${PUB_FLAVOUR_FULL_DETOX}' or '${PUB_FLAVOUR_DETOX_NATIVE}'.") + break + } +} + +def onPrePublish = { + assertDefined(_versionName, "Publishing: Version not specified (run 'gradle publish' with a -Dversion=1.2.3 argument)") + logger.lifecycle("Detox publishing is now in session! šŸ“£\n Version: $_versionName\n Target URL: ${_mavenRepoUrl}\n Build-variant: '${_selectedVariant.name}'") +} + +def shouldPublishVariant = { + return isReleaseVariant(it) && isVariantOfProductFlavour(it, _flavour) +} + +def declareArchive = { target -> + project.artifacts { + archives target + } +} + +initPublishing() + +/* + * Documentation JAR configuration using dokka + * Dokka is the official javadoc equivalent that supports kotlin KDoc (see https://github.com/Kotlin/dokka) + */ + +tasks.named("dokkaJavadoc") { + File dokkaDoc = new File("$buildDir/dokkaDoc"); + outputDirectory.set(dokkaDoc) + dokkaSourceSets { + named("main") { + sourceRoots.from(android.sourceSets.main.java.srcDirs) + reportUndocumented.set(false) + skipEmptyPackages.set(true) + def suppressedPackages = ["android_libs", "com.wix.detox.espresso.common.annot"] + for (String packagePrefix : suppressedPackages) { + packageOptions { + prefix = packagePrefix + suppress = true + } + } + } + } +} + +// Side note / TODO (revisit because dokka's 419 issue has been resolved, since): +// Dokka outputs R and BuildConfig; currently, there's nothing to do about it, as issues such as +// this on - https://github.com/Kotlin/dokka/issues/419 are still open :-/ +// We might want to revisit this in the future -- see if they've decided to export a custom classes +// suppression config var or something. +task dokkaDocJar(type: Jar, dependsOn: dokkaJavadoc) { + from "$buildDir/dokkaDoc" + archiveClassifier.set("javadoc") +} + +/* + * Sources JAR configuration + */ + +task sourcesJar(type: Jar) { + from android.sourceSets.main.java.srcDirs + archiveClassifier.set("sources") +} + +/* + * Signing configuration + * https://docs.gradle.org/current/userguide/signing_plugin.html + */ + +// Tell signing task to sign everything current and future we set as a project archive... +signing { + required { _shouldSignArtifacts } + sign configurations.archives +} + +/* + * Plumbing work for actually having the publishing task work properly, if executed + */ + +project.afterEvaluate { + project.tasks.all { Task task -> + android { + libraryVariants.all { variant -> + String variantName = variant.name.capitalize() + if (task.name == "publishMaven${variantName}AarPublicationToMavenRepository".toString()) { + task.dependsOn "assemble${variantName}" + task.dependsOn project.tasks.signArchives + task.doFirst { + onPrePublish() + } + } + } + } + } +} + +/* + * Publishing configuration + */ + +publishing { + repositories { + maven { + url _mavenRepoUrl + if (_mavenCredentials != null) { + credentials { + username _mavenCredentials.username + password _mavenCredentials.password + } + } + } + } + + publications { + android.libraryVariants.all { variant -> + if (shouldPublishVariant(variant)) { + _selectedVariant = variant + + String variantNameCapitalized = variant.name.capitalize() + + "maven${variantNameCapitalized}Aar"(MavenPublication) { + groupId 'com.wix' + if (rnInfo.isRN72OrHigher) { + artifactId 'detox' + } else { + artifactId 'detox-legacy' + } + version "$_versionName" + + // Register built .aar as published artifact (as a file, explicitly) + variant.outputs.forEach { output -> + artifact output.outputFile + + // Also register as an archive-artifact, for signing (via equivalent task's output) + declareArchive project.tasks["bundle${variantNameCapitalized}Aar"] + } + + // Register sources, javadoc as published artifacts (via equivalent tasks' output) + artifact sourcesJar + //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 // waiting for dokka to fix https://github.com/Kotlin/dokka/issues/3153 + + // Add detox package metadata to the .pom + pom { + name = 'Detox' + description = 'Gray box end-to-end testing and automation library for mobile apps' + url = 'https://github.com/wix/Detox' + packaging 'aar' // Oh so important - or apps would ignore our code!!!!! + scm { + connection = 'scm:git:git://github.com/wix/detox.git' + developerConnection = 'scm:git:git@github.com/wix/detox.git' + url = 'https://github.com/wix/detox' + } + licenses { + license { + name = 'The MIT License' + url = 'https://github.com/wix/Detox/blob/master/LICENSE' + } + } + developers { + DEVELOPERS.each { d -> + developer { + name = d.name + email = d.email + } + } + } + } + + // Add detox dependencies to the .pom + buildPomXmlDependencies(pom, configurations) + + // Register pom.xml's signature file (pom.xml.asc) as published artifact + // Note: this is done manually, instead of registering the pom as an archived artifact + if (_shouldSignArtifacts) { + pom.withXml { + def pomFile = file("${project.buildDir}/generated-pom.xml") + writeTo(pomFile) // Need to force-write so as to have the signature generated over the finalized content + + def pomAscFile = signing.sign(pomFile).signatureFiles[0] + artifact(pomAscFile) { + classifier = null + extension = 'pom.asc' + } + } + } + + // Register all artifacts we've previously registered as signed archives (i.e. .jar.asc's, .aar.asc's) as publish-artifacts. + // Note: this relies on preregistering the equivalent generator-tasks as archive artifacts inside a project.artifacts { ... } clause. + if (_shouldSignArtifacts) { + project.tasks.signArchives.signatureFiles.each { + artifact(it) { + def matcherSrcDocs = (it.file =~ /-(sources|javadoc)\.jar\.asc$/) + def matcherAAR = (it.file =~ /\.aar\.asc$/) + if (matcherSrcDocs.find()) { + classifier = matcherSrcDocs.group(1) + extension = 'jar.asc' + } else if (matcherAAR.find()) { + classifier = null + extension = 'aar.asc' + } else { + classifier = null + extension = null + } + } + } + } + } + } + } + } +} + +private static def isReleaseVariant(variant) { + return variant.buildType.name == 'release' +} + +private static def isVariantOfProductFlavour(variant, flavourName) { + return variant.productFlavors.name.find { name -> name == flavourName } != null +} + +private static def assertDefined(target, message) { + if (target == null) { + throw new IllegalArgumentException(message) + } +} + +private static def assertNull(target, message) { + if (target != null) { + throw new IllegalArgumentException(message) + } +} diff --git a/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt b/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt new file mode 100644 index 000000000..74729150f --- /dev/null +++ b/detox/android/detox/src/coreNative/java/com/wix/detox/actions/DetoxViewActions.kt @@ -0,0 +1,32 @@ +package com.wix.detox.actions + +import androidx.test.espresso.ViewAction +import androidx.test.espresso.action.GeneralClickAction +import androidx.test.espresso.action.GeneralLocation +import androidx.test.espresso.action.Press +import androidx.test.espresso.action.ViewActions.actionWithAssertions +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.action.DetoxCustomTapper +import com.wix.detox.espresso.scroll.DetoxScrollAction + +public object DetoxViewActions { + public fun tap() = multiTap(1) + public fun doubleTap() = multiTap(2) + public fun multiTap(times: Int): ViewAction = + actionWithAssertions(GeneralClickAction(DetoxCustomTapper(times), GeneralLocation.CENTER, Press.FINGER, 0, 0)) + + public fun scrollUpBy(amountInDp: Double, startOffsetPercentX: Float? = null, startOffsetPercentY: Float? = null): ViewAction = + actionWithAssertions(DetoxScrollAction(MOTION_DIR_UP, amountInDp, startOffsetPercentX, startOffsetPercentY)) + + public fun scrollDownBy(amountInDp: Double, startOffsetPercentX: Float? = null, startOffsetPercentY: Float? = null): ViewAction = + actionWithAssertions(DetoxScrollAction(MOTION_DIR_DOWN, amountInDp, startOffsetPercentX, startOffsetPercentY)) + + public fun scrollLeftBy(amountInDp: Double, startOffsetPercentX: Float? = null, startOffsetPercentY: Float? = null): ViewAction = + actionWithAssertions(DetoxScrollAction(MOTION_DIR_LEFT, amountInDp, startOffsetPercentX, startOffsetPercentY)) + + public fun scrollRightBy(amountInDp: Double, startOffsetPercentX: Float? = null, startOffsetPercentY: Float? = null): ViewAction = + actionWithAssertions(DetoxScrollAction(MOTION_DIR_RIGHT, amountInDp, startOffsetPercentX, startOffsetPercentY)) +} 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 000000000..7ccc113a6 --- /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/Delegator.java b/detox/android/detox/src/full/java/com/wix/detox/Delegator.java new file mode 100644 index 000000000..0132ae1f7 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/Delegator.java @@ -0,0 +1,106 @@ +package com.wix.detox; + +import org.joor.Reflect; +import org.joor.ReflectException; + +import java.lang.reflect.InvocationHandler; +import java.lang.reflect.Method; + +/** + * Created by simonracz on 29/05/2017. + + *

+ * Helper class for InvocationHandlers, which delegates equals, hashCode and toString + * calls to Object. + *

+ * + *

+ * Copied from here + * Delegator + *

+ */ +public class Delegator implements InvocationHandler { + + private static Method hashCodeMethod; + private static Method equalsMethod; + private static Method toStringMethod; + static { + try { + hashCodeMethod = Object.class.getMethod("hashCode"); + equalsMethod = + Object.class.getMethod("equals", new Class[] { Object.class }); + toStringMethod = Object.class.getMethod("toString"); + } catch (NoSuchMethodException e) { + throw new NoSuchMethodError(e.getMessage()); + } + } + + private Class[] interfaces; + private Object[] delegates; + + public Delegator(Class[] interfaces, Object[] delegates) { + this.interfaces = (Class[]) interfaces.clone(); + this.delegates = (Object[]) delegates.clone(); + } + + public Object invoke(Object proxy, Method m, Object[] args) + throws Throwable + { + Class declaringClass = m.getDeclaringClass(); + + if (declaringClass == Object.class) { + if (m.equals(hashCodeMethod)) { + return proxyHashCode(proxy); + } else if (m.equals(equalsMethod)) { + return proxyEquals(proxy, args[0]); + } else if (m.equals(toStringMethod)) { + return proxyToString(proxy); + } else { + throw new InternalError( + "unexpected Object method dispatched: " + m); + } + } else { + for (int i = 0; i < interfaces.length; i++) { + if (declaringClass.isAssignableFrom(interfaces[i])) { + try { + return Reflect.on(delegates[i]).call(m.getName(), args).get(); + } catch (ReflectException e) { + throw e.getCause(); + } + } + } + + return invokeNotDelegated(proxy, m, args); + } + } + + // Simple workaround for a deeply rooted issue regarding Proxy classes + public Object invokeAsString(String methodName) throws ReflectException { + return Reflect.on(delegates[0]).call(methodName).get(); + } + + // Simple workaround for a deeply rooted issue regarding Proxy classes + public Object invokeAsString(String methodName, Object[] args) throws ReflectException { + return Reflect.on(delegates[0]).call(methodName, args).get(); + } + + protected Object invokeNotDelegated(Object proxy, Method m, + Object[] args) + throws Throwable + { + throw new InternalError("unexpected method dispatched: " + m); + } + + protected Integer proxyHashCode(Object proxy) { + return System.identityHashCode(proxy); + } + + protected Boolean proxyEquals(Object proxy, Object other) { + return (proxy == other ? Boolean.TRUE : Boolean.FALSE); + } + + protected String proxyToString(Object proxy) { + return proxy.getClass().getName() + '@' + + Integer.toHexString(proxy.hashCode()); + } +} 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 new file mode 100644 index 000000000..0e81f218a --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/Detox.java @@ -0,0 +1,149 @@ +package com.wix.detox; + +import android.content.Context; + +import androidx.annotation.NonNull; +import androidx.test.platform.app.InstrumentationRegistry; +import androidx.test.rule.ActivityTestRule; + +import com.wix.detox.config.DetoxConfig; +import com.wix.detox.espresso.hierarchy.ViewHierarchyGenerator; + +/** + *

Static class.

+ * + *

To start Detox tests, call runTests() from a JUnit test. + * This test must use AndroidJUnitRunner or a subclass of it, as Detox uses Espresso internally. + * All non-standard async code must be wrapped in an Espresso + * IdlingResource.

+ * + * Example usage + *
{@code
+ *@literal @runWith(AndroidJUnit4.class)
+ *@literal @LargeTest
+ * public class DetoxTest {
+ *  @literal @Rule
+ *   //The Activity that controls React Native.
+ *   public ActivityTestRule mActivityRule = new ActivityTestRule(MainActivity.class);
+ *
+ *  @literal @Before
+ *   public void setUpCustomEspressoIdlingResources() {
+ *     // set up your own custom Espresso resources here
+ *   }
+ *
+ *  @literal @Test
+ *   public void runDetoxTests() {
+ *     Detox.runTests();
+ *   }
+ * }}
+ * + *

Two required parameters are detoxServer and detoxSessionId. These + * must be provided either by Gradle. + *
+ * + *

{@code
+ * android {
+ *   defaultConfig {
+ *     testInstrumentationRunner "android.support.test.runner.AndroidJUnitRunner"
+ *     testInstrumentationRunnerArguments = [
+ *       'detoxServer': 'ws://10.0.2.2:8001',
+ *       'detoxSessionId': '1'
+ *     ]
+ *   }
+ * }}
+ * 
+ * + * Or through command line, e.g
+ *
{@code adb shell am instrument -w -e detoxServer ws://localhost:8001 -e detoxSessionId + * 1 com.example/android.support.test.runner.AndroidJUnitRunner}

+ * + *

These are automatically set using, + *

{@code detox test}

+ * + *

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 ActivityLaunchHelper sActivityLaunchHelper; + + private Detox() { + } + + /** + *

+ * Call this method from a JUnit test to invoke detox tests. + *

+ * + *

+ * In case you have a non-standard React Native application, consider using + * {@link #runTests(ActivityTestRule, Context)}}. + *

+ * + * @param activityTestRule the activityTestRule + */ + public static void runTests(ActivityTestRule activityTestRule) { + runTests(activityTestRule, getAppContext()); + } + + /** + * Same as the default {@link #runTests(ActivityTestRule)} method, but allows for the explicit specification of + * various configurations. Note: review {@link DetoxConfig} for defaults. + * + * @param detoxConfig The configurations to apply. + */ + public static void runTests(ActivityTestRule activityTestRule, DetoxConfig detoxConfig) { + runTests(activityTestRule, getAppContext(), detoxConfig); + } + + /** + *

+ * Use this method only if you have a React Native application and it + * doesn't implement ReactApplication; Otherwise use {@link Detox#runTests(ActivityTestRule)}. + *

+ * + *

+ * The only requirement is that the passed in object must have + * a method with the signature + *

{@code ReactNativeHost getReactNativeHost();}
+ *

+ * + * @param activityTestRule the activityTestRule + * @param context an object that has a {@code getReactNativeHost()} method + */ + public static void runTests(ActivityTestRule activityTestRule, @NonNull final Context context) { + runTests(activityTestRule, context, new DetoxConfig()); + } + + /** + * Same as {@link #runTests(ActivityTestRule, Context)}, but allows for the explicit specification of + * various configurations. Note: review {@link DetoxConfig} for defaults. + * + * @param detoxConfig The configurations to apply. + */ + public static void runTests(ActivityTestRule activityTestRule, @NonNull final Context context, DetoxConfig detoxConfig) { + DetoxConfig.CONFIG = detoxConfig; + DetoxConfig.CONFIG.apply(); + + sActivityLaunchHelper = new ActivityLaunchHelper(activityTestRule); + DetoxMain.run(context, sActivityLaunchHelper); + } + + public static void launchMainActivity() { + sActivityLaunchHelper.launchMainActivity(); + } + + public static void startActivityFromUrl(String url) { + sActivityLaunchHelper.startActivityFromUrl(url); + } + + public static void startActivityFromNotification(String dataFilePath) { + sActivityLaunchHelper.startActivityFromNotification(dataFilePath); + } + + private static Context getAppContext() { + return InstrumentationRegistry.getInstrumentation().getTargetContext().getApplicationContext(); + } + + public static String generateViewHierarchyXml(boolean shouldInjectTestIds) { + return ViewHierarchyGenerator.generateXml(shouldInjectTestIds); + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxANRHandler.kt b/detox/android/detox/src/full/java/com/wix/detox/DetoxANRHandler.kt new file mode 100644 index 000000000..6108e8bce --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxANRHandler.kt @@ -0,0 +1,25 @@ +package com.wix.detox + +import android.util.Log +import com.github.anrwatchdog.ANRWatchDog +import com.wix.detox.adapters.server.OutboundServerAdapter + +class DetoxANRHandler(private val outboundServerAdapter: OutboundServerAdapter) { + fun attach() { + ANRWatchDog().setReportMainThreadOnly().setANRListener { + val info = mapOf("threadDump" to Log.getStackTraceString(it)) + outboundServerAdapter.sendMessage(ACTION_NAME, info, MESSAGE_ID) + }.start() + + ANRWatchDog().setANRListener { + Log.e(LOG_TAG, "App nonresnponsive detection!", it) + }.start() + } + + companion object { + private val LOG_TAG: String = DetoxANRHandler::class.java.simpleName + + private const val ACTION_NAME = "AppNonresponsiveDetected" + private const val MESSAGE_ID = -10001L + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxCrashHandler.kt b/detox/android/detox/src/full/java/com/wix/detox/DetoxCrashHandler.kt new file mode 100644 index 000000000..62efbea30 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxCrashHandler.kt @@ -0,0 +1,22 @@ +package com.wix.detox + +import android.util.Log +import com.wix.detox.adapters.server.OutboundServerAdapter + +class DetoxCrashHandler(private val outboundServerAdapter: OutboundServerAdapter) { + fun attach() { + Thread.setDefaultUncaughtExceptionHandler { thread, exception -> + Log.e(LOG_TAG, "Crash detected!!! thread=${thread.name} (${thread.id})", exception) + + val crashInfo = mapOf("errorDetails" to "@Thread ${thread.name}(${thread.id}):\n${Log.getStackTraceString(exception)}\nCheck device logs for full details!") + outboundServerAdapter.sendMessage(ACTION_NAME, crashInfo, MESSAGE_ID) + } + } + + companion object { + private val LOG_TAG: String = DetoxCrashHandler::class.java.simpleName + + private const val ACTION_NAME = "AppWillTerminateWithError" + private const val MESSAGE_ID = -10000L + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/DetoxJUnitRunner.java b/detox/android/detox/src/full/java/com/wix/detox/DetoxJUnitRunner.java new file mode 100644 index 000000000..4f0977421 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxJUnitRunner.java @@ -0,0 +1,73 @@ +package com.wix.detox; + +import android.app.Application; +import android.os.Bundle; +import android.util.Log; + +import androidx.test.runner.AndroidJUnitRunner; +import androidx.test.runner.lifecycle.ApplicationLifecycleCallback; +import androidx.test.runner.lifecycle.ApplicationLifecycleMonitor; +import androidx.test.runner.lifecycle.ApplicationLifecycleMonitorRegistry; +import androidx.test.runner.lifecycle.ApplicationStage; + +import com.wix.detox.instruments.DetoxInstrumentsManager; + + +public class DetoxJUnitRunner extends AndroidJUnitRunner { + private static final String TAG = "DetoxJUnitRunner"; + private DetoxInstrumentsManager instrumentsManager; + private ApplicationLifecycleCallback lifecycleCallback; + + @Override + public void onCreate(final Bundle arguments) { + super.onCreate(arguments); + + final ApplicationLifecycleMonitor monitor = ApplicationLifecycleMonitorRegistry.getInstance(); + lifecycleCallback = new ApplicationLifecycleCallback() { + @Override + public void onApplicationLifecycleChanged(Application app, ApplicationStage stage) { + if (stage == ApplicationStage.PRE_ON_CREATE) { + onBeforeAppOnCreate(app, arguments); + } else if (stage == ApplicationStage.CREATED) { + onAfterAppOnCreate(); + } + } + }; + monitor.addLifecycleCallback(lifecycleCallback); + } + + @Override + public void onDestroy() { + instrumentsManager.stopRecording(); + instrumentsManager = null; + lifecycleCallback = null; + + super.onDestroy(); + } + + private void onBeforeAppOnCreate(Application app, Bundle arguments) { + final String recordingPath = arguments.getString("detoxInstrumRecPath"); + if (recordingPath != null) { + if (DetoxInstrumentsManager.supports()) { + long samplingInterval = 250; + try { + final String interval = arguments.getString("detoxInstrumSamplingInterval"); + if (interval != null) { + samplingInterval = Long.parseLong(interval); + } + } catch (NumberFormatException ignore) { + Log.w(TAG, "Invalid value for param \"detoxInstrumSamplingInterval\", default was used"); + } + + instrumentsManager = new DetoxInstrumentsManager(app); + instrumentsManager.startRecordingAtLocalPath(recordingPath, samplingInterval); + } + } + } + + private void onAfterAppOnCreate() { + if (instrumentsManager != null) { + instrumentsManager.tryInstallJsi(); + } + } +} 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 new file mode 100644 index 000000000..811fab107 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/DetoxMain.kt @@ -0,0 +1,130 @@ +package com.wix.detox + +import android.content.Context +import android.util.Log +import com.wix.detox.adapters.server.* +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 TERMINATION_ACTION = "_terminate" + +object DetoxMain { + private val handshakeLock = CountDownLatch(1) + + @JvmStatic + fun run(rnHostHolder: Context, activityLaunchHelper: ActivityLaunchHelper) { + val detoxServerInfo = DetoxServerInfo() + val testEngineFacade = TestEngineFacade() + val actionsDispatcher = DetoxActionsDispatcher() + 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() + } + + /** + * 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. + */ + private fun launchActivityOnCue(rnHostHolder: Context, activityLaunchHelper: ActivityLaunchHelper) { + synchronized(this) { + awaitHandshake() + launchActivity(rnHostHolder, activityLaunchHelper) + } + } + + private fun awaitHandshake() { + handshakeLock.await() + } + + private fun onLoginSuccess() { + handshakeLock.countDown() + } + + private fun doTeardown(serverAdapter: DetoxServerAdapter, actionsDispatcher: DetoxActionsDispatcher, testEngineFacade: TestEngineFacade) { + testEngineFacade.resetReactNative() + + serverAdapter.teardown() + actionsDispatcher.teardown() + } + + 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 readyHandler = SynchronizedActionHandler( ReadyActionHandler(serverAdapter, testEngineFacade) ) + val rnReloadHandler = SynchronizedActionHandler( ReactNativeReloadActionHandler(rnHostHolder, serverAdapter, testEngineFacade) ) + + 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) { -> doTeardown(serverAdapter, actionsDispatcher, testEngineFacade) } + + if (DetoxInstrumentsManager.supports()) { + val instrumentsManager = DetoxInstrumentsManager(rnHostHolder) + associateActionHandler("setRecordingState", InstrumentsRecordingStateActionHandler(instrumentsManager, serverAdapter)) + associateActionHandler("event", InstrumentsEventsActionsHandler(instrumentsManager, serverAdapter)) + } + } + + // Secondary actions + with(actionsDispatcher) { + val queryStatusHandler = SynchronizedActionHandler( QueryStatusActionHandler(serverAdapter, testEngineFacade) ) + associateSecondaryActionHandler("currentStatus", queryStatusHandler) + } + } + + private fun initCrashHandler(outboundServerAdapter: OutboundServerAdapter) { + DetoxCrashHandler(outboundServerAdapter).attach() + } + + private fun initANRListener(outboundServerAdapter: OutboundServerAdapter) { + DetoxANRHandler(outboundServerAdapter).attach() + } + + 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/LaunchArgs.java b/detox/android/detox/src/full/java/com/wix/detox/LaunchArgs.java new file mode 100644 index 000000000..0e1352615 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/LaunchArgs.java @@ -0,0 +1,82 @@ +package com.wix.detox; + +import android.os.Bundle; +import android.util.Base64; + +import java.util.Arrays; +import java.util.List; + +import androidx.test.platform.app.InstrumentationRegistry; + +public class LaunchArgs { + private static final String DETOX_SERVER_URL_ARG = "detoxServer"; + private static final String DETOX_SESSION_ID_ARG_KEY = "detoxSessionId"; + private static final String DETOX_NOTIFICATION_PATH_ARG = "detoxUserNotificationDataURL"; + private static final String DETOX_BLACKLIST_URLS_ARG = "detoxURLBlacklistRegex"; + private static final String DETOX_URL_OVERRIDE_ARG = "detoxURLOverride"; + private static final String DETOX_ENABLE_SYNCHRONIZATION = "detoxEnableSynchronization"; + private static final List RESERVED_INSTRUMENTATION_ARGS = Arrays.asList("class", "package", "func", "unit", "size", "perf", "debug", "log", "emma", "coverageFile"); + + public boolean hasNotificationPath() { + return InstrumentationRegistry.getArguments().containsKey(DETOX_NOTIFICATION_PATH_ARG); + } + + public String getNotificationPath() { + return InstrumentationRegistry.getArguments().getString(DETOX_NOTIFICATION_PATH_ARG); + } + + public boolean hasUrlOverride() { + return InstrumentationRegistry.getArguments().containsKey(DETOX_URL_OVERRIDE_ARG); + } + + public String getURLBlacklist() { + return InstrumentationRegistry.getArguments().getString(DETOX_BLACKLIST_URLS_ARG); + } + + public boolean hasURLBlacklist() { + return InstrumentationRegistry.getArguments().containsKey(DETOX_BLACKLIST_URLS_ARG); + } + + public String getEnableSynchronization() { + return InstrumentationRegistry.getArguments().getString(DETOX_ENABLE_SYNCHRONIZATION); + } + + public boolean hasEnableSynchronization() { + return InstrumentationRegistry.getArguments().containsKey(DETOX_ENABLE_SYNCHRONIZATION); + } + + public String getUrlOverride() { + return InstrumentationRegistry.getArguments().getString(DETOX_URL_OVERRIDE_ARG); + } + + public String getDetoxServerUrl() { + return InstrumentationRegistry.getArguments().getString(DETOX_SERVER_URL_ARG); + } + + public String getDetoxSessionId() { + return InstrumentationRegistry.getArguments().getString(DETOX_SESSION_ID_ARG_KEY); + } + + public Bundle asIntentBundle() { + final Bundle instrumentationArgs = InstrumentationRegistry.getArguments(); + final Bundle launchArgs = new Bundle(); + + for (String arg : instrumentationArgs.keySet()) { + if (!RESERVED_INSTRUMENTATION_ARGS.contains(arg)) { + launchArgs.putString(arg, decodeLaunchArgValue(arg, instrumentationArgs)); + } + } + return launchArgs; + } + + private String decodeLaunchArgValue(String arg, Bundle instrumArgs) { + final String rawValue = instrumArgs.getString(arg); + + if (arg.startsWith("detox")) { + return rawValue; + } + + byte[] base64Value = Base64.decode(rawValue, Base64.DEFAULT); + return new String(base64Value); + } +} 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 new file mode 100644 index 000000000..80f5801c2 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/LaunchIntentsFactory.kt @@ -0,0 +1,104 @@ +package com.wix.detox + +import android.app.Activity +import android.content.Context +import android.content.Intent +import android.net.Uri +import android.os.Bundle + +class LaunchIntentsFactory { + + /** + * Constructs an intent tightly associated with a specific activity. + * + * @param activity The activity to launch (typically extracted from an [androidx.test.rule.ActivityTestRule]). + * + * @return The resulting intent. + */ + fun activityLaunchIntent(activity: Activity) + = Intent(activity.applicationContext, + activity.javaClass).apply { + flags = coreFlags + } + + /** + * Constructs a near-empty, activity-anonymous intent, assuming an ActivityTestRule instance that would handle it + * and fill in all the missing details. Namely, the activity class (aka component), which is taken from activityTestRule's + * own activityClass data member which was set in the c'tor by the user (outside of Detox). + * + * @return The resulting intent. + */ + fun cleanIntent() + = Intent(Intent.ACTION_MAIN) + + /** + * Constructs an activity-anonymous intent with a URL such that the resolved activity to be launched would be an activity that has + * been defined to match it using an intent-filter xml tag associated with an [Intent.ACTION_VIEW] action. + * + * @param url The activity-lookup URL. + * @param initialLaunch Whether this is done in the context of the preliminary app launch, or midway through a running test. + * + * @return The resulting intent. + */ + fun intentWithUrl(url: String?, initialLaunch: Boolean) + = Intent(Intent.ACTION_VIEW).apply { + data = Uri.parse(url) + flags = coreFlags + if (initialLaunch) { + addFlags(initialLaunchFlags) + } + } + + /** + * Constructs an activity-anonymous intent with extras equivalent to a given data bundle, assumed + * to be holding notification data. + * + * In essence, this mimics the way the FCM's default implementation handles simple *message*-ish notifications (as + * oppose to *data*-ish notifications) on the device, where the sender simply provides only a title, some content + * (body), and a flat key-value dictionary. What the FCM service does in the use case is to post a notification + * with a simple launcher-like intent, holding the user data in the extra's root, as originally provided. + * >Note: this is typically what happens only when the app is in the background/terminated; otherwise the notification + * is delivered to the apps' registered handlers. + * + * Obviously, to properly expose more of what Android has to offer in this context, a more customizable version of + * this (e.g. with more explicit intent-related configurations) should eventually be introduced as well. + * + * @param data The notification data, as a bundle. + * @param initialLaunch Whether this is done in the context of the preliminary app launch, or midway through a running test. + * + * @return The resulting intent. + */ + fun intentWithNotificationData(appContext: Context, data: Bundle, initialLaunch: Boolean) + = Intent(Intent.ACTION_MAIN).apply { + addCategory(Intent.CATEGORY_LAUNCHER) + setPackage(appContext.packageName) + putExtras(data) + flags = coreFlags + if (initialLaunch) { + addFlags(initialLaunchFlags) + } + } + + /** + * The core flags we typically set in all intents: + * + * CLEAR_TOP is important so as to avoid launching the app's main activity over an existing instance (in the same task), + * in case it's already running. It *would* happen without the flag, since by default ActivityTestRule instances + * are created so as to force the FLAG_ACTIVITY_NEW_TASK flag in the initial launch (see flags-less c'tor), which + * evidently causes consequent launches to create additional activity instances on top of it (although inside the same task). + * SINGLE_TOP here is needed as well so as to avoid the *relaunch* of the already-running activity (rather, the intent + * would be delivered to that activity's onNewIntent(), as explain in the docs for CLEAR_TOP). + */ + private val coreFlags: Int + get() = Intent.FLAG_ACTIVITY_SINGLE_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP + + /** + * Additional flags to user to initial-launches (i.e. when launch using a test-rule): + * + * Upon initial launch (first-ever instance of the test activity), we also manually need to add the NEW_TASK flag + * so as to mimic the ActivityTestRule's behavior: we get NEW_TASK from it if no flags are specified; here we _do_ + * specify flags so need to add it ourselves. + */ + private val initialLaunchFlags: Int + get() = Intent.FLAG_ACTIVITY_NEW_TASK +} 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 new file mode 100644 index 000000000..f2e2d8a97 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/NotificationDataParser.kt @@ -0,0 +1,18 @@ +package com.wix.detox + +import android.os.Bundle +import com.wix.detox.common.JsonConverter +import com.wix.detox.common.TextFileReader +import org.json.JSONObject + +class NotificationDataParser(private val notificationPath: String) { + fun toBundle(): Bundle { + val rawData = readNotificationFromFile() + val json = JSONObject(rawData) + val payload = json.getJSONObject("payload") + return JsonConverter(payload).toBundle() + } + + private fun readNotificationFromFile() + = TextFileReader(notificationPath).read() +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt b/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt new file mode 100644 index 000000000..b1635c2a0 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/TestEngineFacade.kt @@ -0,0 +1,22 @@ +package com.wix.detox + +import android.content.Context +import android.util.Log +import androidx.test.espresso.Espresso +import com.wix.detox.common.DetoxLog.Companion.LOG_TAG +import com.wix.detox.espresso.UiAutomatorHelper +import com.wix.detox.espresso.registry.BusyResourcesInquirer +import com.wix.detox.reactnative.ReactNativeExtension + +class TestEngineFacade { + fun awaitIdle(): Unit? = Espresso.onIdle { + Log.i(LOG_TAG, "Wait is over: App is now idle!") + null + } + fun syncIdle() = UiAutomatorHelper.espressoSync() // TODO Check whether this can be replaced with #awaitIdle() + fun getAllBusyResources() = BusyResourcesInquirer.INSTANCE.getAllBusyResources() + + // TODO Refactor RN related stuff away + fun reloadReactNative(appContext: Context) = ReactNativeExtension.reloadReactNative(appContext) + fun resetReactNative() = ReactNativeExtension.clearAllSynchronization() +} 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 new file mode 100644 index 000000000..1404f63ab --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionHandlers.kt @@ -0,0 +1,154 @@ +package com.wix.detox.adapters.server + +import android.content.Context +import android.util.Log +import com.wix.detox.TestEngineFacade +import com.wix.detox.common.extractRootCause +import com.wix.detox.instruments.DetoxInstrumentsException +import com.wix.detox.instruments.DetoxInstrumentsManager +import com.wix.invoke.MethodInvocation +import org.json.JSONObject +import java.lang.reflect.InvocationTargetException + +private const val LOG_TAG = "DetoxActionHandlers" + +interface DetoxActionHandler { + fun handle(params: String, messageId: Long) +} + +class ReadyActionHandler( + private val outboundServerAdapter: OutboundServerAdapter, + private val testEngineFacade: TestEngineFacade) + : DetoxActionHandler { + + override fun handle(params: String, messageId: Long) { + testEngineFacade.awaitIdle() + outboundServerAdapter.sendMessage("ready", emptyMap(), messageId) + } +} + +open class ReactNativeReloadActionHandler( + private val appContext: Context, + private val outboundServerAdapter: OutboundServerAdapter, + private val testEngineFacade: TestEngineFacade) + : DetoxActionHandler { + + override fun handle(params: String, messageId: Long) { + testEngineFacade.syncIdle() + testEngineFacade.reloadReactNative(appContext) + outboundServerAdapter.sendMessage("ready", emptyMap(), messageId) + } +} + + +class InvokeActionHandler @JvmOverloads constructor( + private val methodInvocation: MethodInvocation, + private val outboundServerAdapter: OutboundServerAdapter, + private val errorParse: (e: Throwable?) -> String = Log::getStackTraceString) + : DetoxActionHandler { + + private val VIEW_HIERARCHY_TEXT = "View Hierarchy:" + + override fun handle(params: String, messageId: Long) { + try { + val invocationResult = methodInvocation.invoke(params) + outboundServerAdapter.sendMessage("invokeResult", mapOf("result" to invocationResult), messageId) + } catch (e: InvocationTargetException) { + Log.i(LOG_TAG, "Test exception", e) + val payload = extractFailurePayload(e) + outboundServerAdapter.sendMessage("testFailed", payload, messageId) + } catch (e: Exception) { + Log.e(LOG_TAG, "Exception", e) + outboundServerAdapter.sendMessage("error", mapOf("error" to "${errorParse(e)}\nCheck device logs for full details!\n"), messageId) + } + } + + private fun extractFailurePayload(e: InvocationTargetException): Map + = e.targetException.message?.let { message: String -> + if (message.contains(VIEW_HIERARCHY_TEXT)) { + val error = message.substringBefore(VIEW_HIERARCHY_TEXT).trim() + val viewHierarchy = message.substringAfter(VIEW_HIERARCHY_TEXT).trim() + mapOf("details" to "${error}\n", "viewHierarchy" to viewHierarchy) + } else { + val error = extractRootCause(e.targetException) + mapOf("details" to error.message) + } + } ?: emptyMap() +} + +class CleanupActionHandler( + private val outboundServerAdapter: OutboundServerAdapter, + private val testEngineFacade: TestEngineFacade, + private val doStopDetox: () -> Unit) + : DetoxActionHandler { + override fun handle(params: String, messageId: Long) { + val stopRunner = JSONObject(params).optBoolean("stopRunner", false) + if (stopRunner) { + doStopDetox() + } else { + testEngineFacade.resetReactNative() + } + outboundServerAdapter.sendMessage("cleanupDone", emptyMap(), messageId) + } +} + +class InstrumentsRecordingStateActionHandler( + private val instrumentsManager: DetoxInstrumentsManager, + private val outboundServerAdapter: OutboundServerAdapter +) : DetoxActionHandler { + companion object { + const val DEFAULT_SAMPLING_INTERVAL = 250L + } + + override fun handle(params: String, messageId: Long) { + val json = JSONObject(params) + val recordingPath = json.opt("recordingPath") + if (recordingPath is String) { + val samplingInterval = json.optLong("samplingInterval", DEFAULT_SAMPLING_INTERVAL) + instrumentsManager.startRecordingAtLocalPath(recordingPath, samplingInterval) + } else { + instrumentsManager.stopRecording() + } + + outboundServerAdapter.sendMessage("setRecordingStateDone", emptyMap(), messageId) + } +} + +class InstrumentsEventsActionsHandler( + private val instrumentsManager: DetoxInstrumentsManager, + private val outboundServerAdapter: OutboundServerAdapter +) : DetoxActionHandler { + + override fun handle(params: String, messageId: Long) { + with (JSONObject(params)) { + when (getString("action")) { + "begin" -> { + instrumentsManager.eventBeginInterval( + getString("category"), + getString("name"), + getString("id"), + getString("additionalInfo") + ) + } + "end" -> { + instrumentsManager.eventEndInterval( + getString("id"), + getString("status"), + getString("additionalInfo") + ) + } + "mark" -> { + instrumentsManager.eventMark( + getString("category"), + getString("name"), + getString("id"), + getString("status"), + getString("additionalInfo") + ) + } + else -> throw DetoxInstrumentsException("Invalid action") + } + } + outboundServerAdapter.sendMessage("eventDone", emptyMap(), messageId) + } +} 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 new file mode 100644 index 000000000..c3d83d50f --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxActionsDispatcher.kt @@ -0,0 +1,90 @@ +package com.wix.detox.adapters.server + +import android.os.Handler +import android.os.Looper +import android.util.Log +import java.util.concurrent.CountDownLatch + +private const val LOG_TAG = "DetoxDispatcher" + +class DetoxActionsDispatcher { + private val primaryExec = ActionsExecutor("detox.primary") + private val secondaryExec = ActionsExecutor("detox.secondary") + + 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)) + .let { handled -> + if (!handled) Log.w(LOG_TAG, "No handler found for action '$type'") + } + } + + fun teardown() { + primaryExec.teardown() + secondaryExec.teardown() + } + + fun join() { + 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) { + private val actionHandlers = mutableMapOf() + private val thread: Thread + private lateinit var handler: Handler + + init { + val latch = CountDownLatch(1) + + thread = Thread(Runnable { + Looper.prepare() + handler = Handler(Looper.myLooper()!!) + latch.countDown() + Looper.loop() + }, name) + thread.start() + latch.await() + } + + fun associateHandler(type: String, actionHandler: DetoxActionHandler) { + actionHandlers[type] = actionHandler + } + + fun executeAction(type: String, params: String, messageId: Long): Boolean { + actionHandlers[type]?.let { + handler.post { + Log.i(LOG_TAG, "Handling action '$type' (ID #$messageId)...") + it.handle(params, messageId) + Log.i(LOG_TAG, "Done with action '$type'") + } + return true + } + return false + } + + fun teardown() { + actionHandlers.clear() + handler.looper.quit() + } + + 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 new file mode 100644 index 000000000..9873a72bc --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerAdapter.kt @@ -0,0 +1,42 @@ +package com.wix.detox.adapters.server + +import android.util.Log +import com.wix.detox.common.DetoxLog + +interface OutboundServerAdapter { + fun sendMessage(type: String, payload: Map, id: Long) +} + +class DetoxServerAdapter( + private val actionsDispatcher: DetoxActionsDispatcher, + private val detoxServerInfo: DetoxServerInfo, + private val terminationActionType: String) + : WebSocketClient.WSEventsHandler, OutboundServerAdapter { + + private val wsClient = WebSocketClient(this) + + fun connect() { + Log.i(DetoxLog.LOG_TAG, "Connecting to server...") + wsClient.connectToServer(detoxServerInfo.serverUrl, detoxServerInfo.sessionId) + } + + fun teardown() { + wsClient.close() + } + + override fun onConnect() { + Log.i(DetoxLog.LOG_TAG, "Connected to server!") + } + + override fun onClosed() { + Log.i(DetoxLog.LOG_TAG, "Disconnected from server") + actionsDispatcher.dispatchAction(terminationActionType, "", 0) + } + + override fun onAction(type: String, params: String, messageId: Long) { + actionsDispatcher.dispatchAction(type, params, messageId) + } + + override fun sendMessage(type: String, payload: Map, id: Long) + = wsClient.sendAction(type, payload, id) +} 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 new file mode 100644 index 000000000..947c69ea2 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/DetoxServerInfo.kt @@ -0,0 +1,17 @@ +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" + +class DetoxServerInfo internal constructor(launchArgs: LaunchArgs = LaunchArgs()) { + val serverUrl: String = launchArgs.detoxServerUrl ?: DEFAULT_URL + val sessionId: String = launchArgs.detoxSessionId ?: InstrumentationRegistry.getInstrumentation().targetContext.applicationInfo.packageName + + 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/adapters/server/QueryStatusActionHandler.kt b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/QueryStatusActionHandler.kt new file mode 100644 index 000000000..971bd9c26 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/QueryStatusActionHandler.kt @@ -0,0 +1,28 @@ +package com.wix.detox.adapters.server + +import com.wix.detox.TestEngineFacade +import com.wix.detox.inquiry.DetoxBusyResource + +class QueryStatusActionHandler( + private val outboundServerAdapter: OutboundServerAdapter, + private val testEngineFacade: TestEngineFacade +) : DetoxActionHandler { + + override fun handle(params: String, messageId: Long) { + val busyResources = testEngineFacade.getAllBusyResources() + val data = mapOf( + "status" to formatStatus(busyResources) + ) + outboundServerAdapter.sendMessage("currentStatusResult", data, messageId) + } + + private fun formatStatus(busyResources: List): Map = + if (busyResources.isEmpty()) { + mapOf("app_status" to "idle") + } else { + mapOf( + "app_status" to "busy", + "busy_resources" to busyResources.map { it.getDescription().json() } + ) + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/adapters/server/WebSocketClient.java b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/WebSocketClient.java new file mode 100644 index 000000000..ef531bc99 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/adapters/server/WebSocketClient.java @@ -0,0 +1,153 @@ +package com.wix.detox.adapters.server; + +import android.util.Log; + +import com.wix.detox.common.DetoxErrors; + +import org.json.JSONException; +import org.json.JSONObject; + +import java.util.HashMap; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.WebSocket; +import okhttp3.WebSocketListener; +import okio.ByteString; + +public class WebSocketClient { + + private static final String LOG_TAG = "DetoxWSClient"; + + private volatile boolean closing = false; + + public void close() { + if (closing) return; + closing = true; + websocket.close(NORMAL_CLOSURE_STATUS, null); + } + + private String url; + private String sessionId; + private WebSocket websocket = null; + + private final WSEventsHandler wsEventsHandler; + private final WebSocketEventsListener wsEventListener = new WebSocketEventsListener(); + + private static final int NORMAL_CLOSURE_STATUS = 1000; + + public WebSocketClient(WSEventsHandler wsEventsHandler) { + this.wsEventsHandler = wsEventsHandler; + } + + public void connectToServer(String url, String sessionId) { + Log.i(LOG_TAG, "At connectToServer"); + + this.url = url; + this.sessionId = sessionId; + + final OkHttpClient client = new OkHttpClient.Builder() + .retryOnConnectionFailure(true) + .connectTimeout(1500, TimeUnit.MILLISECONDS) + .readTimeout(0, TimeUnit.MILLISECONDS) + .build(); + + final Request request = new Request.Builder().url(url).build(); + this.websocket = client.newWebSocket(request, wsEventListener); + + client.dispatcher().executorService().shutdown(); + } + + public void sendAction(String type, Map params, Long messageId) { + Log.i(LOG_TAG, "Sending out action '" + type + "' (ID #" + messageId + ")"); + + final Map data = new HashMap<>(); + data.put("type", type); + data.put("params", params); + data.put("messageId", messageId); + + final JSONObject json = new JSONObject(data); + websocket.send(json.toString()); + } + + private void receiveAction(String json) { + try { + final JSONObject object = new JSONObject(json); + final String type = (String) object.get("type"); + final Object params = object.get("params"); + final long messageId = object.getLong("messageId"); + + Log.d(LOG_TAG, "Received action '" + type + "' (ID #" + messageId + ", params=" + params + ")"); + + if (wsEventsHandler != null) { + wsEventsHandler.onAction(type, params.toString(), messageId); + } + } catch (JSONException e) { + throw new DetoxErrors.DetoxIllegalArgumentException(e); + } + } + + /** + * These methods are called on an inner worker thread. + * @see OkHTTP + */ + public interface WSEventsHandler { + void onAction(String type, String params, long messageId); + void onConnect(); + void onClosed(); + } + + private class WebSocketEventsListener extends WebSocketListener { + @Override + public void onOpen(WebSocket webSocket, Response response) { + Log.d(LOG_TAG, "At onOpen"); + + Map params = new HashMap<>(); + params.put("sessionId", sessionId); + params.put("role", "app"); + sendAction("login", params, 0L); + wsEventsHandler.onConnect(); + } + + @Override + public void onFailure(WebSocket webSocket, Throwable t, Response response) { +// Log.e(LOG_TAG, "Detox Error: ", t); + + //OKHttp won't recover from failure if it got ConnectException, + // this is a workaround to make the websocket client try reconnecting when failed. + try { + Thread.sleep(3000); + } catch (InterruptedException e2) { + Log.d(LOG_TAG, "interrupted", e2); + } + + Log.d(LOG_TAG, "Retrying..."); + connectToServer(url, sessionId); + } + + @Override + public void onMessage(WebSocket webSocket, String text) { + receiveAction(text); + } + + @Override + public void onMessage(WebSocket webSocket, ByteString bytes) { + Log.e(LOG_TAG, "Unexpected binary ws message from detox server."); + } + + @Override + public void onClosed(WebSocket webSocket, int code, String reason) { + closing = true; + wsEventsHandler.onClosed(); + } + + @Override + public void onClosing(WebSocket webSocket, int code, String reason) { + closing = true; + websocket.close(NORMAL_CLOSURE_STATUS, null); + } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/common/RNAnnotations.kt b/detox/android/detox/src/full/java/com/wix/detox/common/RNAnnotations.kt new file mode 100644 index 000000000..c4dfedea5 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/common/RNAnnotations.kt @@ -0,0 +1,11 @@ +package com.wix.detox.common + +import kotlin.annotation.AnnotationTarget.* + +/** + * Source-annotation, indicating that some changes need to be made once the associated RN version + * (or higher) becomes the one minimally supported by Detox Android. + */ +@Target(FUNCTION, CLASS, CONSTRUCTOR, PROPERTY_GETTER, PROPERTY_SETTER, PROPERTY, FIELD, FILE) +@Retention(AnnotationRetention.SOURCE) +annotation class RNDropSupportTodo(val rnMajorVersion: Int, val message: String) diff --git a/detox/android/detox/src/full/java/com/wix/detox/common/UIExtensions.kt b/detox/android/detox/src/full/java/com/wix/detox/common/UIExtensions.kt new file mode 100644 index 000000000..b867a3c8a --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/common/UIExtensions.kt @@ -0,0 +1,28 @@ +package com.wix.detox.common + +import android.view.View +import android.view.ViewGroup + +fun View.forEachChild(callback: (child: View) -> Unit) { + if (this is ViewGroup) { + for (index in 0 until childCount) { + val child = getChildAt(index) + callback(child) + } + } +} + +/** + * In-order traverse the view-hierarchy specified by a view, considered to be the hierarchy's root. + * + * @param view The hierarchy's root-view. + * @param callback A function to call per each view. Returning `false` from the callback indicates + * a request to refrain from traversing the sub-hierarchy associated with the current view. + */ +fun traverseViewHierarchy(view: View, callback: (view: View) -> Boolean) { + if (callback(view)) { + view.forEachChild { child -> + traverseViewHierarchy(child, callback) + } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/config/DetoxConfig.kt b/detox/android/detox/src/full/java/com/wix/detox/config/DetoxConfig.kt new file mode 100644 index 000000000..b33b20866 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/config/DetoxConfig.kt @@ -0,0 +1,14 @@ +package com.wix.detox.config + +class DetoxConfig { + @JvmField var idlePolicyConfig: DetoxIdlePolicyConfig = DetoxIdlePolicyConfig() + @JvmField var rnContextLoadTimeoutSec = 60 + + fun apply() { + idlePolicyConfig.apply() + } + + companion object { + lateinit var CONFIG: DetoxConfig + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/config/DetoxIdlePolicyConfig.kt b/detox/android/detox/src/full/java/com/wix/detox/config/DetoxIdlePolicyConfig.kt new file mode 100644 index 000000000..1b642e8ce --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/config/DetoxIdlePolicyConfig.kt @@ -0,0 +1,23 @@ +package com.wix.detox.config + +import androidx.test.espresso.IdlingPolicies +import java.util.concurrent.TimeUnit + +/** + * Specification of values to use for Espresso's {@link IdlingPolicies} timeouts. + * + * Overrides Espresso's defaults as they tend to be too short (e.g. when running a heavy-load app + * on suboptimal CI machines). + */ +class DetoxIdlePolicyConfig { + /** Directly binds to [IdlingPolicies.setMasterPolicyTimeout]. Applied in seconds. */ + @JvmField var masterTimeoutSec = 240 + + /** Directly binds to [IdlingPolicies.setIdlingResourceTimeout]. Applied in seconds. */ + @JvmField var idleResourceTimeoutSec = 180 + + fun apply() { + IdlingPolicies.setMasterPolicyTimeout(masterTimeoutSec.toLong(), TimeUnit.SECONDS) + IdlingPolicies.setIdlingResourceTimeout(idleResourceTimeoutSec.toLong(), TimeUnit.SECONDS) + } +} 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 new file mode 100644 index 000000000..1f0b6362c --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAction.java @@ -0,0 +1,273 @@ +package com.wix.detox.espresso; + +import static androidx.test.espresso.action.ViewActions.actionWithAssertions; +import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static org.hamcrest.Matchers.allOf; + +import android.view.View; + +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.ViewInteraction; +import androidx.test.espresso.action.CoordinatesProvider; +import androidx.test.espresso.action.GeneralClickAction; +import androidx.test.espresso.action.GeneralLocation; +import androidx.test.espresso.action.Press; +import androidx.test.espresso.contrib.PickerActions; + +import com.wix.detox.action.common.MotionDir; +import com.wix.detox.common.DetoxErrors.DetoxRuntimeException; +import com.wix.detox.common.DetoxErrors.StaleActionException; +import com.wix.detox.espresso.action.AdjustSliderToPositionAction; +import com.wix.detox.espresso.action.DetoxCustomTapper; +import com.wix.detox.espresso.action.GetAttributesAction; +import com.wix.detox.espresso.action.LongPressAndDragAction; +import com.wix.detox.espresso.action.RNClickAction; +import com.wix.detox.espresso.action.RNDetoxAccessibilityAction; +import com.wix.detox.espresso.action.ScreenshotResult; +import com.wix.detox.espresso.action.ScrollToIndexAction; +import com.wix.detox.espresso.action.TakeViewScreenshotAction; +import com.wix.detox.espresso.action.common.utils.ViewInteractionExt; +import com.wix.detox.espresso.action.common.DetoxViewConfigurations; +import com.wix.detox.espresso.scroll.DetoxScrollAction; +import com.wix.detox.espresso.scroll.DetoxScrollActionStaleAtEdge; +import com.wix.detox.espresso.scroll.ScrollEdgeException; +import com.wix.detox.espresso.scroll.ScrollHelper; +import com.wix.detox.espresso.scroll.SwipeHelper; + +import org.hamcrest.Matcher; + +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Calendar; +import java.util.Date; + +/** + * Created by simonracz on 10/07/2017. + */ + +public class DetoxAction { + private static final String LOG_TAG = "detox"; + private static final String ISO8601_FORMAT = "yyyy-MM-dd'T'HH:mm:ssZ"; + private static final String ISO8601_FORMAT_NO_TZ = "yyyy-MM-dd'T'HH:mm:ss"; + + private DetoxAction() { + // static class + } + + public static ViewAction multiClick(int times) { + return actionWithAssertions(new GeneralClickAction(new DetoxCustomTapper(times), GeneralLocation.CENTER, Press.FINGER, 0, 0)); + } + + public static ViewAction tapAtLocation(final int x, final int y) { + CoordinatesProvider coordinatesProvider = createCoordinatesProvider(x, y); + return actionWithAssertions(new RNClickAction(coordinatesProvider)); + } + + private static CoordinatesProvider createCoordinatesProvider(final int x, final int y) { + final int px = DeviceDisplay.convertDpiToPx(x); + final int py = DeviceDisplay.convertDpiToPx(y); + + return new CoordinatesProvider() { + @Override + public float[] calculateCoordinates(View view) { + final int[] xy = new int[2]; + view.getLocationOnScreen(xy); + final float fx = xy[0] + px; + final float fy = xy[1] + py; + return new float[]{fx, fy}; + } + }; + }; + + /** + * 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, 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() { + return allOf(isAssignableFrom(View.class), isDisplayed()); + } + + @Override + public String getDescription() { + return "scrollToEdge"; + } + + @Override + public void perform(UiController uiController, View view) { + try { + for (int i = 0; i < 100; i++) { + ScrollHelper.performOnce(uiController, view, edge, _startOffsetPercentX, _startOffsetPercentY); + } + throw new DetoxRuntimeException("Scrolling a lot without reaching the edge: force-breaking the loop"); + } catch (ScrollEdgeException e) { + // Done + } + } + }); + } + + /** + * Scrolls the View in a direction by the Density Independent Pixel amount. + * + * @param direction Direction to scroll (see {@link MotionDir}) + * @param amountInDP Density Independent Pixels + * @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; + final Float _startOffsetPercentY = startOffsetPercentY < 0 ? null : (float) startOffsetPercentY; + return actionWithAssertions(new DetoxScrollAction(direction, amountInDP, _startOffsetPercentX, _startOffsetPercentY)); + } + + /** + * Scroll the view in a direction by a specified amount (DP units). + *
Similar to {@link #scrollInDirection(int, double, double, double)}, but stops gracefully in the case + * where the scrolling-edge is reached, by throwing the {@link StaleActionException} exception (i.e. + * so as to make this use case manageable by the user). + * + * @param direction Direction to scroll (see {@link MotionDir}) + * @param amountInDP Density Independent Pixels + * @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; + final Float _startOffsetPercentY = startOffsetPercentY < 0 ? null : (float) startOffsetPercentY; + return actionWithAssertions(new DetoxScrollActionStaleAtEdge(direction, amountInDP, _startOffsetPercentX, _startOffsetPercentY)); + } + + /** + * Swipes the View in a direction. + * + * @param direction Direction to swipe (see {@link MotionDir}) + * @param fast true if fast, false if slow + * @param normalizedOffset or "swipe amount" between 0.0 and 1.0, relative to the screen width/height + * @param normalizedStartingPointX X coordinate of swipe starting point (between 0.0 and 1.0), relative to the view width + * @param normalizedStartingPointY Y coordinate of swipe starting point (between 0.0 and 1.0), relative to the view height + */ + public static ViewAction swipeInDirection(final int direction, boolean fast, double normalizedOffset, double normalizedStartingPointX, double normalizedStartingPointY) { + SwipeHelper swipeHelper = SwipeHelper.getDefault(); + return swipeHelper.swipeInDirection(direction, fast, normalizedOffset, normalizedStartingPointX, normalizedStartingPointY); + } + + public static ViewAction getAttributes() { + return new GetAttributesAction(); + } + + public static ViewAction scrollToIndex(int index) { + return new ScrollToIndexAction(index); + } + + public static ViewAction setDatePickerDate(String dateString, String formatString) throws ParseException { + Date date; + if (formatString.equals("ISO8601")) { + date = parseDateISO8601(dateString); + } else { + date = new SimpleDateFormat(formatString).parse(dateString); + } + + 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)); + } + + public static ViewAction adjustSliderToPosition(final Float newPosition) { + return new AdjustSliderToPositionAction(newPosition); + } + + public static ViewAction longPressAndDrag(Integer duration, + Double normalizedPositionX, + Double normalizedPositionY, + ViewInteraction targetElement, + Double normalizedTargetPositionX, + Double normalizedTargetPositionY, + boolean isFast, + Integer holdDuration) { + + // We receive a ViewInteraction which represents an interactions of the target view. We need to extract the view + // from it in order to get the coordinates of the target view. + View targetView = ViewInteractionExt.getView(targetElement); + + return actionWithAssertions(new LongPressAndDragAction( + duration, + normalizedPositionX, + normalizedPositionY, + targetView, + normalizedTargetPositionX, + normalizedTargetPositionY, + isFast, + holdDuration + )); + } + + public static ViewAction longPress() { + return longPress(null, null, null); + } + + public static ViewAction longPress(Integer duration) { + return longPress(null, null, duration); + } + + public static ViewAction longPress(Integer x, Integer y) { + return longPress(x, y, null); + } + + public static ViewAction longPress(Integer x, Integer y, Integer duration) { + Long finalDuration = duration != null ? duration : DetoxViewConfigurations.getLongPressTimeout(); + CoordinatesProvider coordinatesProvider = x == null || y == null ? null : createCoordinatesProvider(x, y); + + return actionWithAssertions(new RNClickAction(coordinatesProvider, finalDuration)); + } + + public static ViewAction takeViewScreenshot() { + return new ViewActionWithResult() { + private final TakeViewScreenshotAction action = new TakeViewScreenshotAction(); + + @Override + public Matcher getConstraints() { + return action.getConstraints(); + } + + @Override + public String getDescription() { + return action.getDescription(); + } + + @Override + public void perform(UiController uiController, View view) { + action.perform(uiController, view); + } + + @Override + public String getResult() { + ScreenshotResult result = action.getResult(); + return (result == null ? null : result.asBase64String()); + } + }; + } + + public static ViewAction accessibilityAction(final String actionName) { + return new RNDetoxAccessibilityAction(actionName); + } + + private static Date parseDateISO8601(String dateString) throws ParseException { + try { + return new SimpleDateFormat(ISO8601_FORMAT).parse(dateString); + } catch (ParseException e) { + return new SimpleDateFormat(ISO8601_FORMAT_NO_TZ).parse(dateString); + } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAssertion.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAssertion.java new file mode 100644 index 000000000..63bb5a020 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxAssertion.java @@ -0,0 +1,109 @@ +package com.wix.detox.espresso; + +import android.view.View; + +import com.wix.detox.common.DetoxErrors.DetoxRuntimeException; +import com.wix.detox.common.DetoxErrors.StaleActionException; + +import junit.framework.AssertionFailedError; + +import org.hamcrest.Matcher; + +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.ViewInteraction; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.assertion.ViewAssertions.doesNotExist; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static com.wix.detox.espresso.assertion.ViewAssertions.matches; +import static org.hamcrest.Matchers.not; + +/** + * Created by simonracz on 10/07/2017. + */ + +public class DetoxAssertion { + + private static final double NANOSECONDS_IN_A_SECOND = 1_000_000_000.0; + + private DetoxAssertion() { + // This is a utility class and shouldn't be instantiated. + } + + /** + * Asserts the given matcher for the provided view interaction. + */ + public static ViewInteraction assertMatcher(ViewInteraction viewInteraction, Matcher viewMatcher) { + return viewInteraction.check(matches(viewMatcher)); + } + + /** + * Asserts that the given view interaction is not visible. + */ + public static ViewInteraction assertNotVisible(ViewInteraction viewInteraction) { + ViewInteraction result; + try { + result = viewInteraction.check(doesNotExist()); + return result; + } catch (AssertionFailedError e) { + result = viewInteraction.check(matches(not(isDisplayed()))); + return result; + } + } + + /** + * Asserts that the given view interaction does not exist. + */ + public static ViewInteraction assertNotExists(ViewInteraction viewInteraction) { + return viewInteraction.check(doesNotExist()); + } + + /** + * Waits until the provided matcher matches the view interaction or a timeout occurs. + */ + public static void waitForAssertMatcher(final ViewInteraction viewInteraction, final Matcher viewMatcher, double timeoutSeconds) { + final long startTime = System.nanoTime(); + + while (true) { + long currentTime = System.nanoTime(); + long elapsedTime = currentTime - startTime; + double elapsedSeconds = (double) elapsedTime / NANOSECONDS_IN_A_SECOND; + if (elapsedSeconds >= timeoutSeconds) { + throw new DetoxRuntimeException( + "" + timeoutSeconds + "sec timeout expired without matching of given matcher: " + viewMatcher); + } + + try { + viewInteraction.check(matches(viewMatcher)); + break; + } catch (AssertionFailedError err) { + UiAutomatorHelper.espressoSync(20); + } + } + } + + /** + * Continually asserts the provided matcher until a search action returns a matching view or a + * `StaleActionException` error is thrown. + */ + public static void waitForAssertMatcherWithSearchAction( + final ViewInteraction viewInteraction, + final Matcher viewMatcher, + final ViewAction searchAction, + final Matcher searchMatcher + ) { + while (true) { + try { + assertMatcher(viewInteraction, viewMatcher); + break; + } catch (AssertionFailedError err) { + try { + onView(searchMatcher).perform(searchAction); + } catch (StaleActionException exStaleAction) { + assertMatcher(viewInteraction, viewMatcher); + break; + } + } + } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxMatcher.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxMatcher.java new file mode 100644 index 000000000..8005c7441 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxMatcher.java @@ -0,0 +1,122 @@ +package com.wix.detox.espresso; + +import static androidx.test.espresso.matcher.ViewMatchers.hasDescendant; +import static androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom; +import static androidx.test.espresso.matcher.ViewMatchers.isChecked; +import static androidx.test.espresso.matcher.ViewMatchers.isDescendantOfA; +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayed; +import static androidx.test.espresso.matcher.ViewMatchers.isFocused; +import static androidx.test.espresso.matcher.ViewMatchers.isNotChecked; +import static androidx.test.espresso.matcher.ViewMatchers.withEffectiveVisibility; +import static com.wix.detox.espresso.matcher.ViewMatchers.matcherForDisplayingAtLeast; +import static com.wix.detox.espresso.matcher.ViewMatchers.isMatchingAtIndex; +import static com.wix.detox.espresso.matcher.ViewMatchers.isOfClassName; +import static com.wix.detox.espresso.matcher.ViewMatchers.toHaveSliderPosition; +import static com.wix.detox.espresso.matcher.ViewMatchers.withAccessibilityLabel; +import static com.wix.detox.espresso.matcher.ViewMatchers.withContentDescription; +import static com.wix.detox.espresso.matcher.ViewMatchers.withShallowAccessibilityLabel; +import static com.wix.detox.espresso.matcher.ViewMatchers.withTagValue; +import static com.wix.detox.espresso.matcher.ViewMatchers.withText; +import static org.hamcrest.Matchers.allOf; +import static org.hamcrest.Matchers.anyOf; +import static org.hamcrest.Matchers.not; +import static org.hamcrest.Matchers.notNullValue; +import static org.hamcrest.Matchers.nullValue; + +import android.view.View; + +import androidx.test.espresso.matcher.ViewMatchers.Visibility; + +import org.hamcrest.Matcher; + +/** + * Created by simonracz on 10/07/2017. + */ + +public class DetoxMatcher { + + private DetoxMatcher() { + // static class + } + + public static Matcher matcherForText(String text, boolean isRegex) { + // return anyOf(withText(text), withContentDescription(text)); + return allOf(withText(text, isRegex), withEffectiveVisibility(Visibility.VISIBLE)); + } + + public static Matcher matcherForAccessibilityLabel(String label, boolean isRegex) { + return allOf(withAccessibilityLabel(label, isRegex), withEffectiveVisibility(Visibility.VISIBLE)); + } + + public static Matcher matcherForShallowAccessibilityLabel(String label, boolean isRegex) { + return allOf(withShallowAccessibilityLabel(label, isRegex), withEffectiveVisibility(Visibility.VISIBLE)); + } + + public static Matcher matcherForContentDescription(String contentDescription) { + return allOf(withContentDescription(contentDescription, false), withEffectiveVisibility(Visibility.VISIBLE)); + } + + public static Matcher matcherForTestId(String testId, boolean isRegex) { + return allOf(withTagValue(testId, isRegex), withEffectiveVisibility(Visibility.VISIBLE)); + } + + public static Matcher matcherForToggleable(boolean value) { + return (value ? isChecked() : isNotChecked()); + } + + public static Matcher matcherForAnd(Matcher m1, Matcher m2) { + return allOf(m1, m2); + } + + public static Matcher matcherForOr(Matcher m1, Matcher m2) { + return anyOf(m1, m2); + } + + public static Matcher matcherForNot(Matcher m) { + return not(m); + } + + public static Matcher matcherWithAncestor(Matcher m, Matcher ancestorMatcher) { + return allOf(m, isDescendantOfA(ancestorMatcher)); + } + + public static Matcher matcherWithDescendant(Matcher m, Matcher descendantMatcher) { + return allOf(m, hasDescendant(descendantMatcher)); + } + + public static Matcher matcherForClass(String className) { + return isOfClassName(className); + } + + public static Matcher matcherForSufficientlyVisible(int pct) { + return matcherForDisplayingAtLeast(pct); + } + + public static Matcher matcherForNotVisible() { + return anyOf(nullValue(), not(isDisplayed())); + } + + public static Matcher matcherForNotNull() { + return notNullValue(android.view.View.class); + } + + public static Matcher matcherForNull() { + return nullValue(android.view.View.class); + } + + public static Matcher matcherForAtIndex(final int index, final Matcher innerMatcher) { + return isMatchingAtIndex(index, innerMatcher); + } + + public static Matcher matcherForAnything() { + return isAssignableFrom(View.class); + } + + public static Matcher matcherForFocus() { + return isFocused(); + } + + public static Matcher matcherForSliderPosition(double position, double tolerance) { + return toHaveSliderPosition(position, tolerance); + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxViewActions.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxViewActions.java new file mode 100644 index 000000000..bfbf322dd --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/DetoxViewActions.java @@ -0,0 +1,22 @@ +package com.wix.detox.espresso; + +import com.wix.detox.espresso.action.DetoxTypeTextAction; +import com.wix.detox.espresso.action.RNClickAction; + +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.action.ViewActions; + +import static androidx.test.espresso.action.ViewActions.actionWithAssertions; + +/** + * An alternative to {@link ViewActions} - providing alternative implementations, where needed. + */ +public class DetoxViewActions { + public static ViewAction click() { + return actionWithAssertions(new RNClickAction()); + } + + public static ViewAction typeText(String text) { + return actionWithAssertions(new DetoxTypeTextAction(text)); + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/EspressoDetox.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/EspressoDetox.java new file mode 100644 index 000000000..45e6d9b93 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/EspressoDetox.java @@ -0,0 +1,176 @@ +package com.wix.detox.espresso; + +import com.wix.detox.espresso.performer.ViewActionPerformer; + +import android.app.Activity; +import android.content.Context; +import android.content.ContextWrapper; +import android.content.pm.ActivityInfo; +import android.util.Log; +import android.view.View; +import android.view.ViewGroup; + +import com.facebook.react.ReactApplication; +import com.wix.detox.common.UIThread; +import com.wix.detox.reactnative.ReactNativeExtension; +import com.wix.detox.reactnative.idlingresources.network.NetworkIdlingResource; + +import org.hamcrest.Matcher; + +import java.util.ArrayList; + +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.platform.app.InstrumentationRegistry; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.matcher.ViewMatchers.isRoot; +import static com.wix.detox.espresso.UiAutomatorHelper.getStatusBarHeightDps; + +/** + * Created by rotemm on 26/12/2016. + */ +public class EspressoDetox { + private static final String LOG_TAG = "detox"; + + private static int calculateAdjustedY(View view, Integer y, boolean shouldIgnoreStatusBar) { + return shouldIgnoreStatusBar ? y + getStatusBarHeightDps(view) : y; + } + + public static Object perform(Matcher matcher, ViewAction action) { + ViewActionPerformer performer = ViewActionPerformer.forAction(action); + return performer.performOn(matcher); + } + + public static Activity getActivity(Context context) { + if (context instanceof Activity) { + return (Activity) context; + } + while (context instanceof ContextWrapper) { + if (context instanceof Activity) { + return (Activity) context; + } + context = ((ContextWrapper) context).getBaseContext(); + } + return null; + } + + public static void changeOrientation(final int orientation) { + onView(isRoot()).perform(new ViewAction() { + @Override + public Matcher getConstraints() { + return isRoot(); + } + + @Override + public String getDescription() { + return "changing orientation to " + orientation; + } + + @Override + public void perform(UiController uiController, View view) { + Activity activity = ReactNativeExtension.getRNActivity(view.getContext().getApplicationContext()); + + if (activity == null) { + activity = getActivity(view.getContext()); + if (activity == null && view instanceof ViewGroup) { + ViewGroup v = (ViewGroup) view; + int c = v.getChildCount(); + for (int i = 0; i < c && activity == null; ++i) { + activity = getActivity(v.getChildAt(i).getContext()); + } + } + } + + if (activity == null) { + throw new RuntimeException("Couldn't get a hold of the Activity"); + } + + switch (orientation) { + case 0: + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT); + break; + case 1: + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_LANDSCAPE); + break; + case 2: + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_PORTRAIT); + break; + case 3: + activity.setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_REVERSE_LANDSCAPE); + break; + default: + Log.e(LOG_TAG, "Not supported orientation: " + orientation); + } + uiController.loopMainThreadUntilIdle(); + } + }); + } + + public static void setSynchronization(boolean enabled) { + if (enabled) { + Context context = InstrumentationRegistry.getInstrumentation().getTargetContext(); + ReactNativeExtension.enableAllSynchronization((ReactApplication) context.getApplicationContext()); + } else { + ReactNativeExtension.clearAllSynchronization(); + } + } + + public static void setURLBlacklist(final ArrayList urls) { + UIThread.postSync(new Runnable() { + @Override + public void run() { + NetworkIdlingResource.setURLBlacklist(urls); + } + }); + } + + public static void tap(Integer x, Integer y, boolean shouldIgnoreStatusBar) { + onView(isRoot()).perform(new ViewAction() { + @Override + public Matcher getConstraints() { + return isRoot(); + } + + @Override + public String getDescription() { + return "tap on screen"; + } + + @Override + public void perform(UiController uiController, View view) { + int adjustedY = calculateAdjustedY(view, y, shouldIgnoreStatusBar); + ViewAction action = DetoxAction.tapAtLocation(x, adjustedY); + action.perform(uiController, view); + uiController.loopMainThreadUntilIdle(); + } + }); + } + + public static void longPress(Integer x, Integer y, boolean shouldIgnoreStatusBar) { + longPress(x, y, null, shouldIgnoreStatusBar); + } + + public static void longPress(Integer x, Integer y, Integer duration, boolean shouldIgnoreStatusBar) { + onView(isRoot()).perform(new ViewAction() { + @Override + public Matcher getConstraints() { + return isRoot(); + } + + @Override + public String getDescription() { + return "long press on screen"; + } + + @Override + public void perform(UiController uiController, View view) { + int adjustedY = calculateAdjustedY(view, y, shouldIgnoreStatusBar); + ViewAction action = DetoxAction.longPress(x, adjustedY, duration); + action.perform(uiController, view); + uiController.loopMainThreadUntilIdle(); + } + }); + } +} + diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/UiAutomatorHelper.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/UiAutomatorHelper.java new file mode 100644 index 000000000..e82be14e4 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/UiAutomatorHelper.java @@ -0,0 +1,125 @@ +package com.wix.detox.espresso; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Handler; +import android.util.DisplayMetrics; +import android.util.Log; +import android.util.TypedValue; +import android.view.Choreographer; +import android.view.View; + +import com.wix.detox.common.UIThread; +import com.wix.detox.espresso.action.common.utils.UiControllerUtils; + +import org.joor.Reflect; +import org.joor.ReflectException; + +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; + +import androidx.test.platform.app.InstrumentationRegistry; + +/** + * Created by simonracz on 19/07/2017. + */ + +public class UiAutomatorHelper { + private static final String LOG_TAG = "detox"; + + private static final String METHOD_LOOP_UNTIL_IDLE = "loopMainThreadUntilIdle"; + private static final String METHOD_LOOP_AT_LEAST = "loopMainThreadForAtLeast"; + + /** + * This triggers a full Espresso sync. It's intended use is to sync UIAutomator calls. + */ + public static void espressoSync() { + // I want to invoke Espresso's sync mechanism manually. + // This turned out to be amazingly difficult. This below is the + // nicest solution I could come up with. + UIThread.runSync(new Runnable() { + @Override + public void run() { + try { + Reflect.on(UiControllerUtils.getUiController()).call(METHOD_LOOP_UNTIL_IDLE); + } catch (ReflectException e) { + Log.e(LOG_TAG, "Failed to sync Espresso manually.", e.getCause()); + } + } + }); + } + + /** + * This triggers a full Espresso sync. Waits at least millis amount of time. + * + * @param millis waits at least this amount of time + */ + public static void espressoSync(final long millis) { + // I want to invoke Espresso's sync mechanism manually. + // This turned out to be amazingly difficult. This below is the + // nicest solution I could come up with. + UIThread.runSync(new Runnable() { + @Override + public void run() { + try { + Reflect.on(UiControllerUtils.getUiController()).call(METHOD_LOOP_AT_LEAST, millis); + } catch (ReflectException e) { + Log.e(LOG_TAG, "Failed to sync Espresso manually.", e.getCause()); + } + } + }); + } + + /** + * Waits for some Choreographer calls. + *

+ * React Native uses Choreographer callbacks. Those are invisible to Espresso. + * One of them is UIModule, UIViewOperationQueue. + *

+ * After everything idled out, we should still wait for UIModule to initiate it's changes + * on the UI by waiting out its Choreographer frame. + *

+ * TODO: + * Find a way to wrap up the UIModule in an Espresso IdlingResource, similar to JS Timers. + */ + private static void waitForChoreographer() { + final int waitFrameCount = 2; + final CountDownLatch latch = new CountDownLatch(1); + Handler handler = new Handler(InstrumentationRegistry.getInstrumentation().getTargetContext().getMainLooper()); + handler.post( + new Runnable() { + @Override + public void run() { + Choreographer.getInstance().postFrameCallback( + new Choreographer.FrameCallback() { + + private int frameCount = 0; + + @Override + public void doFrame(long frameTimeNanos) { + frameCount++; + if (frameCount == waitFrameCount) { + latch.countDown(); + } else { + Choreographer.getInstance().postFrameCallback(this); + } + } + }); + } + }); + try { + if (!latch.await(500, TimeUnit.MILLISECONDS)) { + throw new RuntimeException("Timed out waiting for Choreographer"); + } + } catch (Exception e) { + throw new RuntimeException(e); + } + } + + @SuppressLint({"DiscouragedApi", "InternalInsetResource"}) + public static int getStatusBarHeightDps(View view) { + Context context = view.getContext(); + int resourceId = context.getResources().getIdentifier("status_bar_height", "dimen", "android"); + return (int) (context.getResources().getDimensionPixelSize(resourceId) / ((float) context.getResources().getDisplayMetrics().densityDpi / DisplayMetrics.DENSITY_DEFAULT)); + } +} 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 new file mode 100644 index 000000000..08e609a67 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/AdjustSliderToPositionAction.kt @@ -0,0 +1,22 @@ +package com.wix.detox.espresso.action + +import android.view.View +import androidx.appcompat.widget.AppCompatSeekBar +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import androidx.test.espresso.matcher.ViewMatchers.isAssignableFrom +import androidx.test.espresso.matcher.ViewMatchers.isDisplayed +import com.wix.detox.espresso.common.ReactSliderHelper +import org.hamcrest.Matcher +import org.hamcrest.Matchers + +class AdjustSliderToPositionAction(private val targetPositionPct: Float) : ViewAction { + override fun getDescription() = "adjustSliderToPosition" + override fun getConstraints(): Matcher? = + Matchers.allOf( isDisplayed(), isAssignableFrom(AppCompatSeekBar::class.java) ) + + override fun perform(uiController: UiController?, view: View) { + val sliderHelper = ReactSliderHelper.create(view) + sliderHelper.setProgressPct(targetPositionPct) + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/DetoxTypeTextAction.java b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/DetoxTypeTextAction.java new file mode 100644 index 000000000..3c100c4aa --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/DetoxTypeTextAction.java @@ -0,0 +1,39 @@ +package com.wix.detox.espresso.action; + +import android.view.View; + +import org.hamcrest.Matcher; + +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.action.TypeTextAction; + +import static org.hamcrest.Matchers.allOf; + +public class DetoxTypeTextAction implements ViewAction { + private final String text; + private final RNClickAction clickAction; + private final TypeTextAction typeTextAction; + + public DetoxTypeTextAction(String text) { + this.text = text; + clickAction = new RNClickAction(); + typeTextAction = new TypeTextAction(text, false); + } + + @Override + public Matcher getConstraints() { + return allOf(clickAction.getConstraints(), new TypeTextAction("", true).getConstraints()); + } + + @Override + public String getDescription() { + return "Click to focus & type text ("+text+")"; + } + + @Override + public void perform(UiController uiController, View view) { + clickAction.perform(uiController, view); + typeTextAction.perform(uiController, view); + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/GetAttributesAction.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/GetAttributesAction.kt new file mode 100644 index 000000000..8de5a2fd7 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/GetAttributesAction.kt @@ -0,0 +1,174 @@ +package com.wix.detox.espresso.action + +import android.graphics.Rect +import android.os.Build +import android.view.View +import android.widget.CheckBox +import android.widget.ProgressBar +import android.widget.TextView +import androidx.test.espresso.UiController +import com.wix.detox.espresso.ViewActionWithResult +import com.wix.detox.espresso.MultipleViewsAction +import com.wix.detox.espresso.common.ReactSliderHelper +import com.wix.detox.espresso.common.MaterialSliderHelper +import com.wix.detox.reactnative.ui.getAccessibilityLabel +import org.hamcrest.Matcher +import org.hamcrest.Matchers +import org.hamcrest.Matchers.allOf +import org.hamcrest.Matchers.notNullValue +import org.json.JSONObject + +private interface AttributeExtractor { + fun extractAttributes(json: JSONObject, view: View) +} + +class GetAttributesAction() : ViewActionWithResult, MultipleViewsAction { + private val attributeExtractors = listOf( + CommonAttributes(), + TextViewAttributes(), + CheckBoxAttributes(), + ProgressBarAttributes(), + MaterialSliderAttributes() + ) + private var result: JSONObject? = null + + override fun perform(uiController: UiController?, view: View?) { + view!! + + val json = JSONObject() + attributeExtractors.forEach { it.extractAttributes(json, view) } + + result = json + } + + override fun getResult() = result + override fun getDescription() = "Get view attributes" + override fun getConstraints(): Matcher = allOf(notNullValue(), Matchers.isA(View::class.java)) +} + +private class CommonAttributes : AttributeExtractor { + override fun extractAttributes(json: JSONObject, view: View) { + getId(json, view) + getVisibility(json, view) + getAccessibilityLabel(json, view) + getAlpha(json, view) + getElevation(json, view) + getFrame(json, view) + getHeight(json, view) + getWidth(json, view) + getHasFocus(json, view) + getIsEnabled(json, view) + } + + private fun getId(json: JSONObject, view: View) = + view.tag?.let { + json.put("identifier", it.toString()) + } + + private fun getFrame(json: JSONObject, view: View) { + val location = IntArray(2) + view.getLocationOnScreen(location) + json.put("frame", JSONObject().apply { + put("x", location[0]) + put("y", location[1]) + put("width", view.width) + put("height", view.height) + }) + } + + private fun getVisibility(json: JSONObject, view: View) { + json.put("visibility", visibilityMap[view.visibility]) + json.put("visible", view.getLocalVisibleRect(Rect())) + } + + private fun getAccessibilityLabel(json: JSONObject, view: View) = + view.getAccessibilityLabel()?.let { + json.put("label", it) + } + + private fun getElevation(json: JSONObject, view: View) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) { + json.put("elevation", view.elevation) + } + } + + private fun getAlpha(json: JSONObject, view: View) = json.put("alpha", view.alpha) + private fun getHeight(json: JSONObject, view: View) = json.put("height", view.height) + private fun getWidth(json: JSONObject, view: View) = json.put("width", view.width) + private fun getIsEnabled(json: JSONObject, view: View) = json.put("enabled", view.isEnabled) + private fun getHasFocus(json: JSONObject, view: View) = json.put("focused", view.isFocused) + + companion object { + private val visibilityMap = mapOf(View.VISIBLE to "visible", View.INVISIBLE to "invisible", View.GONE to "gone") + } +} + +private class TextViewAttributes : AttributeExtractor { + override fun extractAttributes(json: JSONObject, view: View) { + if (view is TextView) { + getText(json, view) + getLength(json, view) + getTextSize(json, view) + getHint(json, view) + } + } + + private fun getText(rootObject: JSONObject, view: TextView) = + view.text?.let { + rootObject.put("text", it.toString()) + } + + private fun getTextSize(rootObject: JSONObject, view: TextView) = + rootObject.put("textSize", view.textSize) + + private fun getLength(rootObject: JSONObject, view: TextView) = + view.text?.let { + rootObject.put("length", view.length()) + } + + private fun getHint(rootObject: JSONObject, view: TextView) = + view.hint?.let { + rootObject.put("placeholder", it.toString()) + } +} + +private class CheckBoxAttributes : AttributeExtractor { + override fun extractAttributes(json: JSONObject, view: View) { + if (view is CheckBox) { + getCheckboxValue(json, view) + } + } + + private fun getCheckboxValue(rootObject: JSONObject, view: CheckBox) = + rootObject.put("value", view.isChecked) +} + +/** + * Note: this applies also to [androidx.appcompat.widget.AppCompatSeekBar], which + * is anything RN-slider-ish. + */ +private class ProgressBarAttributes : AttributeExtractor { + override fun extractAttributes(json: JSONObject, view: View) { + if (view is ProgressBar) { + ReactSliderHelper.maybeCreate(view)?.let { + getReactSliderValue(json, it) + } ?: + getProgressBarValue(json, view) + } + } + + private fun getReactSliderValue(rootObject: JSONObject, reactSliderHelper: ReactSliderHelper) { + rootObject.put("value", reactSliderHelper.getCurrentProgressPct()) + } + + private fun getProgressBarValue(rootObject: JSONObject, view: ProgressBar) = + rootObject.put("value", view.progress) +} + +private class MaterialSliderAttributes : AttributeExtractor { + override fun extractAttributes(json: JSONObject, view: View) { + MaterialSliderHelper(view).getValueIfSlider()?.let { + json.put("value", it) + } + } +} 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 new file mode 100644 index 000000000..f9cf16eb8 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNClickAction.java @@ -0,0 +1,63 @@ +package com.wix.detox.espresso.action; + +import android.view.InputDevice; +import android.view.MotionEvent; +import android.view.View; + +import com.wix.detox.reactnative.ReactNativeExtension; + +import org.hamcrest.Matcher; + +import androidx.test.espresso.UiController; +import androidx.test.espresso.ViewAction; +import androidx.test.espresso.action.CoordinatesProvider; +import androidx.test.espresso.action.GeneralClickAction; +import androidx.test.espresso.action.GeneralLocation; +import androidx.test.espresso.action.Press; + +import static androidx.test.espresso.matcher.ViewMatchers.isDisplayingAtLeast; + +public class RNClickAction implements ViewAction { + private final GeneralClickAction clickAction; + + public RNClickAction() { + this(null, null); + } + + public RNClickAction(CoordinatesProvider coordinatesProvider) { + this(coordinatesProvider, null); + } + + public RNClickAction(CoordinatesProvider coordinatesProvider, Long duration) { + coordinatesProvider = coordinatesProvider != null ? coordinatesProvider : GeneralLocation.VISIBLE_CENTER; + + clickAction = new GeneralClickAction( + new DetoxSingleTap(duration), + coordinatesProvider, + Press.FINGER, + InputDevice.SOURCE_UNKNOWN, + MotionEvent.BUTTON_PRIMARY + ); + } + + @Override + public Matcher getConstraints() { + return isDisplayingAtLeast(75); + } + + @Override + public String getDescription() { + return clickAction.getDescription(); + } + + @Override + public void perform(UiController uiController, View view) { + ReactNativeExtension.toggleTimersSynchronization(false); + try { + clickAction.perform(uiController, view); + } finally { + ReactNativeExtension.toggleTimersSynchronization(true); + } + uiController.loopMainThreadUntilIdle(); + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNDetoxAccessibilityAction.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNDetoxAccessibilityAction.kt new file mode 100644 index 000000000..aa50a7385 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/action/RNDetoxAccessibilityAction.kt @@ -0,0 +1,46 @@ +package com.wix.detox.espresso.action + +import android.view.View +import androidx.test.espresso.UiController +import androidx.test.espresso.ViewAction +import com.facebook.react.bridge.Arguments +import com.facebook.react.bridge.ReactContext +import com.facebook.react.bridge.WritableMap +import com.facebook.react.uimanager.UIManagerHelper +import com.facebook.react.uimanager.events.Event +import com.wix.detox.espresso.DetoxMatcher +import org.hamcrest.Matcher + +class RNDetoxAccessibilityAction(private val mActionName: String) : ViewAction { + + override fun getConstraints(): Matcher? = DetoxMatcher.matcherForNotNull() + + override fun getDescription(): String = "Dispatch an Accessibility Action" + + override fun perform(uiController: UiController?, view: View?) { + val reactContext = view?.context as? ReactContext ?: return + val reactTag = view.id + + UIManagerHelper.getEventDispatcherForReactTag(reactContext, reactTag) + ?.dispatchEvent( + TopAccessibilityEvent( + surfaceId = UIManagerHelper.getSurfaceId(reactContext), + viewId = reactTag, + actionName = mActionName, + ) + ) + + val waitTimeMS = 100 + uiController!!.loopMainThreadForAtLeast(waitTimeMS.toLong()) + } +} + +class TopAccessibilityEvent(surfaceId: Int, viewId: Int, private val actionName: String) : + Event(surfaceId, viewId) { + + override fun getEventName(): String = "topAccessibilityAction" + + override fun getEventData(): WritableMap? { + return Arguments.createMap().apply { putString("actionName", actionName) } + } +} 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 new file mode 100644 index 000000000..d32f2e746 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/assertion/ViewAssertions.java @@ -0,0 +1,60 @@ +package com.wix.detox.espresso.assertion; + +import static androidx.test.espresso.matcher.ViewMatchers.assertThat; + +import android.view.View; + +import androidx.annotation.NonNull; +import androidx.test.espresso.NoMatchingViewException; +import androidx.test.espresso.ViewAssertion; + +import org.hamcrest.Matcher; +import org.hamcrest.StringDescription; + +/** + * A custom extension of {@link androidx.test.espresso.assertion.ViewAssertions}. + * + *

Perhaps in the future we could extend Espresso's using Kotlin's extension functions. + */ +public class ViewAssertions { + + /** + * An alternative to Espresso's {@link androidx.test.espresso.assertion.ViewAssertions#matches(Matcher)}, + * which is more suitable for Detox' separated interaction-matcher architecture. + * See {@link MatchesViewAssertion} for more details. + */ + public static ViewAssertion matches(final Matcher viewMatcher) { + return new MatchesViewAssertion(viewMatcher); + } + + /** + * Identical to Espresso's {@link androidx.test.espresso.assertion.ViewAssertions}#MatchesViewAssertion + * typically created by {@link androidx.test.espresso.assertion.ViewAssertions#matches(Matcher)}, except + * that instead of throwing the {@link NoMatchingViewException} (given to the matcher by the interaction + * when the view wasn't in the hierarchy), it invokes the matcher nonetheless (i.e. with a null as the item). + */ + private static class MatchesViewAssertion implements ViewAssertion { + final Matcher viewMatcher; + + private MatchesViewAssertion(final Matcher viewMatcher) { + this.viewMatcher = viewMatcher; + } + + @Override + public void check(View view, NoMatchingViewException noViewException) { + StringDescription description = new StringDescription(); + description.appendText("'"); + viewMatcher.describeTo(description); + + description.appendText("' doesn't match the selected view."); + + assertThat(description.toString(), noViewException != null ? null : view, viewMatcher); + } + + @NonNull + @Override + public String toString() { + return String.format("MatchesViewAssertion(Detox){viewMatcher=%s}", viewMatcher); + } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/common/MaterialSliderHelper.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/MaterialSliderHelper.kt new file mode 100644 index 000000000..479ac09c8 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/MaterialSliderHelper.kt @@ -0,0 +1,21 @@ +package com.wix.detox.espresso.common + +import android.view.View +import com.wix.detox.espresso.action.common.ReflectUtils +import org.joor.Reflect + +private const val CLASS_MATERIAL_SLIDER = "com.google.android.material.slider.Slider" + +open class MaterialSliderHelper(protected val view: View) { + fun getValueIfSlider(): Float? { + if (!isSlider()) { + return null + } + + return getValue() + } + + private fun isSlider() = ReflectUtils.isAssignableFrom(view, CLASS_MATERIAL_SLIDER) + + private fun getValue() = Reflect.on(view).call("getValue").get() as Float +} 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 new file mode 100644 index 000000000..c39667aac --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/ReactSliderHelper.kt @@ -0,0 +1,76 @@ +package com.wix.detox.espresso.common + +import android.view.View +import androidx.appcompat.widget.AppCompatSeekBar +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 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" + +abstract class ReactSliderHelper(protected val slider: AppCompatSeekBar) { + fun getCurrentProgressPct(): Double { + val nativeProgress = slider.progress.toDouble() + val nativeMax = slider.max + return nativeProgress / nativeMax + } + + // TODO Make this more testable (e.g. by delegating the set action away) + fun setProgressPct(valuePct: Float) { + val maxJSProgress = calcMaxJSProgress() + val valueJS = valuePct * maxJSProgress + setProgressJS(valueJS.toFloat()) + } + + protected abstract fun setProgressJS(valueJS: Float) + + private fun calcMaxJSProgress(): Double { + val nativeProgress = slider.progress.toDouble() + val nativeMax = slider.max + val toMaxFactor = nativeMax / nativeProgress + + val jsProgress = getJSProgress() + return jsProgress * toMaxFactor + } + + private fun getJSProgress(): Double = + Reflect.on(slider).call("toRealProgress", slider.progress).get() as Double + + companion object { + fun create(view: View) = + maybeCreate(view) + ?: throw DetoxIllegalStateException("Cannot handle this type of a seek-bar view (Class ${view.javaClass.canonicalName}). " + + "Only React Native sliders are currently supported.") + + fun maybeCreate(view: View): ReactSliderHelper? = + when { + ReflectUtils.isAssignableFrom(view, CLASS_REACT_SLIDER_LEGACY) + -> LegacySliderHelper(view as AppCompatSeekBar) + ReflectUtils.isAssignableFrom(view, CLASS_REACT_SLIDER_COMMUNITY) + -> CommunitySliderHelper(view as AppCompatSeekBar) + else + -> null + } + } +} + +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: 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/espresso/common/UiControllerImplReflected.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/UiControllerImplReflected.kt new file mode 100644 index 000000000..4003dc4c1 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/common/UiControllerImplReflected.kt @@ -0,0 +1,16 @@ +package com.wix.detox.espresso.common + +import com.wix.detox.espresso.action.common.utils.getUiController +import org.joor.Reflect + +private const val FIELD_ASYNC_IDLE = "asyncIdle" +private const val FIELD_COMPAT_IDLE = "compatIdle" +private const val METHOD_IS_IDLE_NOW = "isIdleNow" + +class UiControllerImplReflected { + fun isAsyncIdleNow(): Boolean = + Reflect.on(getUiController()).field(FIELD_ASYNC_IDLE).call(METHOD_IS_IDLE_NOW).get() + + fun isCompatIdleNow(): Boolean = + Reflect.on(getUiController()).field(FIELD_COMPAT_IDLE).call(METHOD_IS_IDLE_NOW).get() +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/RootViewsHelper.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/RootViewsHelper.kt new file mode 100644 index 000000000..3f635cc65 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/RootViewsHelper.kt @@ -0,0 +1,51 @@ +package com.wix.detox.espresso.hierarchy + +import android.annotation.SuppressLint +import android.view.View +import java.lang.reflect.Field +import java.lang.reflect.Method + +object RootViewsHelper { + + /** + * Get rootviews from RootViewImpl instances that are stored in WindowManagerGlobal. + */ + fun getRootViews(): List? { + val rootViewsReflectedObjects = getAllViewRootObjects() + val rootViews = rootViewsReflectedObjects?.map { + // Root View is stored in the ViewRootImpl instance + val getViewMethod = it.javaClass.getDeclaredMethod("getView") + getViewMethod.isAccessible = true + + // Invoke the method to get the root View + getViewMethod.invoke(it) as? View + } + return rootViews + } + + @SuppressLint("PrivateApi", "DiscouragedPrivateApi") + private fun getAllViewRootObjects(): List? { + return try { + // Get the WindowManagerGlobal class + val windowManagerGlobalClass = Class.forName("android.view.WindowManagerGlobal") + + // Get the getInstance method + val getInstanceMethod: Method = windowManagerGlobalClass.getDeclaredMethod("getInstance") + getInstanceMethod.isAccessible = true + + // Get the single instance of WindowManagerGlobal + val windowManagerGlobal = getInstanceMethod.invoke(null) + + // Get the mRoots field, which is a list of ViewRootImpl instances + val mRootsField: Field = windowManagerGlobalClass.getDeclaredField("mRoots") + mRootsField.isAccessible = true + + // Return the list of ViewRootImpl instances + @Suppress("UNCHECKED_CAST") + mRootsField.get(windowManagerGlobal) as? List + } catch (e: Exception) { + e.printStackTrace() + null + } + } +} diff --git a/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/ViewHierarchyGenerator.kt b/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/ViewHierarchyGenerator.kt new file mode 100644 index 000000000..c43ffb3b6 --- /dev/null +++ b/detox/android/detox/src/full/java/com/wix/detox/espresso/hierarchy/ViewHierarchyGenerator.kt @@ -0,0 +1,193 @@ +package com.wix.detox.espresso.hierarchy + +import android.util.Xml +import android.view.View +import android.view.ViewGroup +import android.webkit.WebView +import android.widget.TextView +import com.wix.detox.reactnative.ui.getAccessibilityLabel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import org.xmlpull.v1.XmlSerializer +import java.io.StringWriter +import kotlin.coroutines.resume + + +private const val GET_HTML_SCRIPT = """ +(function() { + const blacklistedTags = ['script', 'style', 'head', 'meta']; + const blackListedTagsSelector = blacklistedTags.join(','); + + // Clone the entire document + var clonedDoc = document.documentElement.cloneNode(true); + + // Remove all