From 5d129af1c7c6685f87042677deadad6832430aaf Mon Sep 17 00:00:00 2001 From: piotradamczyk5 <65554637+piotradamczyk5@users.noreply.github.com> Date: Thu, 1 Oct 2020 13:39:39 +0200 Subject: [PATCH] test: Added cucumber sample app for testing #1118 (#1174) * test: Added cucumber sample app for testing * test: Added cucumber sample app for testing * remove unused files --- test_projects/android/build.gradle | 5 +- .../cucumber-android/.gitignore | 1 + .../cucumber-android/build.gradle | 65 +++ .../src/main/AndroidManifest.xml | 5 + .../java/AndroidJavaBackendFactory.java | 31 ++ .../runner/CucumberAndroidJUnitRunner.java | 29 ++ .../AndroidPatternCompiler.java | 11 + .../cucumber/junit/AndroidFeatureRunner.java | 45 ++ .../AndroidJunitRuntimeOptionsFactory.java | 57 +++ .../cucumber/junit/AndroidLogcatReporter.java | 86 ++++ .../cucumber/junit/AndroidPickleRunner.java | 53 ++ .../io/cucumber/junit/AndroidResource.java | 53 ++ .../cucumber/junit/AndroidResourceLoader.java | 68 +++ .../java/io/cucumber/junit/Arguments.java | 195 ++++++++ .../io/cucumber/junit/CoverageDumper.java | 104 ++++ .../junit/CucumberAndroidJUnitArguments.java | 95 ++++ .../junit/CucumberArgumentsProvider.java | 8 + .../cucumber/junit/CucumberJUnitRunner.java | 295 +++++++++++ .../junit/CucumberJUnitRunnerBuilder.java | 16 + .../io/cucumber/junit/DebuggerWaiter.java | 32 ++ .../io/cucumber/junit/DexClassFinder.java | 123 +++++ .../io/cucumber/junit/FeatureCompiler.java | 33 ++ .../junit/MissingStepDefinitionError.java | 16 + ...cumber.cucumberexpressions.PatternCompiler | 1 + .../src/test/java/com/vladium/emma/rt/RT.java | 39 ++ .../junit/AndroidPatternCompilerTest.java | 30 ++ .../junit/AndroidResourceLoaderTest.java | 111 +++++ .../cucumber/junit/AndroidResourceTest.java | 77 +++ .../java/io/cucumber/junit/ArgumentsTest.java | 469 ++++++++++++++++++ .../io/cucumber/junit/CoverageDumperTest.java | 153 ++++++ .../io/cucumber/junit/DebuggerWaiterTest.java | 56 +++ .../io/cucumber/junit/DexClassFinderTest.java | 178 +++++++ .../junit/MissingStepDefinitionErrorTest.java | 24 + .../cucumber/junit/shadow/ShadowDexFile.java | 23 + .../stub/unwanted/SomeUnwantedClass.java | 4 + .../cucumber/junit/stub/wanted/Manifest.java | 4 + .../java/io/cucumber/junit/stub/wanted/R.java | 8 + .../cucumber/junit/stub/wanted/SomeClass.java | 4 + .../junit/stub/wanted/SomeKotlinClass.kt | 8 + .../src/test/resources/robolectric.properties | 1 + .../cucumber_sample_app/cukeulator/.gitignore | 28 ++ .../cucumber_sample_app/cukeulator/README.md | 77 +++ .../cukeulator/build.gradle | 248 +++++++++ .../assets/features/extra/calculate.feature | 20 + .../features/operations/addition.feature | 31 ++ .../features/operations/division.feature | 31 ++ .../operations/multiplication.feature | 31 ++ .../features/operations/subtraction.feature | 31 ++ .../test/CalculatorActivitySteps.java | 134 +++++ .../test/CukeulatorAndroidJUnitRunner.java | 61 +++ .../test/InstrumentationNonCucumberTest.java | 41 ++ .../cucumber/cukeulator/test/KotlinSteps.kt | 18 + .../test/SomeClassWithUnsupportedApi.java | 51 ++ .../cukeulator/test/SomeDependency.java | 5 + .../test/TypeRegistryConfiguration.java | 45 ++ .../cukeulator/src/debug/AndroidManifest.xml | 9 + .../cukeulator/src/main/AndroidManifest.xml | 19 + .../cukeulator/CalculatorActivity.java | 152 ++++++ .../main/res/drawable-hdpi/ic_launcher.png | Bin 0 -> 6468 bytes .../main/res/drawable-mdpi/ic_launcher.png | Bin 0 -> 3664 bytes .../main/res/drawable-xhdpi/ic_launcher.png | Bin 0 -> 9291 bytes .../main/res/drawable-xxhdpi/ic_launcher.png | Bin 0 -> 16093 bytes .../main/res/layout/activity_calculator.xml | 187 +++++++ .../src/main/res/menu/menu_main.xml | 5 + .../src/main/res/values-v21/styles.xml | 5 + .../src/main/res/values-w820dp/dimens.xml | 6 + .../cukeulator/src/main/res/values/dimens.xml | 5 + .../src/main/res/values/strings.xml | 9 + .../cukeulator/src/main/res/values/styles.xml | 8 + .../android/multi-modules/multiapp/.gitignore | 1 + test_projects/android/ops.sh | 20 + test_projects/android/settings.gradle | 4 + test_projects/ops.sh | 0 73 files changed, 3897 insertions(+), 1 deletion(-) create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/.gitignore create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/build.gradle create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/AndroidManifest.xml create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/cucumber/runtime/java/AndroidJavaBackendFactory.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/android/runner/CucumberAndroidJUnitRunner.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/cucumberexpressions/AndroidPatternCompiler.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidFeatureRunner.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidJunitRuntimeOptionsFactory.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidLogcatReporter.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidPickleRunner.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResource.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResourceLoader.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/Arguments.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CoverageDumper.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberAndroidJUnitArguments.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberArgumentsProvider.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunner.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunnerBuilder.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DebuggerWaiter.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DexClassFinder.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/FeatureCompiler.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/MissingStepDefinitionError.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/main/resources/META-INF/services/io.cucumber.cucumberexpressions.PatternCompiler create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/com/vladium/emma/rt/RT.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidPatternCompilerTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceLoaderTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/ArgumentsTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/CoverageDumperTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DebuggerWaiterTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DexClassFinderTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/MissingStepDefinitionErrorTest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/shadow/ShadowDexFile.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/unwanted/SomeUnwantedClass.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/Manifest.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/R.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeClass.java create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeKotlinClass.kt create mode 100644 test_projects/android/cucumber_sample_app/cucumber-android/src/test/resources/robolectric.properties create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/.gitignore create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/README.md create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/build.gradle create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/extra/calculate.feature create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/addition.feature create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/division.feature create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/multiplication.feature create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/subtraction.feature create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CalculatorActivitySteps.java create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CukeulatorAndroidJUnitRunner.java create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/InstrumentationNonCucumberTest.java create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/KotlinSteps.kt create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeClassWithUnsupportedApi.java create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeDependency.java create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/TypeRegistryConfiguration.java create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/debug/AndroidManifest.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/AndroidManifest.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/java/cucumber/cukeulator/CalculatorActivity.java create mode 100755 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-hdpi/ic_launcher.png create mode 100755 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-mdpi/ic_launcher.png create mode 100755 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-xhdpi/ic_launcher.png create mode 100755 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-xxhdpi/ic_launcher.png create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/layout/activity_calculator.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/menu/menu_main.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-v21/styles.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-w820dp/dimens.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/dimens.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/strings.xml create mode 100644 test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/styles.xml mode change 100644 => 100755 test_projects/android/ops.sh mode change 100644 => 100755 test_projects/ops.sh diff --git a/test_projects/android/build.gradle b/test_projects/android/build.gradle index 0af7981577..9b27aac347 100644 --- a/test_projects/android/build.gradle +++ b/test_projects/android/build.gradle @@ -5,12 +5,15 @@ buildscript { repositories { google() jcenter() - + mavenLocal() + maven { url "https://oss.sonatype.org/content/repositories/snapshots" } + maven { url 'https://jitpack.io' } } dependencies { // https://dl.google.com/dl/android/maven2/index.html classpath 'com.android.tools.build:gradle:3.5.3' classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version" + classpath "com.jaredsburrows:gradle-spoon-plugin:1.5.0" // NOTE: Do not place your application dependencies here; they belong // in the individual module build.gradle files } diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/.gitignore b/test_projects/android/cucumber_sample_app/cucumber-android/.gitignore new file mode 100644 index 0000000000..e8e450bed8 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/.gitignore @@ -0,0 +1 @@ +gen/ diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/build.gradle b/test_projects/android/cucumber_sample_app/cucumber-android/build.gradle new file mode 100644 index 0000000000..f6b5269a94 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/build.gradle @@ -0,0 +1,65 @@ +import java.time.Duration + +apply plugin: 'com.android.library' +apply plugin: 'signing' +apply plugin: 'kotlin-android' + +android { + compileSdkVersion 29 + buildToolsVersion '29.0.3' + + defaultConfig { + minSdkVersion 14 + targetSdkVersion 29 + } + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + abortOnError false + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } +} + +configurations.all { + // check for updates every build + resolutionStrategy.cacheChangingModulesFor 0, 'seconds' +} + + +dependencies { + api "io.cucumber:cucumber-java:4.8.1" + api "io.cucumber:cucumber-junit:4.8.1" + api 'junit:junit:4.13' + api "androidx.test:runner:1.2.0" + testImplementation "org.robolectric:robolectric:4.3.1" + testImplementation "org.powermock:powermock-api-mockito2:2.0.2" + testImplementation "org.powermock:powermock-module-junit4:2.0.2" + testImplementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" +} + +task("generateJavadoc", type: Javadoc, group: 'documentation') { + source = android.sourceSets.main.java.srcDirs + destinationDir = new File("${project.buildDir}/javadoc") + options.addStringOption('Xdoclint:none', '-quiet') +} + +task javadocJar(type: Jar, dependsOn: generateJavadoc) { + archiveClassifier.set 'javadoc' + from generateJavadoc.destinationDir +} + +task sourcesJar(type: Jar) { + from android.sourceSets.main.java.srcDirs + archiveClassifier.set 'sources' +} + + diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/AndroidManifest.xml b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..fcebf64962 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/AndroidManifest.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest package="io.cucumber.android" + xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools"> +</manifest> diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/cucumber/runtime/java/AndroidJavaBackendFactory.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/cucumber/runtime/java/AndroidJavaBackendFactory.java new file mode 100644 index 0000000000..119d663508 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/cucumber/runtime/java/AndroidJavaBackendFactory.java @@ -0,0 +1,31 @@ +package cucumber.runtime.java; + +import cucumber.api.java.ObjectFactory; +import cucumber.runtime.BackendSupplier; +import cucumber.runtime.ClassFinder; +import cucumber.runtime.DefaultTypeRegistryConfiguration; +import cucumber.runtime.Env; +import cucumber.runtime.Reflections; +import io.cucumber.core.api.TypeRegistryConfigurer; +import io.cucumber.core.options.RuntimeOptions; +import io.cucumber.stepexpression.TypeRegistry; + +import static java.util.Collections.singletonList; + +/** + * This factory is responsible for creating the {@see JavaBackend} with dex class finder. + */ +public class AndroidJavaBackendFactory { + public static BackendSupplier createBackend(RuntimeOptions runtimeOptions, ClassFinder classFinder) { + return () -> { + final Reflections reflections = new Reflections(classFinder); + final ObjectFactory delegateObjectFactory = ObjectFactoryLoader.loadObjectFactory(classFinder, + JavaBackend.getObjectFactoryClassName(Env.INSTANCE), JavaBackend.getDeprecatedObjectFactoryClassName(Env.INSTANCE)); + final TypeRegistryConfigurer typeRegistryConfigurer = reflections.instantiateExactlyOneSubclass(TypeRegistryConfigurer.class, + runtimeOptions.getGlue(), new Class[0], new Object[0], new DefaultTypeRegistryConfiguration()); + final TypeRegistry typeRegistry = new TypeRegistry(typeRegistryConfigurer.locale()); + typeRegistryConfigurer.configureTypeRegistry(typeRegistry); + return singletonList(new JavaBackend(delegateObjectFactory, classFinder, typeRegistry)); + }; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/android/runner/CucumberAndroidJUnitRunner.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/android/runner/CucumberAndroidJUnitRunner.java new file mode 100644 index 0000000000..fe64e686c8 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/android/runner/CucumberAndroidJUnitRunner.java @@ -0,0 +1,29 @@ +package io.cucumber.android.runner; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.test.runner.AndroidJUnitRunner; + +import io.cucumber.junit.CucumberArgumentsProvider; +import io.cucumber.junit.CucumberAndroidJUnitArguments; + +/** + * {@link AndroidJUnitRunner} for cucumber tests. It supports running tests from Android Tests Orchestrator + */ +public class CucumberAndroidJUnitRunner extends AndroidJUnitRunner implements CucumberArgumentsProvider { + + private CucumberAndroidJUnitArguments cucumberJUnitRunnerCore; + + @Override + public void onCreate(final Bundle bundle) { + cucumberJUnitRunnerCore = new CucumberAndroidJUnitArguments(bundle); + super.onCreate(cucumberJUnitRunnerCore.processArgs()); + } + + @NonNull + @Override + public CucumberAndroidJUnitArguments getArguments() { + return cucumberJUnitRunnerCore; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/cucumberexpressions/AndroidPatternCompiler.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/cucumberexpressions/AndroidPatternCompiler.java new file mode 100644 index 0000000000..0e055fec51 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/cucumberexpressions/AndroidPatternCompiler.java @@ -0,0 +1,11 @@ +package io.cucumber.cucumberexpressions; + +import java.util.regex.Pattern; + +public class AndroidPatternCompiler implements PatternCompiler { + + @Override + public Pattern compile(String regexp, int flags) { + return Pattern.compile(regexp,flags& ~Pattern.UNICODE_CHARACTER_CLASS); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidFeatureRunner.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidFeatureRunner.java new file mode 100644 index 0000000000..79b99a3cad --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidFeatureRunner.java @@ -0,0 +1,45 @@ +package io.cucumber.junit; + +import cucumber.runtime.model.CucumberFeature; +import gherkin.ast.Feature; +import io.cucumber.junit.AndroidPickleRunner; + +import org.junit.runner.Description; +import org.junit.runner.notification.RunNotifier; +import org.junit.runners.ParentRunner; +import org.junit.runners.model.InitializationError; + +import java.util.List; + +public class AndroidFeatureRunner extends ParentRunner<AndroidPickleRunner> { + + private final List<AndroidPickleRunner> children; + private final CucumberFeature cucumberFeature; + + public AndroidFeatureRunner(Class<?> testClass, CucumberFeature cucumberFeature, List<AndroidPickleRunner> children) throws InitializationError { + super(testClass); + this.cucumberFeature = cucumberFeature; + this.children = children; + } + + @Override + public String getName() { + Feature feature = cucumberFeature.getGherkinFeature().getFeature(); + return feature.getKeyword() + ": " + feature.getName(); + } + + @Override + protected List<AndroidPickleRunner> getChildren() { + return children; + } + + @Override + protected Description describeChild(AndroidPickleRunner child) { + return child.getDescription(); + } + + @Override + protected void runChild(AndroidPickleRunner child, RunNotifier notifier) { + child.run(notifier); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidJunitRuntimeOptionsFactory.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidJunitRuntimeOptionsFactory.java new file mode 100644 index 0000000000..c9c0d76ae3 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidJunitRuntimeOptionsFactory.java @@ -0,0 +1,57 @@ +package io.cucumber.junit; + +import android.content.Context; +import android.util.Log; + +import cucumber.runtime.ClassFinder; +import cucumber.runtime.CucumberException; +import cucumber.runtime.Env; +import cucumber.runtime.io.MultiLoader; +import cucumber.runtime.io.ResourceLoader; +import io.cucumber.core.model.GluePath; +import io.cucumber.core.options.CucumberOptionsAnnotationParser; +import io.cucumber.core.options.EnvironmentOptionsParser; +import io.cucumber.core.options.RuntimeOptions; + +class AndroidJunitRuntimeOptionsFactory { + private static final String TAG = "cucumber-android"; + + static class Options { + final RuntimeOptions runtimeOptions; + final JUnitOptions jUnitOptions; + + Options(RuntimeOptions runtimeOptions, JUnitOptions jUnitOptions) { + this.runtimeOptions = runtimeOptions; + this.jUnitOptions = jUnitOptions; + } + } + + static Options createRuntimeOptions(Context context, ClassFinder classFinder, ClassLoader classLoader) { + for (final Class<?> clazz : classFinder.getDescendants(Object.class, GluePath.parse(context.getPackageName()))) { + if (clazz.isAnnotationPresent(CucumberOptions.class)) { + Log.d(TAG, "Found CucumberOptions in class " + clazz.getName()); + ResourceLoader resourceLoader = new MultiLoader(classLoader); + + RuntimeOptions runtimeOptions = new EnvironmentOptionsParser(resourceLoader) + .parse(Env.INSTANCE) + .build(new CucumberOptionsAnnotationParser(resourceLoader) + .withOptionsProvider(new JUnitCucumberOptionsProvider()) + .parse(clazz) + .build() + ); + + JUnitOptions junitOptions = new JUnitOptionsParser() + .parse(runtimeOptions.getJunitOptions()) + .setStrict(runtimeOptions.isStrict()) + .build(new JUnitOptionsParser() + .parse(clazz) + .build() + ); + + return new Options(runtimeOptions, junitOptions); + } + } + + throw new CucumberException("No CucumberOptions annotation"); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidLogcatReporter.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidLogcatReporter.java new file mode 100644 index 0000000000..ec59079cdb --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidLogcatReporter.java @@ -0,0 +1,86 @@ +package io.cucumber.junit; + +import android.util.Log; +import cucumber.api.PickleStepTestStep; +import cucumber.api.event.ConcurrentEventListener; +import cucumber.api.event.EventHandler; +import cucumber.api.event.EventPublisher; +import cucumber.api.event.TestCaseStarted; +import cucumber.api.event.TestRunFinished; +import cucumber.api.event.TestStepStarted; +import cucumber.runtime.UndefinedStepsTracker; +import cucumber.runtime.formatter.Stats; + +/** + * Logs information about the currently executed statements to androids logcat. + */ +public final class AndroidLogcatReporter implements ConcurrentEventListener { + + /** + * The log tag to be used when logging to logcat. + */ + private final String logTag; + + /** + * The event handler that logs the {@link TestCaseStarted} events. + */ + private final EventHandler<TestCaseStarted> testCaseStartedHandler = new EventHandler<TestCaseStarted>() { + @Override + public void receive(TestCaseStarted event) { + Log.d(logTag, String.format("%s", event.testCase.getName())); + } + }; + + private final Stats stats; + + private final UndefinedStepsTracker undefinedStepsTracker; + + /** + * The event handler that logs the {@link TestStepStarted} events. + */ + private final EventHandler<TestStepStarted> testStepStartedHandler = new EventHandler<TestStepStarted>() { + @Override + public void receive(TestStepStarted event) { + if (event.testStep instanceof PickleStepTestStep) { + PickleStepTestStep testStep = (PickleStepTestStep) event.testStep; + Log.d(logTag, String.format("%s", testStep.getStepText())); + } + } + }; + + /** + * The event handler that logs the {@link TestRunFinished} events. + */ + private EventHandler<TestRunFinished> runFinishHandler = new EventHandler<TestRunFinished>() { + + @Override + public void receive(TestRunFinished event) { + for (final Throwable throwable : stats.getErrors()) { + Log.e(logTag, throwable.toString()); + } + + for (final String snippet : undefinedStepsTracker.getSnippets()) { + Log.w(logTag, snippet); + } + } + }; + + /** + * Creates a new instance for the given parameters. + * + * @param undefinedStepsTracker + * @param logTag the tag to use for logging to logcat + */ + public AndroidLogcatReporter(Stats stats, UndefinedStepsTracker undefinedStepsTracker, final String logTag) { + this.stats = stats; + this.undefinedStepsTracker = undefinedStepsTracker; + this.logTag = logTag; + } + + @Override + public void setEventPublisher(final EventPublisher publisher) { + publisher.registerHandlerFor(TestCaseStarted.class, testCaseStartedHandler); + publisher.registerHandlerFor(TestStepStarted.class, testStepStartedHandler); + publisher.registerHandlerFor(TestRunFinished.class, runFinishHandler); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidPickleRunner.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidPickleRunner.java new file mode 100644 index 0000000000..32eb29fab2 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidPickleRunner.java @@ -0,0 +1,53 @@ +package io.cucumber.junit; + +import cucumber.runner.Runner; +import cucumber.runtime.model.CucumberFeature; +import gherkin.events.PickleEvent; +import gherkin.pickles.PickleStep; + +import org.junit.runner.Description; +import org.junit.runner.notification.RunNotifier; + +public class AndroidPickleRunner implements PickleRunners.PickleRunner { + + private final Runner runner; + private final PickleEvent pickleEvent; + private final JUnitOptions jUnitOptions; + private Description description; + private final CucumberFeature feature; + private String scenarioName; + + public AndroidPickleRunner(Runner runner, PickleEvent pickleEvent, JUnitOptions jUnitOptions, CucumberFeature feature, String scenarioName) { + this.runner = runner; + this.pickleEvent = pickleEvent; + this.jUnitOptions = jUnitOptions; + this.feature = feature; + this.scenarioName = scenarioName; + } + + @Override + public Description getDescription() { + if (description == null) + { + description = makeDescriptionFromPickle(); + } + return description; + } + + @Override + public Description describeChild(PickleStep step) { + throw new UnsupportedOperationException("This pickle runner does not wish to describe its children"); + } + + @Override + public void run(final RunNotifier notifier) { + JUnitReporter jUnitReporter = new JUnitReporter(runner.getBus(), jUnitOptions); + jUnitReporter.startExecutionUnit(this, notifier); + runner.runPickle(pickleEvent); + jUnitReporter.finishExecutionUnit(); + } + + private Description makeDescriptionFromPickle() { + return Description.createTestDescription(feature.getName(), scenarioName, new PickleRunners.PickleId(pickleEvent)); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResource.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResource.java new file mode 100644 index 0000000000..b6060966fc --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResource.java @@ -0,0 +1,53 @@ +package io.cucumber.junit; + +import android.content.Context; +import android.content.res.AssetManager; +import cucumber.runtime.io.Resource; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; + +/** + * Android specific implementation of {@link cucumber.runtime.io.Resource} which is apple + * to create {@link InputStream}s for android assets. + */ +public final class AndroidResource implements Resource { + + /** + * The {@link Context} to get the {@link InputStream} from + */ + private final Context context; + + /** + * The path of the resource. + */ + private final URI path; + private final String pathInAssets; + + /** + * Creates a new instance for the given parameters. + * @param context the {@link Context} to create the {@link InputStream} from + * @param path the path to the resource + */ + AndroidResource(final Context context, final URI path) { + this.context = context; + this.path = path; + this.pathInAssets = path.getSchemeSpecificPart(); + } + + @Override + public URI getPath() { + return path; + } + + @Override + public InputStream getInputStream() throws IOException { + return context.getAssets().open(pathInAssets, AssetManager.ACCESS_UNKNOWN); + } + + @Override + public String toString() { + return "AndroidResource (" + pathInAssets + ")"; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResourceLoader.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResourceLoader.java new file mode 100644 index 0000000000..5b1322432a --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/AndroidResourceLoader.java @@ -0,0 +1,68 @@ +package io.cucumber.junit; + +import android.content.Context; +import android.content.res.AssetManager; +import cucumber.runtime.CucumberException; +import cucumber.runtime.io.Resource; +import cucumber.runtime.io.ResourceLoader; + +import java.io.IOException; +import java.net.URI; +import java.util.ArrayList; +import java.util.List; + +/** + * Android specific implementation of {@link cucumber.runtime.io.ResourceLoader} which loads non-class resources such as .feature files. + */ +final class AndroidResourceLoader implements ResourceLoader { + + /** + * The format of the resource path. + */ + private static final String RESOURCE_PATH_FORMAT = "%s/%s"; + + /** + * The {@link Context} to get the resources from. + */ + private final Context context; + + /** + * Creates a new instance for the given parameter. + * + * @param context the {@link Context} to get resources from + */ + AndroidResourceLoader(final Context context) { + this.context = context; + } + + @Override + public Iterable<Resource> resources(final URI path, final String suffix) { + try { + final List<Resource> resources = new ArrayList<>(); + final AssetManager assetManager = context.getAssets(); + addResourceRecursive(resources, assetManager, path, suffix); + return resources; + } catch (final IOException e) { + throw new CucumberException("Error loading resources from " + path + " with suffix " + suffix, e); + } + } + + private void addResourceRecursive(final List<Resource> resources, + final AssetManager assetManager, + final URI path, + final String suffix) throws IOException { + String schemeSpecificPart = path.getSchemeSpecificPart(); + if (schemeSpecificPart.endsWith(suffix)) { + resources.add(new AndroidResource(context, path)); + return; + } + + String[] list = assetManager.list(schemeSpecificPart); + if (list != null) { + for (String name : list) { + String subPath = String.format(RESOURCE_PATH_FORMAT, path.toString(), name); + addResourceRecursive(resources, assetManager, URI.create(subPath), suffix); + } + } + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/Arguments.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/Arguments.java new file mode 100644 index 0000000000..339969ef6e --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/Arguments.java @@ -0,0 +1,195 @@ +package io.cucumber.junit; + +import android.os.Bundle; + +/** + * Holds instrumentation arguments. + */ +public class Arguments { + + private static final String VALUE_SEPARATOR = "--"; + + /** + * Keys of supported arguments. + */ + static class KEY { + static final String COUNT_ENABLED = "count"; + static final String DEBUG_ENABLED = "debug"; + static final String COVERAGE_ENABLED = "coverage"; + static final String COVERAGE_DATA_FILE_PATH = "coverageFile"; + } + + /** + * Default values of supported arguments. + */ + static class DEFAULT { + static final String COVERAGE_DATA_FILE_PATH = "coverage.ec"; + } + + private final boolean countEnabled; + private final boolean debugEnabled; + private final boolean coverageEnabled; + private final String coverageDataFilePath; + private final String cucumberOptions; + + /** + * Constructs a new instance with arguments extracted from the given {@code bundle}. + * + * @param bundle the {@link Bundle} to extract the arguments from + */ + public Arguments(final Bundle bundle) { + countEnabled = getBooleanArgument(bundle, KEY.COUNT_ENABLED); + debugEnabled = getBooleanArgument(bundle, KEY.DEBUG_ENABLED); + coverageEnabled = getBooleanArgument(bundle, KEY.COVERAGE_ENABLED); + coverageDataFilePath = getStringArgument(bundle, KEY.COVERAGE_DATA_FILE_PATH, DEFAULT.COVERAGE_DATA_FILE_PATH); + cucumberOptions = getCucumberOptionsString(bundle); + } + + /** + * @return whether tests should not be executed, but just being counted + */ + public boolean isCountEnabled() { + return countEnabled; + } + + /** + * @return whether debugging is enabled or not + */ + public boolean isDebugEnabled() { + return debugEnabled; + } + + /** + * @return the path to the coverage data file, defaults to "coverage.ec" + */ + public String coverageDataFilePath() { + return coverageDataFilePath; + } + + /** + * @return whether coverage is enabled or not + */ + public boolean isCoverageEnabled() { + return coverageEnabled; + } + + /** + * @return the cucumber options string + */ + public String getCucumberOptions() { + return cucumberOptions; + } + + /** + * Extracts a boolean value from the bundle which is stored as string. + * Given the string value is "true" the boolean value will be {@code true}, + * given the string value is "false the boolean value will be {@code false}. + * The case in the string is ignored. In case no value is found this method + * returns false. In case the given {@code bundle} is {@code null} {@code false} + * will be returned. + * + * @param bundle the {@link Bundle} to get the value from + * @param key the key to get the value for + * @return the boolean representation of the string value found for the given key, + * or false in case no value was found + */ + private boolean getBooleanArgument(final Bundle bundle, final String key) { + + if (bundle == null) { + return false; + } + + final String tagString = bundle.getString(key); + return tagString != null && Boolean.parseBoolean(tagString); + } + + /** + * Extracts a string value from the bundle, gracefully falling back to the provided {@code defaultValue} + * in case no value could be found for the given {@code key} or the {@code bundle} was {@code null}. + * + * @param bundle the {@link Bundle} to get the value from + * @param key the key to get the value for + * @param defaultValue the default value to take in case no value could be found or the {@code bundle} was {@code null} + * @return the string value for the given {@code key} + */ + private String getStringArgument(final Bundle bundle, final String key, final String defaultValue) { + if (bundle == null) { + return defaultValue; + } + return bundle.getString(key, defaultValue); + } + + /** + * Adds the given {@code optionKey} and its {@code optionValue} tot he given string buffer. This method will split + * potential multiple option values separated by {@link Arguments#VALUE_SEPARATOR} into a space + * separated list of those values. + */ + private void appendOption(final StringBuilder sb, final String optionKey, final String optionValue) { + for (final String value : optionValue.split(VALUE_SEPARATOR)) { + sb.append(sb.length() == 0 || optionKey.isEmpty() ? "" : " ").append(optionKey).append(optionValue.isEmpty() ? "" : " " + value); + } + } + + /** + * Returns a Cucumber options compatible string based on the argument extras found. + * <p/> + * The bundle <em>cannot</em> contain multiple entries for the same key, + * however certain Cucumber options can be passed multiple times (e.g. + * {@code --tags}). The solution is to pass values separated by + * {@link Arguments#VALUE_SEPARATOR} which will result + * in multiple {@code --key value} pairs being created. + * + * @param bundle the {@link Bundle} to get the values from + * @return the cucumber options string + */ + private String getCucumberOptionsString(final Bundle bundle) { + + if (bundle == null) { + return ""; + } + + final String cucumberOptions = bundle.getString("cucumberOptions"); + if (cucumberOptions != null) { + return cucumberOptions; + } + + final StringBuilder sb = new StringBuilder(); + String features = ""; + + for (final String key : bundle.keySet()) { + if ("glue".equals(key)) { + appendOption(sb, "--glue", bundle.getString(key)); + } else if ("format".equals(key)) { + appendOption(sb, "--format", bundle.getString(key)); + } else if ("plugin".equals(key)) { + appendOption(sb, "--plugin", bundle.getString(key)); + } else if ("tags".equals(key)) { + appendOption(sb, "--tags", bundle.getString(key)); + } else if ("name".equals(key)) { + appendOption(sb, "--name", bundle.getString(key)); + } else if ("dryRun".equals(key) && getBooleanArgument(bundle, key)) { + appendOption(sb, "--dry-run", ""); + } else if ("log".equals(key) && getBooleanArgument(bundle, key)) { + appendOption(sb, "--dry-run", ""); + } else if ("noDryRun".equals(key) && getBooleanArgument(bundle, key)) { + appendOption(sb, "--no-dry-run", ""); + } else if ("monochrome".equals(key) && getBooleanArgument(bundle, key)) { + appendOption(sb, "--monochrome", ""); + } else if ("noMonochrome".equals(key) && getBooleanArgument(bundle, key)) { + appendOption(sb, "--no-monochrome", ""); + } else if ("strict".equals(key) && getBooleanArgument(bundle, key)) { + appendOption(sb, "--strict", ""); + } else if ("noStrict".equals(key) && getBooleanArgument(bundle, key)) { + appendOption(sb, "--no-strict", ""); + } else if ("snippets".equals(key)) { + appendOption(sb, "--snippets", bundle.getString(key)); + } else if ("features".equals(key)) { + features = bundle.getString(key); + } + } + // Even though not strictly required, wait until everything else + // has been added before adding any feature references + appendOption(sb, "", features); + return sb.toString(); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CoverageDumper.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CoverageDumper.java new file mode 100644 index 0000000000..2b2e8caa80 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CoverageDumper.java @@ -0,0 +1,104 @@ +package io.cucumber.junit; + +import android.app.Instrumentation; +import android.os.Bundle; +import android.util.Log; + +import java.io.File; +import java.lang.reflect.InvocationTargetException; +import java.lang.reflect.Method; + +/** + * Dumps coverage data into a file. + */ +public class CoverageDumper { + + /** + * The key for the result bundle value which will contain the path to file containing the coverage data. + */ + private static final String RESULT_KEY_COVERAGE_PATH = "coverageFilePath"; + + /** + * The string format to be appended to the result stream in case coverage data could be dumped successfully. + */ + private static final String RESULT_STREAM_SUCCESS_OUTPUT_FORMAT = "Generated code coverage data to %s"; + + /** + * The string to be logged with logcat in case coverage data could not be dumped successfully. + */ + private static final String LOG_ERROR_OUTPUT = "Failed to generate coverage."; + + /** + * The string to be appended to the result stream in case coverage data could not be dumped successfully. + */ + private static final String RESULT_STREAM_ERROR_OUTPUT = "Error: Failed to generate coverage. Check logcat for details."; + + /** + * The implementation of the code coverage tool. + * Currently known implementations are emma and jacoco. + */ + private static final String IMPLEMENTATION_CLASS = "com.vladium.emma.rt.RT"; + + /** + * The method to call for dumping the coverage data. + */ + private static final String IMPLEMENTATION_METHOD = "dumpCoverageData"; + + /** + * The arguments to work with. + */ + private final Arguments arguments; + + /** + * Creates a new instance for the given arguments. + * + * @param arguments the arguments to work with + */ + public CoverageDumper(final Arguments arguments) { + this.arguments = arguments; + } + + /** + * Dumps the coverage data into the given file, if code coverage is enabled. + * + * @param bundle the {@link Bundle} to put coverage information into + */ + public void requestDump(final Bundle bundle) { + + if (!arguments.isCoverageEnabled()) { + return; + } + + final String coverageDateFilePath = arguments.coverageDataFilePath(); + final File coverageFile = new File(coverageDateFilePath); + + try { + final Class dumperClass = Class.forName(IMPLEMENTATION_CLASS); + final Method dumperMethod = dumperClass.getMethod(IMPLEMENTATION_METHOD, coverageFile.getClass(), boolean.class, boolean.class); + dumperMethod.invoke(null, coverageFile, false, false); + + bundle.putString(RESULT_KEY_COVERAGE_PATH, coverageDateFilePath); + appendNewLineToResultStream(bundle, String.format(RESULT_STREAM_SUCCESS_OUTPUT_FORMAT, coverageDateFilePath)); + } catch (final ClassNotFoundException e) { + reportError(bundle, e); + } catch (final SecurityException e) { + reportError(bundle, e); + } catch (final NoSuchMethodException e) { + reportError(bundle, e); + } catch (final IllegalAccessException e) { + reportError(bundle, e); + } catch (final InvocationTargetException e) { + reportError(bundle, e); + } + } + + private void reportError(final Bundle results, final Exception e) { + Log.e(CucumberJUnitRunner.TAG, LOG_ERROR_OUTPUT, e); + appendNewLineToResultStream(results, RESULT_STREAM_ERROR_OUTPUT); + } + + private void appendNewLineToResultStream(final Bundle results, final String message) { + final String currentStream = results.getString(Instrumentation.REPORT_KEY_STREAMRESULT); + results.putString(Instrumentation.REPORT_KEY_STREAMRESULT, currentStream + "\n" + message); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberAndroidJUnitArguments.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberAndroidJUnitArguments.java new file mode 100644 index 0000000000..54c403cf3a --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberAndroidJUnitArguments.java @@ -0,0 +1,95 @@ +package io.cucumber.junit; + +import android.os.Bundle; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.test.runner.AndroidJUnitRunner; + +/** + * This class is responsible for preparing bundle to{@link AndroidJUnitRunner} + * for cucumber tests. It prepares bundle for running tests from Android Tests Orchestrator + * <p> + * Runner argument are linked to the {@link androidx.test.internal.runner.RunnerArgs} + */ +public class CucumberAndroidJUnitArguments { + + /** + * External API argument keys. + */ + public static class Args { + + /** + * User default android junit runner. public interface + */ + public static final String USE_DEFAULT_ANDROID_RUNNER = "cucumberUseAndroidJUnitRunner"; + } + + /** + * Cucumber internal use argument keys. + */ + static class InternalCucumberAndroidArgs { + + static final String CUCUMBER_ANDROID_TEST_CLASS = "cucumberAndroidTestClass"; + } + + /** + * Runner argument are linked to the {@link androidx.test.internal.runner.RunnerArgs}. + */ + private static class AndroidJunitRunnerArgs { + /** + * {@link androidx.test.internal.runner.RunnerArgs#ARGUMENT_RUNNER_BUILDER} + */ + private static final String ARGUMENT_ORCHESTRATOR_RUNNER_BUILDER = "runnerBuilder"; + + /** + * {@link androidx.test.internal.runner.RunnerArgs#ARGUMENT_TEST_CLASS} + */ + private static final String ARGUMENT_ORCHESTRATOR_CLASS = "class"; + } + + private static final String TRUE = Boolean.TRUE.toString(); + private static final String FALSE = Boolean.FALSE.toString(); + + @NonNull + private final Bundle originalArgs; + + @Nullable + private Bundle processedArgs; + + public CucumberAndroidJUnitArguments(@NonNull Bundle arguments) { + this.originalArgs = new Bundle(arguments); + } + + public Bundle processArgs() { + processedArgs = new Bundle(originalArgs); + + if (TRUE.equals(originalArgs.getString(Args.USE_DEFAULT_ANDROID_RUNNER, FALSE))) { + return processedArgs; + } + + processedArgs.putString(AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_RUNNER_BUILDER, CucumberJUnitRunnerBuilder.class.getName()); + + String testClass = originalArgs.getString(AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_CLASS); + if (testClass != null && !testClass.isEmpty()) { + + //if this runner is executed for single class (e.g. from orchestrator or spoon), we set + //special option to let CucumberJUnitRunner handle this + processedArgs.putString(InternalCucumberAndroidArgs.CUCUMBER_ANDROID_TEST_CLASS, testClass); + } + + //there is no need to scan all classes - we can fake this execution to be for single class + //because we delegate test execution to CucumberJUnitRunner + processedArgs.putString(AndroidJunitRunnerArgs.ARGUMENT_ORCHESTRATOR_CLASS, CucumberJUnitRunnerBuilder.class.getName()); + + return processedArgs; + } + + @NonNull + Bundle getRunnerArgs() { + if (processedArgs == null) { + processedArgs = processArgs(); + } + return processedArgs; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberArgumentsProvider.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberArgumentsProvider.java new file mode 100644 index 0000000000..3ab3a8396e --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberArgumentsProvider.java @@ -0,0 +1,8 @@ +package io.cucumber.junit; + +import androidx.annotation.NonNull; + +public interface CucumberArgumentsProvider { + @NonNull + CucumberAndroidJUnitArguments getArguments(); +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunner.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunner.java new file mode 100644 index 0000000000..edce3643a7 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunner.java @@ -0,0 +1,295 @@ +package io.cucumber.junit; + +import android.app.Instrumentation; +import android.content.Context; +import android.os.Bundle; +import android.util.Log; + +import androidx.test.platform.app.InstrumentationRegistry; + +import org.junit.runner.Description; +import org.junit.runner.manipulation.Filterable; +import org.junit.runner.notification.RunNotifier; +import org.junit.runners.ParentRunner; +import org.junit.runners.model.InitializationError; +import org.junit.runners.model.Statement; + +import java.io.IOException; +import java.util.ArrayList; +import java.util.Collection; +import java.util.HashSet; +import java.util.List; + +import cucumber.api.event.TestRunFinished; +import cucumber.api.event.TestRunStarted; +import cucumber.runner.EventBus; +import cucumber.runner.Runner; +import cucumber.runner.ThreadLocalRunnerSupplier; +import cucumber.runner.TimeService; +import cucumber.runner.TimeServiceEventBus; +import cucumber.runtime.ClassFinder; +import cucumber.runtime.CucumberException; +import cucumber.runtime.FeaturePathFeatureSupplier; +import cucumber.runtime.FeatureSupplier; +import cucumber.runtime.UndefinedStepsTracker; +import cucumber.runtime.Utils; +import cucumber.runtime.filter.Filters; +import cucumber.runtime.formatter.PluginFactory; +import cucumber.runtime.formatter.Plugins; +import cucumber.runtime.formatter.Stats; +import cucumber.runtime.java.AndroidJavaBackendFactory; +import cucumber.runtime.model.CucumberFeature; +import cucumber.runtime.model.FeatureLoader; +import dalvik.system.DexFile; +import gherkin.ast.Examples; +import gherkin.ast.Feature; +import gherkin.ast.ScenarioDefinition; +import gherkin.ast.ScenarioOutline; +import gherkin.ast.TableRow; +import gherkin.events.PickleEvent; +import io.cucumber.core.options.RuntimeOptions; + +public class CucumberJUnitRunner extends ParentRunner<AndroidFeatureRunner> implements Filterable { + + + /** + * The logcat tag to log all cucumber related information to. + */ + static final String TAG = "cucumber-android"; + + /** + * The system property name of the cucumber options. + */ + private static final String CUCUMBER_OPTIONS_SYSTEM_PROPERTY = "cucumber.options"; + + /** + * The {@link RuntimeOptions} to get the {@link CucumberFeature}s from. + */ + private List<AndroidFeatureRunner> children = new ArrayList<>(); + + private EventBus bus; + + public CucumberJUnitRunner(Class<?> testClass) throws InitializationError { + super(testClass); + Instrumentation instrumentation = InstrumentationRegistry.getInstrumentation(); + + Bundle runnerArguments = getRunnerBundle(instrumentation); + Arguments arguments = new Arguments(runnerArguments); + + trySetCucumberOptionsToSystemProperties(arguments); + Context context = instrumentation.getContext(); + ClassLoader classLoader = context.getClassLoader(); + ClassFinder classFinder = createDexClassFinder(context); + AndroidJunitRuntimeOptionsFactory.Options options = AndroidJunitRuntimeOptionsFactory.createRuntimeOptions(context, classFinder, classLoader); + + bus = new TimeServiceEventBus(TimeService.SYSTEM); + + Runner runner = new ThreadLocalRunnerSupplier(options.runtimeOptions, bus, AndroidJavaBackendFactory.createBackend(options.runtimeOptions, classFinder)).get(); + FeatureLoader featureLoader = new FeatureLoader(new AndroidResourceLoader(context)); + FeatureSupplier featureSupplier = new FeaturePathFeatureSupplier(featureLoader, options.runtimeOptions); + Filters filters = new Filters(options.runtimeOptions); + UndefinedStepsTracker undefinedStepsTracker = new UndefinedStepsTracker(); + undefinedStepsTracker.setEventPublisher(bus); + Stats stats = new Stats(); + stats.setEventPublisher(bus); + + Plugins plugins = new Plugins(classLoader, new PluginFactory(), options.runtimeOptions); + plugins.addPlugin(new AndroidLogcatReporter(stats, undefinedStepsTracker, TAG)); + //must be after registering plugins + if (options.runtimeOptions.isMultiThreaded()) { + plugins.setSerialEventBusOnEventListenerPlugins(bus); + } else { + plugins.setEventBusOnEventListenerPlugins(bus); + } + + //check if this is for single scenario + String testClassNameFromRunner = runnerArguments.getString(CucumberAndroidJUnitArguments.InternalCucumberAndroidArgs.CUCUMBER_ANDROID_TEST_CLASS); + String requestedFeatureName = null; + String requestedScenarioName = null; + if (testClassNameFromRunner != null) { + String[] split = testClassNameFromRunner.split("#"); + if (split.length > 1) { + requestedFeatureName = split[0]; + requestedScenarioName = split[1]; + } else { + Log.e(TAG, "CucumberJUnitRunner: invalid argument '" + CucumberAndroidJUnitArguments.InternalCucumberAndroidArgs.CUCUMBER_ANDROID_TEST_CLASS + "' = '" + testClassNameFromRunner + "'"); + } + } + // Start the run before reading the features. + // Allows the test source read events to be broadcast properly + List<CucumberFeature> features = featureSupplier.get(); + Collection<String> featuresNames = new HashSet<>(features.size()); + StringBuilder duplicateScenariosNameMessage = new StringBuilder(); + bus.send(new TestRunStarted(bus.getTime(), bus.getTimeMillis())); + + + for (CucumberFeature feature : features) { + feature.sendTestSourceRead(bus); + String featureName = feature.getName(); + if (requestedFeatureName != null && !requestedFeatureName.equals(featureName)) { + continue; + } + List<PickleEvent> pickles = feature.getPickles(); + List<AndroidPickleRunner> pickleRunners = new ArrayList<>(pickles.size()); + Collection<String> pickleNames = new HashSet<>(pickles.size()); + for (PickleEvent pickleEvent : pickles) { + + if (filters.matchesFilters(pickleEvent)) { + String currentScenarioName = getScenarioName(pickleEvent, feature.getGherkinFeature().getFeature()); + if (pickleNames.contains(currentScenarioName)) { + // in case of scenario name duplication in single feature: + addDuplicateScenarioMessage(duplicateScenariosNameMessage, featureName, currentScenarioName); + } + pickleNames.add(currentScenarioName); + + if (requestedScenarioName != null) { + if (requestedScenarioName.equals(currentScenarioName)) { + AndroidPickleRunner pickleRunner = new AndroidPickleRunner(runner, pickleEvent, options.jUnitOptions, feature, currentScenarioName); + pickleRunners.add(pickleRunner); + children.add(new AndroidFeatureRunner(testClass, feature, pickleRunners)); + throwErrorIfDuplicateScenarios(duplicateScenariosNameMessage); + return; + } + } else { + AndroidPickleRunner pickleRunner = new AndroidPickleRunner(runner, pickleEvent, options.jUnitOptions, feature, currentScenarioName); + pickleRunners.add(pickleRunner); + } + } + } + addFeatureIfHasChildren(testClass, featuresNames, duplicateScenariosNameMessage, feature, featureName, pickleRunners); + } + throwErrorIfDuplicateScenarios(duplicateScenariosNameMessage); + } + + private Bundle getRunnerBundle(Instrumentation instrumentation) throws InitializationError { + if (!(instrumentation instanceof CucumberArgumentsProvider)) { + Log.e(TAG, "Runner must implement CucumberArgumentsProvider"); + throw new InitializationError("Use runner that implements CucumberArgumentsProvider class."); + } + + return ((CucumberArgumentsProvider) instrumentation).getArguments().getRunnerArgs(); + } + + private void addFeatureIfHasChildren(Class<?> testClass, Collection<String> featuresNames, StringBuilder duplicateScenariosNameMessage, + CucumberFeature feature, String featureName, List<AndroidPickleRunner> pickleRunners) throws InitializationError { + if (!pickleRunners.isEmpty()) { + if (featuresNames.contains(featureName)) { + // in case of feature name duplication: + addDuplicateFeatureMessage(duplicateScenariosNameMessage, featureName); + } + featuresNames.add(featureName); + children.add(new AndroidFeatureRunner(testClass, feature, pickleRunners)); + } + } + + private static void throwErrorIfDuplicateScenarios(CharSequence duplicateScenariosNameMessage) throws InitializationError { + if (duplicateScenariosNameMessage.length() > 0) { + InitializationError error = new InitializationError(duplicateScenariosNameMessage.toString()); + Log.e(TAG, "CucumberJUnitRunner: ", error); + throw error; + } + } + + private static void addDuplicateFeatureMessage(StringBuilder duplicateScenariosNameMessage, String featureName) { + duplicateScenariosNameMessage + .append('\n') + .append("Duplicate feature name '") + .append(featureName) + .append("'"); + } + + private static void addDuplicateScenarioMessage(StringBuilder duplicateScenariosNameMessage, String featureName, String currentScenarioName) { + duplicateScenariosNameMessage.append('\n') + .append("Duplicate scenario name '") + .append(currentScenarioName) + .append("' in feature '") + .append(featureName) + .append("'"); + } + + private static String getScenarioName(PickleEvent pickleEvent, Feature feature) { + int exampleNumber = findExampleNumber(pickleEvent, feature); + String pickleName = pickleEvent.pickle.getName(); + if (exampleNumber > 0) { + // if this is example we always add example number in case of sharding + return Utils.getUniqueTestNameForScenarioExample(pickleName, exampleNumber); + } + return pickleName; + } + + + private static int findExampleNumber(PickleEvent pickleEvent, Feature feature) { + int pickleLine = getLine(pickleEvent); + for (ScenarioDefinition definition : feature.getChildren()) { + if (definition instanceof ScenarioOutline) { + List<Examples> examples = ((ScenarioOutline) definition).getExamples(); + int index = 0; + for (Examples example : examples) { + List<TableRow> tableBody = example.getTableBody(); + for (TableRow row : tableBody) { + if (row.getLocation().getLine() == pickleLine) { + return index + 1; + } + index++; + } + } + } + } + return 0; + } + + + private static int getLine(PickleEvent pickleEvent) { + return pickleEvent.pickle.getLocations().get(0).getLine(); + } + + + @Override + protected List<AndroidFeatureRunner> getChildren() { + return children; + } + + @Override + protected Description describeChild(AndroidFeatureRunner child) { + return child.getDescription(); + } + + @Override + protected void runChild(AndroidFeatureRunner child, RunNotifier notifier) { + child.run(notifier); + } + + + private static void trySetCucumberOptionsToSystemProperties(final Arguments arguments) { + final String cucumberOptions = arguments.getCucumberOptions(); + if (!cucumberOptions.isEmpty()) { + Log.d(TAG, "Setting cucumber.options from arguments: '" + cucumberOptions + "'"); + System.setProperty(CUCUMBER_OPTIONS_SYSTEM_PROPERTY, cucumberOptions); + } + } + + private static ClassFinder createDexClassFinder(final Context context) { + final String apkPath = context.getPackageCodePath(); + return new DexClassFinder(newDexFile(apkPath)); + } + + private static DexFile newDexFile(final String apkPath) { + try { + return new DexFile(apkPath); + } catch (final IOException e) { + throw new CucumberException("Failed to open " + apkPath); + } + } + + @Override + protected Statement childrenInvoker(RunNotifier notifier) { + final Statement features = super.childrenInvoker(notifier); + return new Statement() { + @Override + public void evaluate() throws Throwable { + features.evaluate(); + bus.send(new TestRunFinished(bus.getTime(), bus.getTimeMillis())); + } + }; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunnerBuilder.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunnerBuilder.java new file mode 100644 index 0000000000..4fcb8da7f3 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/CucumberJUnitRunnerBuilder.java @@ -0,0 +1,16 @@ +package io.cucumber.junit; + +import org.junit.runner.Runner; +import org.junit.runners.model.InitializationError; +import org.junit.runners.model.RunnerBuilder; + +public class CucumberJUnitRunnerBuilder extends RunnerBuilder { + @Override + public Runner runnerForClass(Class<?> testClass) throws InitializationError { + if (testClass.equals(getClass())) { + return new CucumberJUnitRunner(testClass); + } + + return null; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DebuggerWaiter.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DebuggerWaiter.java new file mode 100644 index 0000000000..94bfe644d3 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DebuggerWaiter.java @@ -0,0 +1,32 @@ +package io.cucumber.junit; + +import android.os.Debug; + +/** + * Waits for the debugger, if configured through the given {@link Arguments}. + */ +public final class DebuggerWaiter { + + /** + * The arguments to work with. + */ + private final Arguments arguments; + + /** + * Creates a new instance for the given arguments. + * + * @param arguments the {@link Arguments} which specify whether waiting is required. + */ + public DebuggerWaiter(final Arguments arguments) { + this.arguments = arguments; + } + + /** + * Waits until a debugger is attached, if configured. + */ + public void requestWaitForDebugger() { + if (arguments.isDebugEnabled()) { + Debug.waitForDebugger(); + } + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DexClassFinder.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DexClassFinder.java new file mode 100644 index 0000000000..39a8490c32 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/DexClassFinder.java @@ -0,0 +1,123 @@ +package io.cucumber.junit; + +import java.net.URI; +import java.util.ArrayList; +import java.util.Collection; +import java.util.Enumeration; +import java.util.List; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import cucumber.runtime.ClassFinder; +import dalvik.system.DexFile; +import io.cucumber.core.model.Classpath; + +/** + * Android specific implementation of {@link ClassFinder} which loads classes contained in the provided {@link DexFile}. + */ +final class DexClassFinder implements ClassFinder { + + /** + * Symbol name of the manifest class. + */ + private static final String MANIFEST_CLASS_NAME = "Manifest"; + + /** + * Symbol name of the resource class. + */ + private static final String RESOURCE_CLASS_NAME = "R"; + + /** + * Symbol name prefix of any inner class of the resource class. + */ + private static final String RESOURCE_INNER_CLASS_NAME_PREFIX = "R$"; + + /** + * The file name separator. + */ + private static final String FILE_NAME_SEPARATOR = "."; + + /** + * The class loader to actually load the classes specified by the {@link DexFile}. + */ + private static final ClassLoader CLASS_LOADER = DexClassFinder.class.getClassLoader(); + + /** + * The "symbol" representing the default package. + */ + private static final String DEFAULT_PACKAGE = ""; + private static final Pattern PATH_SEPARATOR_PATTERN = Pattern.compile("/", Pattern.LITERAL); + /** + * The {@link DexFile} to load classes from + */ + private final DexFile dexFile; + + /** + * Creates a new instance for the given parameter. + * + * @param dexFile the {@link DexFile} to load classes from + */ + DexClassFinder(final DexFile dexFile) { + this.dexFile = dexFile; + } + + @Override + public <T> Collection<Class<? extends T>> getDescendants(final Class<T> parentType, final URI packageName) { + final List<Class<? extends T>> result = new ArrayList<Class<? extends T>>(); + String packageNameString = PATH_SEPARATOR_PATTERN.matcher(Classpath.resourceName(packageName)).replaceAll(Matcher.quoteReplacement(".")); + final Enumeration<String> entries = dexFile.entries(); + while (entries.hasMoreElements()) { + final String className = entries.nextElement(); + if (isInPackage(className, packageNameString) && !isGenerated(className)) { + try { + final Class<? extends T> clazz = loadClass(className); + if (!parentType.equals(clazz) && parentType.isAssignableFrom(clazz) && canGetMethods(clazz)) { + result.add(clazz.asSubclass(parentType)); + } + } catch (ClassNotFoundException ignored) { + //ignore, class cannot be loaded - same as in ResourceLoaderClassFinder + } catch (NoClassDefFoundError ignored){ + //ignore, class cannot be loaded - same as in ResourceLoaderClassFinder + } + } + } + return result; + } + + @Override + public <T> Class<? extends T> loadClass(final String className) throws ClassNotFoundException { + return (Class<? extends T>) Class.forName(className, false, CLASS_LOADER); + } + + private boolean isInPackage(final String className, final String packageName) { + final int lastDotIndex = className.lastIndexOf(FILE_NAME_SEPARATOR); + final String classPackage = lastDotIndex == -1 ? DEFAULT_PACKAGE : className.substring(0, lastDotIndex); + return classPackage.startsWith(packageName); + } + + private static boolean isGenerated(final String className) { + return isAndroidGenerated(className) || isKotlinGenerated(className); + } + + private static boolean isAndroidGenerated(final String className) { + final int lastDotIndex = className.lastIndexOf(FILE_NAME_SEPARATOR); + final String shortName = lastDotIndex == -1 ? className : className.substring(lastDotIndex + 1); + return shortName.equals(MANIFEST_CLASS_NAME) || shortName.equals(RESOURCE_CLASS_NAME) || shortName.startsWith(RESOURCE_INNER_CLASS_NAME_PREFIX); + } + + private static boolean isKotlinGenerated(String className) { + return className.contains("$$inlined$"); + } + + /** + * On older apis obtaining all methods via {@link Class#getMethods()} can lead to NoClassDefFoundError if such methods has parameters with unavailable on this api classes such as {@link java.util.function.Function} + */ + private static boolean canGetMethods(Class<?> clazz) { + try { + clazz.getMethods(); + } catch (NoClassDefFoundError ignored){ + return false; + } + return true; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/FeatureCompiler.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/FeatureCompiler.java new file mode 100644 index 0000000000..aafac2830e --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/FeatureCompiler.java @@ -0,0 +1,33 @@ +package io.cucumber.junit; + +import cucumber.runtime.filter.Filters; +import cucumber.runtime.model.CucumberFeature; +import gherkin.events.PickleEvent; + +import java.util.ArrayList; +import java.util.List; + +/** + * Utility class to count scenarios, including outlined. + */ +final class FeatureCompiler { + + /** + * Compilers the given {@code cucumberFeatures} to {@link PickleEvent}s. + * + * @param cucumberFeatures the list of {@link CucumberFeature} to compile + * @return the compiled pickles in {@link PickleEvent}s + */ + static List<PickleEvent> compile(final List<CucumberFeature> cucumberFeatures, final Filters filters) { + List<PickleEvent> pickles = new ArrayList<PickleEvent>(); + for (final CucumberFeature feature : cucumberFeatures) { + for (final PickleEvent pickleEvent : feature.getPickles()) { + if (filters.matchesFilters(pickleEvent)) { + pickles.add(pickleEvent); + } + } + } + return pickles; + } + +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/MissingStepDefinitionError.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/MissingStepDefinitionError.java new file mode 100644 index 0000000000..c5f07377b5 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/java/io/cucumber/junit/MissingStepDefinitionError.java @@ -0,0 +1,16 @@ +package io.cucumber.junit; + +/** + * Indicates that there was a missing step in the execution of the scenario lifecycle. + */ +final class MissingStepDefinitionError extends AssertionError { + + /** + * Creates a new instance for the given snippet. + * + * @param snippet the suggested snippet which could be implemented to avoid this exception + */ + MissingStepDefinitionError(final String snippet) { + super(String.format("\n\n%s", snippet)); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/main/resources/META-INF/services/io.cucumber.cucumberexpressions.PatternCompiler b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/resources/META-INF/services/io.cucumber.cucumberexpressions.PatternCompiler new file mode 100644 index 0000000000..1b7a15a7fb --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/main/resources/META-INF/services/io.cucumber.cucumberexpressions.PatternCompiler @@ -0,0 +1 @@ +io.cucumber.cucumberexpressions.AndroidPatternCompiler diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/com/vladium/emma/rt/RT.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/com/vladium/emma/rt/RT.java new file mode 100644 index 0000000000..abffbe158f --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/com/vladium/emma/rt/RT.java @@ -0,0 +1,39 @@ +package com.vladium.emma.rt; + +import java.io.File; + +/** + * This is just a stub implementation to test the code coverage logic, it should not be used in multi threaded tests. + */ +public class RT { + + private static File lastFile; + private static Throwable throwable; + + private RT() { + } + + public static void dumpCoverageData(final File file, final boolean merge, final boolean stopDataCollection) throws Throwable { + + if (throwable != null) { + throw throwable; + } + + file.createNewFile(); + lastFile = file; + } + + public static void throwOnNextInvocation(final Throwable throwable) { + RT.throwable = throwable; + } + + public static void resetMock() { + lastFile = null; + throwable = null; + } + + public static File getLastFile() { + return lastFile; + } +} + diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidPatternCompilerTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidPatternCompilerTest.java new file mode 100644 index 0000000000..684a30d7e9 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidPatternCompilerTest.java @@ -0,0 +1,30 @@ +package io.cucumber.junit; + +import io.cucumber.cucumberexpressions.AndroidPatternCompiler; +import io.cucumber.cucumberexpressions.PatternCompiler; + +import org.junit.Test; + +import java.util.ServiceLoader; +import java.util.regex.Pattern; + +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public class AndroidPatternCompilerTest { + + @Test + public void compiles_pattern_only_with_supported_flag() { + + AndroidPatternCompiler compiler = (AndroidPatternCompiler) ServiceLoader.load(PatternCompiler.class).iterator().next(); + + Pattern pattern = compiler.compile("HELLO", Pattern.UNICODE_CHARACTER_CLASS); + + assertFalse(pattern.matcher("hello").find()); + + + pattern = compiler.compile("HELLO", Pattern.CASE_INSENSITIVE | Pattern.UNICODE_CHARACTER_CLASS); + + assertTrue(pattern.matcher("hello").find()); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceLoaderTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceLoaderTest.java new file mode 100644 index 0000000000..a8f92a7976 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceLoaderTest.java @@ -0,0 +1,111 @@ +package io.cucumber.junit; + +import android.content.Context; +import android.content.res.AssetManager; +import com.google.common.collect.Lists; +import cucumber.runtime.io.Resource; + +import org.hamcrest.Description; +import org.hamcrest.Matcher; +import org.hamcrest.TypeSafeMatcher; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.net.URI; +import java.util.List; + +import static org.hamcrest.CoreMatchers.hasItem; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.mockito.Mockito.RETURNS_SMART_NULLS; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class AndroidResourceLoaderTest { + + private final Context context = mock(Context.class); + private final AssetManager assetManager = mock(AssetManager.class, RETURNS_SMART_NULLS); + private final AndroidResourceLoader androidResourceLoader = new AndroidResourceLoader(context); + + @Before + public void beforeEachTest() { + when(context.getAssets()).thenReturn(assetManager); + } + + @Test + public void retrieves_resource_by_given_path_and_suffix() { + + // given + final URI path = URI.create("file:some/path/some.feature"); + final String suffix = "feature"; + + // when + final List<Resource> resources = Lists.newArrayList(androidResourceLoader.resources(path, suffix)); + + // then + assertThat(resources.size(), is(1)); + assertThat(resources.get(0).getPath(), is(path)); + } + + @Test + public void retrieves_resources_recursively_from_given_path() throws IOException { + + // given + final String path = "file:dir"; + final String dir = "dir"; + final String dirFile = "dir.feature"; + final String subDir = "subdir"; + final String subDirFile = "subdir.feature"; + final String suffix = "feature"; + + when(assetManager.list(dir)).thenReturn(new String[]{subDir, dirFile}); + when(assetManager.list(dir + "/" + subDir)).thenReturn(new String[]{subDirFile}); + + // when + final List<Resource> resources = Lists.newArrayList(androidResourceLoader.resources(URI.create(path), suffix)); + + // then + assertThat(resources.size(), is(2)); + assertThat(resources, hasItem(withPath(path + "/" + dirFile))); + assertThat(resources, hasItem(withPath(path + "/" + subDir + "/" + subDirFile))); + } + + @Test + public void only_retrieves_those_resources_which_end_the_specified_suffix() throws IOException { + + // given + final String dir = "dir"; + String path = "file:" + dir; + final String expected = "expected.feature"; + final String unexpected = "unexpected.thingy"; + final String suffix = "feature"; + when(assetManager.list(dir)).thenReturn(new String[]{expected, unexpected}); + + // when + final List<Resource> resources = Lists.newArrayList(androidResourceLoader.resources(URI.create(path), suffix)); + + // then + assertThat(resources.size(), is(1)); + assertThat(resources, hasItem(withPath(path + "/" + expected))); + } + + private static Matcher<? super Resource> withPath(final String path) { + return new TypeSafeMatcher<Resource>() { + @Override + protected boolean matchesSafely(final Resource item) { + return item.getPath().toString().equals(path); + } + + @Override + public void describeTo(final Description description) { + description.appendText("resource with path: " + path); + } + }; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceTest.java new file mode 100644 index 0000000000..893141ec73 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/AndroidResourceTest.java @@ -0,0 +1,77 @@ +package io.cucumber.junit; + +import android.content.Context; +import android.content.res.AssetManager; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.mockito.Mockito; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.io.InputStream; +import java.net.URI; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertSame; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class AndroidResourceTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final Context context = Mockito.mock(Context.class); + + @Test + public void getPath_returns_given_path() { + + // given + final URI path = URI.create("file:some/path.feature"); + final AndroidResource androidResource = new AndroidResource(context, path); + + // when + final URI result = androidResource.getPath(); + + // then + assertThat(result, is(path)); + } + + + @Test + public void toString_outputs_the_path() { + + // given + final URI path = URI.create("file:some/path.feature"); + final AndroidResource androidResource = new AndroidResource(context, path); + + // when + final String result = androidResource.toString(); + + // then + assertEquals("AndroidResource (" + path.getSchemeSpecificPart() + ")",result); + } + + @Test + public void getInputStream_returns_asset_stream() throws IOException { + + // given + final URI path = URI.create("file:some/path.feature"); + AssetManager assetManager = Mockito.mock(AssetManager.class); + InputStream stream = Mockito.mock(InputStream.class); + Mockito.when(assetManager.open("some/path.feature",AssetManager.ACCESS_UNKNOWN)).thenReturn(stream); + Mockito.when(context.getAssets()).thenReturn(assetManager); + final AndroidResource androidResource = new AndroidResource(context, path); + + // when + final InputStream result = androidResource.getInputStream(); + + // then + assertSame(stream, result); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/ArgumentsTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/ArgumentsTest.java new file mode 100644 index 0000000000..2e1c74b9b7 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/ArgumentsTest.java @@ -0,0 +1,469 @@ +package io.cucumber.junit; + +import android.os.Bundle; +import com.google.common.base.Joiner; +import com.google.common.collect.Lists; +import java.util.List; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; +import static org.hamcrest.CoreMatchers.containsString; +import static org.hamcrest.CoreMatchers.is; +import static org.hamcrest.MatcherAssert.assertThat; +import static org.mockito.Mockito.spy; + +@Config(manifest = Config.NONE) +@RunWith(RobolectricTestRunner.class) +public class ArgumentsTest { + + @Test + public void handles_null_bundle_gracefully() { + + // given + final Arguments arguments = new Arguments(null); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("")); + } + + @Test + public void handles_empty_bundle_gracefully() { + + // given + final Arguments arguments = new Arguments(new Bundle()); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("")); + } + + @Test + public void supports_glue_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("glue", "glue/code/path"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--glue glue/code/path")); + } + + @Test + public void supports_format_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("format", "someFormat"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--format someFormat")); + } + + @Test + public void supports_plugin_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("plugin", "someFormat"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--plugin someFormat")); + } + + @Test + public void supports_tags_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("tags", "@someTag"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--tags @someTag")); + } + + @Test + public void supports_name_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("name", "someName"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--name someName")); + } + + @Test + public void supports_dryRun_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("dryRun", "true"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--dry-run")); + } + + @Test + public void supports_log_as_alias_for_dryRun_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("log", "true"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--dry-run")); + } + + @Test + public void supports_noDryRun_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("noDryRun", "true"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--no-dry-run")); + } + + @Test + public void supports_monochrome_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("monochrome", "true"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--monochrome")); + } + + @Test + public void supports_noMonochrome_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("noMonochrome", "true"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--no-monochrome")); + } + + @Test + public void supports_strict_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("strict", "true"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--strict")); + } + + @Test + public void supports_noStrict_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("noStrict", "true"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--no-strict")); + } + + @Test + public void supports_snippets_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("snippets", "someSnippet"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--snippets someSnippet")); + } + + @Test + public void supports_features_as_direct_bundle_argument() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("features", "someFeature"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + // TODO does this space makes sense? + assertThat(cucumberOptions, is(" someFeature")); + } + + @Test + public void supports_multiple_values() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("name", "Feature1--Feature2"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--name Feature1 --name Feature2")); + } + + @Test + public void supports_single_cucumber_options_string() { + + // given + final List<String> cucumberOptions = Lists.newArrayList("--tags @mytag", + "--monochrome", + "--name MyFeature", + "--dry-run", + "--glue com.someglue.Glue", + "--format pretty", + "--snippets underscore", + "--strict", + "--dotcucumber", + "test features"); + final Bundle bundle = new Bundle(); + bundle.putString("cucumberOptions", Joiner.on(" ").join(cucumberOptions)); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + for (final String cucumberOption : cucumberOptions) { + assertThat(arguments.getCucumberOptions(), containsString(cucumberOption)); + } + } + + @Test + public void single_cucumber_options_string_takes_precedence_over_direct_bundle_argument() { + + // given + final String cucumberOptions = "--tags @mytag1"; + final Bundle bundle = new Bundle(); + bundle.putString("cucumberOptions", cucumberOptions); + bundle.putString("tags", "@mytag2"); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.getCucumberOptions(), is(cucumberOptions)); + } + + @Test + public void supports_spaces_in_values() { + + // given + final Bundle bundle = new Bundle(); + bundle.putString("name", "'Name with spaces'"); + final Arguments arguments = new Arguments(bundle); + + // when + final String cucumberOptions = arguments.getCucumberOptions(); + + // then + assertThat(cucumberOptions, is("--name 'Name with spaces'")); + } + + @Test + public void isCountEnabled_returns_true_when_bundle_contains_true() { + // given + final Bundle bundle = spy(new Bundle()); + bundle.putString(Arguments.KEY.COUNT_ENABLED, "true"); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isCountEnabled(), is(true)); + } + + @Test + public void isCountEnabled_returns_false_when_bundle_contains_false() { + // given + final Bundle bundle = spy(new Bundle()); + bundle.putString(Arguments.KEY.COUNT_ENABLED, "false"); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isCountEnabled(), is(false)); + } + + @Test + public void isCountEnabled_returns_false_when_bundle_contains_no_value() { + // given + final Bundle bundle = spy(new Bundle()); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isCountEnabled(), is(false)); + } + + @Test + public void isDebugEnabled_returns_true_when_bundle_contains_true() { + // given + final Bundle bundle = spy(new Bundle()); + bundle.putString(Arguments.KEY.DEBUG_ENABLED, "true"); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isDebugEnabled(), is(true)); + } + + @Test + public void isDebugEnabled_returns_false_when_bundle_contains_false() { + // given + final Bundle bundle = spy(new Bundle()); + bundle.putString(Arguments.KEY.DEBUG_ENABLED, "false"); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isDebugEnabled(), is(false)); + } + + @Test + public void isDebugEnabled_returns_false_when_bundle_contains_no_value() { + // given + final Bundle bundle = spy(new Bundle()); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isDebugEnabled(), is(false)); + } + + @Test + public void coverageDataFilePath_returns_value_when_bundle_contains_value() { + // given + final String fileName = "some_custome_file.name"; + final Bundle bundle = spy(new Bundle()); + bundle.putString(Arguments.KEY.COVERAGE_DATA_FILE_PATH, fileName); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.coverageDataFilePath(), is(fileName)); + } + + @Test + public void coverageDataFilePath_returns_default_value_when_bundle_contains_no_value() { + // given + final Bundle bundle = spy(new Bundle()); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.coverageDataFilePath(), is(Arguments.DEFAULT.COVERAGE_DATA_FILE_PATH)); + } + + @Test + public void isCoverageEnabled_returns_true_when_bundle_contains_true() { + // given + final Bundle bundle = spy(new Bundle()); + bundle.putString(Arguments.KEY.COVERAGE_ENABLED, "true"); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isCoverageEnabled(), is(true)); + } + + @Test + public void isCoverageEnabled_returns_false_when_bundle_contains_false() { + // given + final Bundle bundle = spy(new Bundle()); + bundle.putString(Arguments.KEY.COVERAGE_ENABLED, "false"); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isCoverageEnabled(), is(false)); + } + + @Test + public void isCoverageEnabled_returns_false_when_bundle_contains_no_value() { + // given + final Bundle bundle = spy(new Bundle()); + + // when + final Arguments arguments = new Arguments(bundle); + + // then + assertThat(arguments.isCoverageEnabled(), is(false)); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/CoverageDumperTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/CoverageDumperTest.java new file mode 100644 index 0000000000..bff30df421 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/CoverageDumperTest.java @@ -0,0 +1,153 @@ +package io.cucumber.junit; + +import android.app.Instrumentation; +import android.os.Bundle; +import com.vladium.emma.rt.RT; +import java.io.File; + +import org.junit.Before; +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; +import static org.mockito.AdditionalMatchers.and; +import static org.mockito.Matchers.contains; +import static org.mockito.Matchers.eq; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyZeroInteractions; +import static org.mockito.Mockito.when; + +@RunWith(RobolectricTestRunner.class) +@Config(manifest = Config.NONE) +public class CoverageDumperTest { + + @Rule + public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + private final Bundle bundle = mock(Bundle.class); + private final Arguments arguments = mock(Arguments.class); + private final CoverageDumper coverageDumper = new CoverageDumper(arguments); + + @Before + public void beforeEach() { + RT.resetMock(); + } + + @Test + public void does_not_dump_when_flag_is_disabled() { + + // given + when(arguments.isCoverageEnabled()).thenReturn(false); + + // when + coverageDumper.requestDump(bundle); + + // then + verifyZeroInteractions(bundle); + } + + @Test + public void dumps_file_when_flag_is_enabled() { + + // given + final String fileName = temporaryFolder.getRoot().getAbsolutePath() + File.separator + "foo.bar"; + when(arguments.isCoverageEnabled()).thenReturn(true); + when(arguments.coverageDataFilePath()).thenReturn(fileName); + + // when + coverageDumper.requestDump(bundle); + + // then + assertThat(new File(fileName).exists(), is(true)); + } + + @Test + public void puts_path_to_coverage_file_into_bundle() { + + // given + final String fileName = temporaryFolder.getRoot().getAbsolutePath() + File.separator + "foo.bar"; + when(arguments.isCoverageEnabled()).thenReturn(true); + when(arguments.coverageDataFilePath()).thenReturn(fileName); + + // when + coverageDumper.requestDump(bundle); + + // then + verify(bundle).putString("coverageFilePath", fileName); + } + + @Test + public void appends_message_about_dumped_coverage_data_to_result_stream() { + + // given + final String fileName = temporaryFolder.getRoot().getAbsolutePath() + File.separator + "foo.bar"; + final String previousStream = "previous stream data"; + when(arguments.isCoverageEnabled()).thenReturn(true); + when(arguments.coverageDataFilePath()).thenReturn(fileName); + when(bundle.getString(Instrumentation.REPORT_KEY_STREAMRESULT)).thenReturn(previousStream); + + // when + coverageDumper.requestDump(bundle); + + // then + verify(bundle).putString(eq(Instrumentation.REPORT_KEY_STREAMRESULT), and(contains(previousStream), contains(fileName))); + } + + @Test + public void passes_file_for_specified_name_to_code_coverage_dumper_implementation() { + + // given + final String fileName = temporaryFolder.getRoot().getAbsolutePath() + File.separator + "foo.bar"; + when(arguments.isCoverageEnabled()).thenReturn(true); + when(arguments.coverageDataFilePath()).thenReturn(fileName); + + // when + coverageDumper.requestDump(bundle); + + // then + assertThat(RT.getLastFile().getAbsolutePath(), is(fileName)); + } + + + @Test + public void adds_error_message_to_result_stream_when_coverage_class_can_not_be_found() { + + // given + final String fileName = temporaryFolder.getRoot().getAbsolutePath() + File.separator + "foo.bar"; + final String previousStream = "previous stream data"; + when(arguments.isCoverageEnabled()).thenReturn(true); + when(arguments.coverageDataFilePath()).thenReturn(fileName); + when(bundle.getString(Instrumentation.REPORT_KEY_STREAMRESULT)).thenReturn(previousStream); + + // when + coverageDumper.requestDump(bundle); + + // then + verify(bundle).putString(eq(Instrumentation.REPORT_KEY_STREAMRESULT), and(contains(previousStream), contains(fileName))); + } + + @Test + public void adds_error_message_to_result_stream_when_file_cannot_be_dumped() { + + // given + final String fileName = temporaryFolder.getRoot().getAbsolutePath() + File.separator + "foo.bar"; + final String previousStream = "previous stream data"; + when(arguments.isCoverageEnabled()).thenReturn(true); + when(arguments.coverageDataFilePath()).thenReturn(fileName); + when(bundle.getString(Instrumentation.REPORT_KEY_STREAMRESULT)).thenReturn(previousStream); + RT.throwOnNextInvocation(new RuntimeException("something terrible happened")); + + // when + coverageDumper.requestDump(bundle); + + // then + verify(bundle).putString(eq(Instrumentation.REPORT_KEY_STREAMRESULT), and(contains(previousStream), contains("Error: Failed to generate coverage. Check logcat for details."))); + + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DebuggerWaiterTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DebuggerWaiterTest.java new file mode 100644 index 0000000000..ea3425d66f --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DebuggerWaiterTest.java @@ -0,0 +1,56 @@ +package io.cucumber.junit; + +import android.os.Debug; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.powermock.core.classloader.annotations.PrepareForTest; +import org.powermock.modules.junit4.PowerMockRunner; + +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.when; +import static org.powermock.api.mockito.PowerMockito.mockStatic; +import static org.powermock.api.mockito.PowerMockito.verifyStatic; + +@RunWith(PowerMockRunner.class) +@PrepareForTest(Debug.class) +public class DebuggerWaiterTest { + + + @Test + public void waits_for_debugger_when_flag_is_set() { + // given + final Arguments arguments = mock(Arguments.class); + when(arguments.isDebugEnabled()).thenReturn(true); + + mockStatic(Debug.class); + + final DebuggerWaiter waiter = new DebuggerWaiter(arguments); + + // when + waiter.requestWaitForDebugger(); + + // then + verifyStatic(Debug.class); + Debug.waitForDebugger(); + } + + @Test + public void does_not_wait_for_debugger_when_flag_is_not_set() { + // given + final Arguments arguments = mock(Arguments.class); + when(arguments.isDebugEnabled()).thenReturn(false); + + mockStatic(Debug.class); + + final DebuggerWaiter waiter = new DebuggerWaiter(arguments); + + // when + waiter.requestWaitForDebugger(); + + // then + verifyStatic(Debug.class,never()); + Debug.waitForDebugger(); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DexClassFinderTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DexClassFinderTest.java new file mode 100644 index 0000000000..9b35fe3f38 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/DexClassFinderTest.java @@ -0,0 +1,178 @@ +package io.cucumber.junit; + +import com.google.common.collect.Lists; + +import org.hamcrest.Matcher; +import org.hamcrest.collection.IsIterableContainingInOrder; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.robolectric.RobolectricTestRunner; +import org.robolectric.annotation.Config; + +import java.io.IOException; +import java.lang.reflect.Field; +import java.util.Arrays; +import java.util.Collection; +import java.util.List; + +import dalvik.system.DexFile; +import io.cucumber.core.model.GluePath; +import io.cucumber.junit.shadow.ShadowDexFile; +import io.cucumber.junit.stub.unwanted.SomeUnwantedClass; +import io.cucumber.junit.stub.wanted.Manifest; +import io.cucumber.junit.stub.wanted.R; +import io.cucumber.junit.stub.wanted.SomeClass; +import io.cucumber.junit.stub.wanted.SomeKotlinClass; + +import static org.hamcrest.MatcherAssert.assertThat; + +@RunWith(RobolectricTestRunner.class) +@Config(shadows = {ShadowDexFile.class}, manifest = Config.NONE) +public class DexClassFinderTest { + + private DexFile dexFile; + private DexClassFinder dexClassFinder; + + @Before + public void beforeEachTest() throws IOException { + dexFile = new DexFile("notImportant"); + dexClassFinder = new DexClassFinder(dexFile); + } + + @Test + public void only_loads_classes_from_specified_package() throws Exception { + + // given + setDexFileEntries(SomeClass.class, SomeKotlinClass.class, SomeUnwantedClass.class); + + // when + final Collection<Class<?>> descendants = getDescendants(Object.class, SomeClass.class.getPackage()); + + // then + assertThat(descendants, IsIterableContainingInOrder.<Class<?>>contains(SomeClass.class, SomeKotlinClass.class)); + } + + private <T> Collection<Class<? extends T>> getDescendants(Class<T> parentType, Package javaPackage) + { + return dexClassFinder.getDescendants(parentType, GluePath.parse(javaPackage.getName())); + } + + @Test + public void does_not_load_manifest_class() throws Exception { + + // given + setDexFileEntries(SomeClass.class, Manifest.class); + + // when + final Collection<Class<?>> descendants = getDescendants(Object.class, SomeClass.class.getPackage()); + + // then + assertThat(descendants, containsOnly(SomeClass.class)); + } + + @Test + public void does_not_load_R_class() throws Exception { + + // given + setDexFileEntries(SomeClass.class, R.class); + + // when + final Collection<Class<?>> descendants = getDescendants(Object.class, SomeClass.class.getPackage()); + + // then + assertThat(descendants, containsOnly(SomeClass.class)); + } + + @Test + public void does_not_load_R_inner_class() throws Exception { + + // given + setDexFileEntries(SomeClass.class, R.SomeInnerClass.class); + + // when + final Collection<Class<?>> descendants = getDescendants(Object.class, SomeClass.class.getPackage()); + + // then + assertThat(descendants, containsOnly(SomeClass.class)); + } + + @Test + public void only_loads_class_which_is_not_the_parent_type() throws Exception { + + // given + setDexFileEntries(Integer.class, Number.class); + + // when + final Class parentType = Number.class; + @SuppressWarnings("unchecked") + final Collection<Class<?>> descendants = getDescendants(parentType, Object.class.getPackage()); + + // then + assertThat(descendants, containsOnly(Integer.class)); + } + + @Test + public void only_loads_class_which_is_assignable_to_parent_type() throws Exception { + + // given + setDexFileEntries(Integer.class, String.class); + + // when + final Class parentType = Number.class; + @SuppressWarnings("unchecked") + final Collection<Class<?>> descendants = getDescendants(parentType, Object.class.getPackage()); + + // then + assertThat(descendants, containsOnly(Integer.class)); + } + + @Test + public void does_not_load_kotlin_inlined_classes() throws Exception { + // given + Class<?> kotlinInlinedFunClass = Class.forName("io.cucumber.junit.stub.wanted.SomeKotlinClass$someFun$$inlined$sortedBy$1"); + setDexFileEntries(SomeClass.class, kotlinInlinedFunClass); + + // when + final Collection<Class<?>> descendants = getDescendants(Object.class, SomeClass.class.getPackage()); + + // then + assertThat(descendants, containsOnly(SomeClass.class)); + } + + @Test + public void does_not_throw_exception_if_class_not_found() throws Exception { + // given + setDexFileEntries(Arrays.asList(SomeClass.class.getName(),"SomeNotExistentClass")); + + // when + final Collection<Class<?>> descendants = getDescendants(Object.class, SomeClass.class.getPackage()); + + // then + assertThat(descendants, containsOnly(SomeClass.class)); + } + + private Matcher<Iterable<? extends Class<?>>> containsOnly(final Class<?> type) { + return IsIterableContainingInOrder.<Class<?>>contains(type); + } + + private void setDexFileEntries(final Class... entryClasses) throws NoSuchFieldException, IllegalAccessException { + Collection<String> entries = classToName(entryClasses); + setDexFileEntries(entries); + } + + private void setDexFileEntries(Collection<String> entries) throws NoSuchFieldException, IllegalAccessException { + final Field roboData = DexFile.class.getDeclaredField("__robo_data__"); + final ShadowDexFile shadowDexFile = (ShadowDexFile) roboData.get(dexFile); + shadowDexFile.setEntries(entries); + } + + private Collection<String> classToName(final Class... entryClasses) { + final List<String> names = Lists.newArrayList(); + for (final Class entryClass : entryClasses) { + names.add(entryClass.getName()); + } + + return names; + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/MissingStepDefinitionErrorTest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/MissingStepDefinitionErrorTest.java new file mode 100644 index 0000000000..925c6ac80f --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/MissingStepDefinitionErrorTest.java @@ -0,0 +1,24 @@ +package io.cucumber.junit; + +import org.junit.Test; + +import static org.hamcrest.MatcherAssert.assertThat; +import static org.hamcrest.core.Is.is; + +; + +public class MissingStepDefinitionErrorTest { + + @Test + public void puts_snippet_with_preceeding_new_line_into_exception_message() { + + // given + final String snippet = "some snippet"; + + // when + final String message = new MissingStepDefinitionError(snippet).getMessage(); + + // then + assertThat(message, is("\n\nsome snippet")); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/shadow/ShadowDexFile.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/shadow/ShadowDexFile.java new file mode 100644 index 0000000000..04cdd65559 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/shadow/ShadowDexFile.java @@ -0,0 +1,23 @@ +package io.cucumber.junit.shadow; + +import dalvik.system.DexFile; +import java.util.Collection; +import java.util.Collections; +import java.util.Enumeration; +import org.robolectric.annotation.Implementation; +import org.robolectric.annotation.Implements; + +@Implements(DexFile.class) +public class ShadowDexFile { + + private Enumeration<String> entries; + + @Implementation + public Enumeration<String> entries() { + return entries; + } + + public void setEntries(final Collection<String> entries) { + this.entries = Collections.enumeration(entries); + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/unwanted/SomeUnwantedClass.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/unwanted/SomeUnwantedClass.java new file mode 100644 index 0000000000..a2c2486be7 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/unwanted/SomeUnwantedClass.java @@ -0,0 +1,4 @@ +package io.cucumber.junit.stub.unwanted; + +public class SomeUnwantedClass { +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/Manifest.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/Manifest.java new file mode 100644 index 0000000000..7669d07779 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/Manifest.java @@ -0,0 +1,4 @@ +package io.cucumber.junit.stub.wanted; + +public class Manifest { +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/R.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/R.java new file mode 100644 index 0000000000..4ebb7bfe35 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/R.java @@ -0,0 +1,8 @@ +package io.cucumber.junit.stub.wanted; + +public class R { + + public static class SomeInnerClass { + + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeClass.java b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeClass.java new file mode 100644 index 0000000000..3768496b0e --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeClass.java @@ -0,0 +1,4 @@ +package io.cucumber.junit.stub.wanted; + +public class SomeClass { +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeKotlinClass.kt b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeKotlinClass.kt new file mode 100644 index 0000000000..52c3c0263b --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/java/io/cucumber/junit/stub/wanted/SomeKotlinClass.kt @@ -0,0 +1,8 @@ +package io.cucumber.junit.stub.wanted + +class SomeKotlinClass { + + fun someFun(){ + listOf(1,3,5).sortedBy { it } + } +} diff --git a/test_projects/android/cucumber_sample_app/cucumber-android/src/test/resources/robolectric.properties b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/resources/robolectric.properties new file mode 100644 index 0000000000..932b01b9eb --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cucumber-android/src/test/resources/robolectric.properties @@ -0,0 +1 @@ +sdk=28 diff --git a/test_projects/android/cucumber_sample_app/cukeulator/.gitignore b/test_projects/android/cucumber_sample_app/cukeulator/.gitignore new file mode 100644 index 0000000000..4c5d941320 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/.gitignore @@ -0,0 +1,28 @@ +# Built application files +/*/build/ +build/ + +# Crashlytics configuations +com_crashlytics_export_strings.xml + +# Local configuration file (sdk path, etc) +local.properties + +# Gradle generated files +.gradle/ + +# Signing files +.signing/ + +# User-specific configurations +.idea/ +*.iml + +# OS-specific files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db diff --git a/test_projects/android/cucumber_sample_app/cukeulator/README.md b/test_projects/android/cucumber_sample_app/cukeulator/README.md new file mode 100644 index 0000000000..861dbc4782 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/README.md @@ -0,0 +1,77 @@ +## Cukeulator Example Application +This is the example test-project for the Cukeulator app for Android Studio 3.0+ + +### Setup +Features must be placed in `androidTest/assets/features/`. Subdirectories are allowed. + +The rest of the dependencies are added automatically in `cukeulator/build.gradle`. + +The cucumber-android dependency is added as (see `cukeulator/build.gradle`): + +``` +androidTestImplementation 'io.cucumber:cucumber-android:<version>' +``` + +### Using gradle +To build the cukeulator apk: +``` +./gradlew --parallel :cukeulator:assemble +``` +The build generates an apk in cukeulator/build/outputs/apk/debug/cukeulator-debug.apk. + + +To build the instrumentation test apk: +``` +./gradlew --parallel :cukeulator:assembleDebugTest +``` + +To install the apk on a device: +``` +adb install -r cukeulator/build/outputs/apk/debug/cukeulator-debug.apk +``` + +To install the test apk on a device: +``` +adb install -r cukeulator/build/outputs/apk/androidTest/debug/cukeulator-debug-androidTest.apk +``` + +To verify that the test is installed, run: + +``` +adb shell pm list instrumentation +``` + +The command output should display; + +``` +instrumentation:cucumber.cukeulator.test/.CukeulatorAndroidJUnitRunner (target=cucumber.cukeulator) +``` + +To run the test: + +``` +./gradlew :cukeulator:connectedCheck +``` + +As an alternative option, the test can be run with adb: + +``` +adb shell am instrument -w cucumber.cukeulator.test/cucumber.cukeulator.test.CukeulatorAndroidJUnitRunner +``` + +### Using an Android Studio IDE +1. Import the example to Android Studio: `File > Import Project`. +2. Create a test run configuration: + 1. Run > Edit Configurations + 2. Click `+` button and select Android Instrumented Tests + 3. Specify test name: `CalculatorTest` + 4. Select module: `cukeulator` + 5. Enter a Specific instrumentation runner: `cucumber.cukeulator.test/cucumber.cukeulator.test.CukeulatorAndroidJUnitRunner` + 6. Click Ok + +### Output +Filter for the logcat tag `cucumber-android` in [DDMS](https://developer.android.com/tools/debugging/ddms.html). + +### Using this project with locally built Cucumber-JVM +See [cukeulator/build.gradle](build.gradle) under `dependencies`. +There is a source-code comment which explains how to use a locally built Cucumber-JVM Android library. diff --git a/test_projects/android/cucumber_sample_app/cukeulator/build.gradle b/test_projects/android/cucumber_sample_app/cukeulator/build.gradle new file mode 100644 index 0000000000..6298f34c2e --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/build.gradle @@ -0,0 +1,248 @@ +apply plugin: 'com.android.application' +apply plugin: 'jacoco' +apply plugin: "com.jaredsburrows.spoon" +apply plugin: "kotlin-android" + +android { + + compileSdkVersion 29 + buildToolsVersion '29.0.3' + + + compileOptions { + sourceCompatibility JavaVersion.VERSION_1_8 + targetCompatibility JavaVersion.VERSION_1_8 + } + + lintOptions { + abortOnError false + } + + testOptions { + unitTests { + includeAndroidResources = true + } + } + defaultConfig { + minSdkVersion 14 + targetSdkVersion 29 + multiDexEnabled true + applicationId "cucumber.cukeulator" + testApplicationId "cucumber.cukeulator.test" + testInstrumentationRunner "cucumber.cukeulator.test.CukeulatorAndroidJUnitRunner" + versionCode 1 + versionName '1.0' + //testInstrumentationRunnerArguments = [ + // cucumberUseAndroidJUnitRunner: getProperty("cucumberUseAndroidJUnitRunner"), + // uncomment this to clear app data before each test when running with orchestrator + // clearPackageData: 'true' + //] + } + + + buildTypes { + release { + minifyEnabled false + } + + debug {} + } + + packagingOptions { + exclude 'LICENSE.txt' + } + + // With the following option we does not have to mock everything, + // e.g. super calls cannot be mocked with Mockito only (just with Powermock). + testOptions { + unitTests.returnDefaultValues = true + animationsDisabled true +// uncomment this to run tests with orchestrator +// execution 'ANDROIDX_TEST_ORCHESTRATOR' + } + +} + +// ================================================================== +// Project dependencies +// ================================================================== + +dependencies { + // + // The following dependency works, if you build Cucumber-JVM on your local machine: + // + // androidTestImplementation 'io.cucumber:cucumber-android:2.3.2-SNAPSHOT' + // + // (If you enable it, you must disable the dependency to the stable Cucumber version above.) + // + // If you have not yet built Cucumber-JVM on your local machine, build it with: + // + // cd <cucumber-jvm-source-root> + // mvn clean install + // + // Hint: you find the Cucumber-JVM source root under a parent directory of this file. + // + // If you only change the Android source, you also can do: + // + // cd <cucumber-jvm-source-root>/android + // mvn install + // + // Now, after you have been built Cucumber-JVM with one of the commands above, it is published + // to your local Maven repository and may be used by other projects on your local machine. + // + // For our example project, that means you can execute the feature files, with the just built + // Cucumber-JVM library by using the following command (press Alt+F12 in Android Studio): + // + // gradlew connectedCheck --refresh-dependencies + // + // The --refresh-dependencies option seems not to be required anymore for recent Android Studio + // and Gradle versions. But, if your Cucumber-JVM snapshot dependency is not updated + // automatically by Gradle, the flag always bypasses any caching of dependencies. + + + implementation 'androidx.appcompat:appcompat:1.2.0' + implementation "org.jetbrains.kotlin:kotlin-stdlib:$kotlin_version" + +// testImplementation 'junit:junit:4.12' +// testImplementation 'org.mockito:mockito-core:2.10.0' + androidTestImplementation 'androidx.test.espresso:espresso-core:3.3.0' + androidTestImplementation 'androidx.test:rules:1.3.0' + androidTestUtil 'androidx.test:orchestrator:1.3.0' + + // Use the stable Cucumber version + androidTestImplementation project(":cucumber_sample_app::cucumber-android") + androidTestImplementation "io.cucumber:cucumber-picocontainer:4.8.1" + +} + +// ================================================================== +// Custom tasks +// ================================================================== + + +task runInstrumentationTests { + group "verification" + mustRunAfter "deleteExistingCucumberReports" + dependsOn "deleteExistingCucumberReports","spoonDebugAndroidTest" + finalizedBy "downloadCucumberReports" +} + +spoon { + debug = true + //this is faster but can be set to false + singleInstrumentationCall = true + grantAll = true + shard = true +} + + +/* + * Downloads all Cucumber reports from the connected device. + */ +task downloadCucumberReports { + group "Verification" + description "Downloads the rich Cucumber report files (HTML, XML, JSON) from the connected device" + + doLast { + def deviceSourcePath = getCucumberDevicePath() + def localReportPath = new File(buildDir, "reports/cucumber") + if (!localReportPath.exists()) { + localReportPath.mkdirs() + } + if (!localReportPath.exists()) { + throw new GradleException("Could not create $localReportPath") + } + def adb = getAdbPath() + def files = getCucumberReportFileNames() + files.each { fileName -> + println fileName + exec { + commandLine adb, 'pull', "$deviceSourcePath/$fileName", localReportPath + } + } + } +} + +/** + * Deletes existing Cucumber reports on the device. + */ +task deleteExistingCucumberReports { + group "Verification" + description "Removes the rich Cucumber report files (HTML, XML, JSON) from the connected device" + doLast { + def deviceSourcePath = getCucumberDevicePath() + def files = getCucumberReportFileNames() + files.each { fileName -> + def deviceFileName = deviceSourcePath + '/' + fileName + def output2 = executeAdb('if [ -d "' + deviceFileName + '" ]; then rm -r "' + deviceFileName + '"; else rm -r "' + deviceFileName + '" ; fi') + println output2 + } + } +} + +/** + * Sets the required permissions for Cucumber to write on the internal storage. + */ +task grantPermissions(dependsOn: 'installDebug') { + doLast { + def adb = getAdbPath() + // We only set the permissions for the main application + def mainPackageName = android.defaultConfig.applicationId + def readPermission = "android.permission.READ_EXTERNAL_STORAGE" + def writePermission = "android.permission.WRITE_EXTERNAL_STORAGE" + exec { commandLine adb, 'shell', 'pm', 'grant', mainPackageName, readPermission } + exec { commandLine adb, 'shell', 'pm', 'grant', mainPackageName, writePermission } + } +} + + +// ================================================================== +// Utility methods +// ================================================================== + +/** + * Utility method to get the full ADB path + * @return the absolute ADB path + */ +String getAdbPath() { + def adb = android.getAdbExecutable().toString() + if (adb.isEmpty()) { + throw new GradleException("Could not detect adb path") + } + return adb +} + +/** + * Sometime adb returns '\r' character multiple times. + * @param s the original string returned by adb + * @return the fixed string without '\r' + */ +static def fixAdbOutput(String s) { + return s.replaceAll("[\r\n]+", "\n").trim() +} + +/** + * Runs the adb tool + * @param program the program which is executed on the connected device + * @return the output of the adb tool + */ +def executeAdb(String program) { + def process = new ProcessBuilder(getAdbPath(), "shell", program).redirectErrorStream(true).start() + String text = new BufferedReader(new InputStreamReader(process.inputStream)).text + return fixAdbOutput(text) +} + +/** + * The path which is used to store the Cucumber files. + * @return + */ +def getCucumberDevicePath() { + return 'sdcard/Android/data/cucumber.cukeulator/files/reports' +} + +/** + * @return the known Cucumber report files/directories + */ +static def getCucumberReportFileNames() { + return ['cucumber.xml', 'cucumber.html'] +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/extra/calculate.feature b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/extra/calculate.feature new file mode 100644 index 0000000000..ad05180c70 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/extra/calculate.feature @@ -0,0 +1,20 @@ +Feature: Calculate a result + Perform an arithmetic operation on two numbers using a mathematical operator + """The purpose of this feature is to illustrate how existing step-definitions + can be efficiently reused.""" + + Scenario Outline: Enter a digit, an operator and another digit + Given I have a CalculatorActivity + When I press <num1> + And I press <op> + And I press <num2> + And I press = + Then I should see "<result>" on the display + + Examples: + | num1 | num2 | op | result | + | 9 | 8 | + | 17.0 | + | 7 | 6 | – | 1.0 | + | 5 | 4 | x | 20.0 | + | 3 | 2 | / | 1.5 | + | 1 | 0 | / | Infinity | diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/addition.feature b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/addition.feature new file mode 100644 index 0000000000..9199cd5f08 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/addition.feature @@ -0,0 +1,31 @@ +Feature: Add two numbers + Calculate the sum of two numbers which consist of one or more digits + + Scenario Outline: Enter one digit per number and press = + Given I have a CalculatorActivity + When I press <num1> + And I press + + And I press <num2> + And I press = + Then I should see "<sum>" on the display + + Examples: + | num1 | num2 | sum | + | 0 | 0 | 0.0 | + | 0 | 1 | 1.0 | + | 1 | 1 | 2.0 | + + Scenario Outline: Enter two digits per number and press = + Given I have a CalculatorActivity + When I press <num1> + When I press <num2> + And I press + + And I press <num3> + And I press <num4> + And I press = + Then I should see "<sum>" on the display + + Examples: + | num1 | num2 | num3 | num4 | sum | + | 0 | 0 | 2 | 0 | 20.0 | + | 9 | 8 | 7 | 6 | 174.0 | diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/division.feature b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/division.feature new file mode 100644 index 0000000000..e6dc09a875 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/division.feature @@ -0,0 +1,31 @@ +Feature: Divide two numbers + Calculate the quotient of two numbers which consist of one or more digits + + Scenario Outline: Enter one digit per number and press = + Given I have a CalculatorActivity + When I press <num1> + And I press / + And I press <num2> + And I press = + Then I should see "<quotient>" on the display + + Examples: + | num1 | num2 | quotient | + | 0 | 0 | NaN | + | 1 | 0 | Infinity | + | 1 | 2 | 0.5 | + + Scenario Outline: Enter two digits per number and press = + Given I have a CalculatorActivity + When I press <num1> + When I press <num2> + And I press / + And I press <num3> + And I press <num4> + And I press = + Then I should see "<quotient>" on the display + + Examples: + | num1 | num2 | num3 | num4 | quotient | + | 2 | 2 | 2 | 2 | 1.0 | + | 2 | 0 | 1 | 0 | 2.0 | diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/multiplication.feature b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/multiplication.feature new file mode 100644 index 0000000000..311d4ecb1d --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/multiplication.feature @@ -0,0 +1,31 @@ +Feature: Multiply two numbers + Calculate the product of two numbers which consist of one or more digits + + Scenario Outline: Enter one digit per number and press = + Given I have a CalculatorActivity + When I press <num1> + And I press x + And I press <num2> + And I press = + Then I should see "<product>" on the display + + Examples: + | num1 | num2 | product | + | 0 | 0 | 0.0 | + | 0 | 1 | 0.0 | + | 1 | 2 | 2.0 | + + Scenario Outline: Enter two digits per number and press = + Given I have a CalculatorActivity + When I press <num1> + When I press <num2> + And I press x + And I press <num3> + And I press <num4> + And I press = + Then I should see "<product>" on the display + + Examples: + | num1 | num2 | num3 | num4 | product | + | 2 | 2 | 2 | 2 | 484.0 | + | 2 | 0 | 1 | 0 | 200.0 | diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/subtraction.feature b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/subtraction.feature new file mode 100644 index 0000000000..51a3771ac5 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/assets/features/operations/subtraction.feature @@ -0,0 +1,31 @@ +Feature: Subtract two numbers + Calculate the difference of two numbers which consist of one or more digits + + Scenario Outline: Enter one digit per number and press = + Given I have a CalculatorActivity + When I press <num1> + And I press – + And I press <num2> + And I press = + Then I should see "<delta>" on the display + + Examples: + | num1 | num2 | delta | + | 0 | 0 | 0.0 | + | 0 | 1 | -1.0 | + | 1 | 2 | -1.0 | + + Scenario Outline: Enter two digits per number and press = + Given I have a CalculatorActivity + When I press <num1> + When I press <num2> + And I press – + And I press <num3> + And I press <num4> + And I press = + Then I should see "<delta>" on the display + + Examples: + | num1 | num2 | num3 | num4 | delta | + | 2 | 2 | 2 | 2 | 0.0 | + | 2 | 0 | 1 | 0 | 10.0 | diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CalculatorActivitySteps.java b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CalculatorActivitySteps.java new file mode 100644 index 0000000000..e51edb16a2 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CalculatorActivitySteps.java @@ -0,0 +1,134 @@ +package cucumber.cukeulator.test; + +import android.app.Activity; + +import androidx.test.rule.ActivityTestRule; + +import cucumber.cukeulator.CalculatorActivity; +import cucumber.cukeulator.R; +import io.cucumber.java.After; +import io.cucumber.java.Before; +import io.cucumber.java.en.Given; +import io.cucumber.java.en.When; +import io.cucumber.junit.CucumberJUnitRunner; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static org.junit.Assert.assertNotNull; + +/** + * We use {@link ActivityTestRule} in order to have access to methods like getActivity + * and getInstrumentation. + * </p> + * The CucumberOptions annotation is mandatory for exactly one of the classes in the test project. + * Only the first annotated class that is found will be used, others are ignored. If no class is + * annotated, an exception is thrown. + * <p/> + * The options need to at least specify features = "features". Features must be placed inside + * assets/features/ of the test project (or a subdirectory thereof). + */ +public class CalculatorActivitySteps { + + /** + * Since {@link CucumberJUnitRunner} has the control over the + * test lifecycle, activity test rules must not be launched automatically. Automatic launching of test rules is only + * feasible for JUnit tests. Fortunately, we are able to launch the activity in Cucumber's {@link Before} method. + */ + ActivityTestRule rule = new ActivityTestRule<>(CalculatorActivity.class, false, false); + + public CalculatorActivitySteps(SomeDependency dependency) { + assertNotNull(dependency); + } + + /** + * We launch the activity in Cucumber's {@link Before} hook. + * See the notes above for the reasons why we are doing this. + * + * @throws Exception any possible Exception + */ + @Before + public void launchActivity() throws Exception { + rule.launchActivity(null); + } + + /** + * All the clean up of application's data and state after each scenario must happen here + */ + @After + public void finishActivity() throws Exception { + getActivity().finish(); + } + + /** + * Gets the activity from our test rule. + * + * @return the activity + */ + private Activity getActivity() { + return rule.getActivity(); + } + + @Given("I have a CalculatorActivity") + public void I_have_a_CalculatorActivity() { + assertNotNull(getActivity()); + } + + @When("I press {digit}") + public void I_press_d(final int d) { + switch (d) { + case 0: + onView(withId(R.id.btn_d_0)).perform(click()); + break; + case 1: + onView(withId(R.id.btn_d_1)).perform(click()); + break; + case 2: + onView(withId(R.id.btn_d_2)).perform(click()); + break; + case 3: + onView(withId(R.id.btn_d_3)).perform(click()); + break; + case 4: + onView(withId(R.id.btn_d_4)).perform(click()); + break; + case 5: + onView(withId(R.id.btn_d_5)).perform(click()); + break; + case 6: + onView(withId(R.id.btn_d_6)).perform(click()); + break; + case 7: + onView(withId(R.id.btn_d_7)).perform(click()); + break; + case 8: + onView(withId(R.id.btn_d_8)).perform(click()); + break; + case 9: + onView(withId(R.id.btn_d_9)).perform(click()); + break; + } + } + + @When("I press {operator}") + public void I_press_op(final char op) { + switch (op) { + case '+': + onView(withId(R.id.btn_op_add)).perform(click()); + break; + case '–': + onView(withId(R.id.btn_op_subtract)).perform(click()); + break; + case 'x': + onView(withId(R.id.btn_op_multiply)).perform(click()); + break; + case '/': + onView(withId(R.id.btn_op_divide)).perform(click()); + break; + case '=': + onView(withId(R.id.btn_op_equals)).perform(click()); + break; + } + } + +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CukeulatorAndroidJUnitRunner.java b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CukeulatorAndroidJUnitRunner.java new file mode 100644 index 0000000000..40e5e3a96a --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/CukeulatorAndroidJUnitRunner.java @@ -0,0 +1,61 @@ +package cucumber.cukeulator.test; + +import android.os.Bundle; + +import java.io.File; + +import io.cucumber.android.runner.CucumberAndroidJUnitRunner; +import io.cucumber.junit.CucumberOptions; + +/** + * The CucumberOptions annotation is mandatory for exactly one of the classes in the test project. + * Only the first annotated class that is found will be used, others are ignored. If no class is + * annotated, an exception is thrown. This annotation does not have to placed in runner class + */ +@CucumberOptions( + features = "features", + strict = true +) +public class CukeulatorAndroidJUnitRunner extends CucumberAndroidJUnitRunner { + + @Override + public void onCreate(final Bundle bundle) { + bundle.putString("plugin", getPluginConfigurationString()); // we programmatically create the plugin configuration + //it crashes on Android R without it + new File(getAbsoluteFilesPath()).mkdirs(); + super.onCreate(bundle); + } + + /** + * Since we want to checkout the external storage directory programmatically, we create the plugin configuration + * here, instead of the {@link CucumberOptions} annotation. + * + * @return the plugin string for the configuration, which contains XML, HTML and JSON paths + */ + private String getPluginConfigurationString() { + String cucumber = "cucumber"; + String separator = "--"; + return "junit:" + getCucumberXml(cucumber) + separator + + "html:" + getCucumberHtml(cucumber); + } + + private String getCucumberHtml(String cucumber) { + return getAbsoluteFilesPath() + "/" + cucumber + ".html"; + } + + private String getCucumberXml(String cucumber) { + return getAbsoluteFilesPath() + "/" + cucumber + ".xml"; + } + + /** + * The path which is used for the report files. + * + * @return the absolute path for the report files + */ + private String getAbsoluteFilesPath() { + + //sdcard/Android/data/cucumber.cukeulator + File directory = getTargetContext().getExternalFilesDir(null); + return new File(directory, "reports").getAbsolutePath(); + } +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/InstrumentationNonCucumberTest.java b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/InstrumentationNonCucumberTest.java new file mode 100644 index 0000000000..1dd6ec47a0 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/InstrumentationNonCucumberTest.java @@ -0,0 +1,41 @@ +package cucumber.cukeulator.test; + +import android.content.Intent; + +import androidx.test.filters.SmallTest; +import androidx.test.rule.ActivityTestRule; + +import org.junit.Before; +import org.junit.Test; + +import cucumber.cukeulator.CalculatorActivity; +import cucumber.cukeulator.R; + +import static androidx.test.espresso.Espresso.onView; +import static androidx.test.espresso.action.ViewActions.click; +import static androidx.test.espresso.assertion.ViewAssertions.matches; +import static androidx.test.espresso.matcher.ViewMatchers.withId; +import static androidx.test.espresso.matcher.ViewMatchers.withText; + +/** + * The aim of this test is to make sure that it is possible to run non cucumber instrumentation tests. + */ +public class InstrumentationNonCucumberTest { + + private ActivityTestRule<CalculatorActivity> activityTestRule = new ActivityTestRule<>(CalculatorActivity.class, false, false); + + @Before + public void setUp() throws Exception { + activityTestRule.launchActivity(new Intent()); + } + + @SmallTest + @Test + public void assert_that_click_on_0_is_visible_in_the_text_cal_display() { + onView(withId(R.id.btn_d_0)) + .perform(click()); + + onView(withId(R.id.txt_calc_display)) + .check(matches(withText("0"))); + } +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/KotlinSteps.kt b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/KotlinSteps.kt new file mode 100644 index 0000000000..23c9289663 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/KotlinSteps.kt @@ -0,0 +1,18 @@ +package cucumber.cukeulator.test + +import androidx.test.espresso.Espresso +import androidx.test.espresso.assertion.ViewAssertions +import androidx.test.espresso.matcher.ViewMatchers +import androidx.test.espresso.matcher.ViewMatchers.withId +import io.cucumber.java.en.Then +import cucumber.cukeulator.R + + +class KotlinSteps { + + + @Then("I should see {string} on the display") + fun I_should_see_s_on_the_display(s: String?) { + Espresso.onView(withId(R.id.txt_calc_display)).check(ViewAssertions.matches(ViewMatchers.withText(s))) + } +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeClassWithUnsupportedApi.java b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeClassWithUnsupportedApi.java new file mode 100644 index 0000000000..c1c0bdae1e --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeClassWithUnsupportedApi.java @@ -0,0 +1,51 @@ +package cucumber.cukeulator.test; + +import java.util.Comparator; +import java.util.function.Function; +import java.util.function.ToDoubleFunction; +import java.util.function.ToIntFunction; +import java.util.function.ToLongFunction; + +public class SomeClassWithUnsupportedApi implements Comparator<Integer> { + + + @Override + public int compare(Integer o1, Integer o2) { + return 0; + } + + @Override + public Comparator<Integer> reversed() { + return null; + } + + @Override + public Comparator<Integer> thenComparing(Comparator<? super Integer> other) { + return null; + } + + @Override + public <U> Comparator<Integer> thenComparing(Function<? super Integer, ? extends U> keyExtractor, Comparator<? super U> keyComparator) { + return null; + } + + @Override + public <U extends Comparable<? super U>> Comparator<Integer> thenComparing(Function<? super Integer, ? extends U> keyExtractor) { + return null; + } + + @Override + public Comparator<Integer> thenComparingInt(ToIntFunction<? super Integer> keyExtractor) { + return null; + } + + @Override + public Comparator<Integer> thenComparingLong(ToLongFunction<? super Integer> keyExtractor) { + return null; + } + + @Override + public Comparator<Integer> thenComparingDouble(ToDoubleFunction<? super Integer> keyExtractor) { + return null; + } +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeDependency.java b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeDependency.java new file mode 100644 index 0000000000..79910fe762 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/SomeDependency.java @@ -0,0 +1,5 @@ +package cucumber.cukeulator.test; + +// Dummy class to demonstrate dependency injection +public class SomeDependency { +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/TypeRegistryConfiguration.java b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/TypeRegistryConfiguration.java new file mode 100644 index 0000000000..79997c6a24 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/androidTest/java/cucumber/cukeulator/test/TypeRegistryConfiguration.java @@ -0,0 +1,45 @@ +package cucumber.cukeulator.test; + +import java.util.Locale; + +import io.cucumber.core.api.TypeRegistry; +import io.cucumber.core.api.TypeRegistryConfigurer; +import io.cucumber.cucumberexpressions.ParameterType; +import io.cucumber.cucumberexpressions.Transformer; + +import static java.util.Locale.ENGLISH; + +public class TypeRegistryConfiguration implements TypeRegistryConfigurer { + + @Override + public Locale locale() { + return ENGLISH; + } + + @Override + public void configureTypeRegistry(TypeRegistry typeRegistry) { + typeRegistry.defineParameterType(new ParameterType<Integer>( + "digit", + "[0-9]", + Integer.class, + new Transformer<Integer>() { + @Override + public Integer transform(String text) { + return Integer.parseInt(text); + } + }) + ); + + typeRegistry.defineParameterType(new ParameterType<Character>( + "operator", + "[+–x\\/=]", + Character.class, + new Transformer<Character>() { + @Override + public Character transform(String text) { + return text.charAt(0); + } + }) + ); + } +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/debug/AndroidManifest.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/debug/AndroidManifest.xml new file mode 100644 index 0000000000..c00cb3f03b --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/debug/AndroidManifest.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="cucumber.cukeulator"> + + <!-- For Cucumber reports, we require the following permissions. + (We place this in this debug manifest to avoid that this permissions are provided by default in release APKs.) --> + <uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /> + <uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" /> + +</manifest> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/AndroidManifest.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..3f5d610f68 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/AndroidManifest.xml @@ -0,0 +1,19 @@ +<?xml version="1.0" encoding="utf-8"?> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" package="cucumber.cukeulator"> + + <application + android:allowBackup="true" + android:icon="@drawable/ic_launcher" + android:label="@string/app_name" + android:theme="@style/AppTheme" > + <activity + android:name="cucumber.cukeulator.CalculatorActivity" + android:label="@string/app_name" > + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> + +</manifest> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/java/cucumber/cukeulator/CalculatorActivity.java b/test_projects/android/cucumber_sample_app/cukeulator/src/main/java/cucumber/cukeulator/CalculatorActivity.java new file mode 100644 index 0000000000..5f97b77d94 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/java/cucumber/cukeulator/CalculatorActivity.java @@ -0,0 +1,152 @@ +package cucumber.cukeulator; + +import android.app.Activity; +import android.os.Bundle; +import android.view.View; +import android.widget.Button; +import android.widget.TextView; + +public class CalculatorActivity extends Activity { + private static enum Operation {ADD, SUB, MULT, DIV, NONE} + + private TextView txtCalcDisplay; + private TextView txtCalcOperator; + private Operation operation; + private boolean decimals; + private boolean resetDisplay; + private boolean performOperation; + private double value; + + @Override + public void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + setContentView(R.layout.activity_calculator); + txtCalcDisplay = (TextView) findViewById(R.id.txt_calc_display); + txtCalcOperator = (TextView) findViewById(R.id.txt_calc_operator); + operation = Operation.NONE; + } + + public void onDigitPressed(View v) { + if (resetDisplay) { + txtCalcDisplay.setText(null); + resetDisplay = false; + } + txtCalcOperator.setText(null); + + if (decimals || !only0IsDisplayed()) txtCalcDisplay.append(((Button) v).getText()); + + if (operation != Operation.NONE) performOperation = true; + } + + public void onOperatorPressed(View v) { + if (performOperation) { + performOperation(); + performOperation = false; + } + switch (v.getId()) { + case R.id.btn_op_divide: + operation = Operation.DIV; + txtCalcOperator.setText("/"); + break; + case R.id.btn_op_multiply: + operation = Operation.MULT; + txtCalcOperator.setText("x"); + break; + case R.id.btn_op_subtract: + operation = Operation.SUB; + txtCalcOperator.setText("–"); + break; + case R.id.btn_op_add: + operation = Operation.ADD; + txtCalcOperator.setText("+"); + break; + case R.id.btn_op_equals: + break; + default: + throw new RuntimeException("Unsupported operation."); + } + resetDisplay = true; + value = getDisplayValue(); + } + + public void onSpecialPressed(View v) { + switch (v.getId()) { + case R.id.btn_spec_sqroot: { + double value = getDisplayValue(); + double sqrt = Math.sqrt(value); + txtCalcDisplay.setText(Double.toString(sqrt)); + break; + } + case R.id.btn_spec_pi: { + resetDisplay = false; + txtCalcOperator.setText(null); + txtCalcDisplay.setText(Double.toString(Math.PI)); + if (operation != Operation.NONE) performOperation = true; + return; + } + case R.id.btn_spec_percent: { + double value = getDisplayValue(); + double percent = value / 100.0F; + txtCalcDisplay.setText(Double.toString(percent)); + break; + } + case R.id.btn_spec_comma: { + if (!decimals) { + String text = displayIsEmpty() ? "0." : "."; + txtCalcDisplay.append(text); + decimals = true; + } + break; + } + case R.id.btn_spec_clear: { + value = 0; + decimals = false; + operation = Operation.NONE; + txtCalcDisplay.setText(null); + txtCalcOperator.setText(null); + break; + } + } + resetDisplay = false; + performOperation = false; + } + + private void performOperation() { + double display = getDisplayValue(); + + switch (operation) { + case DIV: + value = value / display; + break; + case MULT: + value = value * display; + break; + case SUB: + value = value - display; + break; + case ADD: + value = value + display; + break; + case NONE: + return; + default: + throw new RuntimeException("Unsupported operation."); + } + txtCalcOperator.setText(null); + txtCalcDisplay.setText(Double.toString(value)); + } + + private boolean only0IsDisplayed() { + CharSequence text = txtCalcDisplay.getText(); + return text.length() == 1 && text.charAt(0) == '0'; + } + + private boolean displayIsEmpty() { + return txtCalcDisplay.getText().length() == 0; + } + + private double getDisplayValue() { + String display = txtCalcDisplay.getText().toString(); + return display.isEmpty() ? 0.0F : Double.parseDouble(display); + } +} diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-hdpi/ic_launcher.png b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-hdpi/ic_launcher.png new file mode 100755 index 0000000000000000000000000000000000000000..019f515cadfec581021dcbb023d4d7dcd4a699b2 GIT binary patch literal 6468 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 literal 0 HcmV?d00001 diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-mdpi/ic_launcher.png b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-mdpi/ic_launcher.png new file mode 100755 index 0000000000000000000000000000000000000000..1838c339a6cca308f5ee9aac6091784d15eb109e GIT binary patch literal 3664 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 literal 0 HcmV?d00001 diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-xhdpi/ic_launcher.png b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-xhdpi/ic_launcher.png new file mode 100755 index 0000000000000000000000000000000000000000..2f5cc8fe8721bdc192b7436ba3a125580ac4e429 GIT binary patch literal 9291 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 literal 0 HcmV?d00001 diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-xxhdpi/ic_launcher.png b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/drawable-xxhdpi/ic_launcher.png new file mode 100755 index 0000000000000000000000000000000000000000..1814a2578e7575ee9edf598ade0211cf0e2f4212 GIT binary patch literal 16093 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 literal 0 HcmV?d00001 diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/layout/activity_calculator.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/layout/activity_calculator.xml new file mode 100644 index 0000000000..57d8882632 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/layout/activity_calculator.xml @@ -0,0 +1,187 @@ +<?xml version="1.0" encoding="utf-8"?> +<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android" + android:layout_width="match_parent" + android:layout_height="match_parent"> + + <TextView + android:id="@+id/txt_calc_operator" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="24dp" + android:layout_marginLeft="10dp" + android:layout_marginRight="10dp" + android:textSize="24dp" + android:gravity="center" + android:layout_alignLeft="@+id/layout_calc_btns" + android:layout_above="@+id/layout_calc_btns"/> + + <TextView + android:id="@+id/txt_calc_display" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="24dp" + android:layout_marginLeft="10dp" + android:layout_marginRight="10dp" + android:textSize="24dp" + android:gravity="right" + android:layout_alignRight="@+id/layout_calc_btns" + android:layout_above="@+id/layout_calc_btns" + android:layout_toRightOf="@+id/txt_calc_operator" + android:hint="@string/txt_calc_display_hint"/> + + <GridLayout + android:id="@+id/layout_calc_btns" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:layout_marginBottom="24dp" + android:layout_centerHorizontal="true" + android:layout_alignParentBottom="true" + android:useDefaultMargins="false" + android:columnCount="4"> + + <!-- row 4 --> + <Button + android:id="@+id/btn_spec_sqroot" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onSpecialPressed" + android:text="√"/> + + <Button + android:id="@+id/btn_spec_pi" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onSpecialPressed" + android:text="π"/> + + <Button + android:id="@+id/btn_spec_percent" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onSpecialPressed" + android:text="%"/> + + <Button + android:id="@+id/btn_spec_clear" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onSpecialPressed" + android:text="C"/> + + <!-- row 3 --> + <Button + android:id="@+id/btn_d_7" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="7"/> + + <Button + android:id="@+id/btn_d_8" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="8"/> + + <Button + android:id="@+id/btn_d_9" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="9"/> + + <Button + android:id="@+id/btn_op_divide" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onOperatorPressed" + android:text="/"/> + + <!-- row 2 --> + <Button + android:id="@+id/btn_d_4" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="4"/> + + <Button + android:id="@+id/btn_d_5" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="5"/> + + <Button + android:id="@+id/btn_d_6" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="6"/> + + <Button + android:id="@+id/btn_op_multiply" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onOperatorPressed" + android:text="x"/> + + <!-- row 1 --> + <Button + android:id="@+id/btn_d_1" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="1"/> + + <Button + android:id="@+id/btn_d_2" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="2"/> + + <Button + android:id="@+id/btn_d_3" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="3"/> + + <Button + android:id="@+id/btn_op_subtract" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onOperatorPressed" + android:text="–"/> + + <!-- row 0 --> + <Button + android:id="@+id/btn_d_0" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onDigitPressed" + android:text="0"/> + + <Button + android:id="@+id/btn_spec_comma" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onSpecialPressed" + android:text="."/> + + <Button + android:id="@+id/btn_op_equals" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onOperatorPressed" + android:text="="/> + + <Button + android:id="@+id/btn_op_add" + android:layout_width="wrap_content" + android:layout_height="wrap_content" + android:onClick="onOperatorPressed" + android:text="+"/> + </GridLayout> +</RelativeLayout> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/menu/menu_main.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/menu/menu_main.xml new file mode 100644 index 0000000000..87a750ece0 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/menu/menu_main.xml @@ -0,0 +1,5 @@ +<menu xmlns:android="http://schemas.android.com/apk/res/android" + xmlns:tools="http://schemas.android.com/tools" tools:context=".MainActivity"> + <item android:id="@+id/action_settings" android:title="@string/action_settings" + android:orderInCategory="100" android:showAsAction="never" /> +</menu> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-v21/styles.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-v21/styles.xml new file mode 100644 index 0000000000..dba3c417be --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-v21/styles.xml @@ -0,0 +1,5 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + <style name="AppTheme" parent="android:Theme.Material.Light"> + </style> +</resources> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-w820dp/dimens.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-w820dp/dimens.xml new file mode 100644 index 0000000000..63fc816444 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values-w820dp/dimens.xml @@ -0,0 +1,6 @@ +<resources> + <!-- Example customization of dimensions originally defined in res/values/dimens.xml + (such as screen margins) for screens with more than 820dp of available width. This + would include 7" and 10" devices in landscape (~960dp and ~1280dp respectively). --> + <dimen name="activity_horizontal_margin">64dp</dimen> +</resources> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/dimens.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/dimens.xml new file mode 100644 index 0000000000..47c8224673 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/dimens.xml @@ -0,0 +1,5 @@ +<resources> + <!-- Default screen margins, per the Android Design guidelines. --> + <dimen name="activity_horizontal_margin">16dp</dimen> + <dimen name="activity_vertical_margin">16dp</dimen> +</resources> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/strings.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/strings.xml new file mode 100644 index 0000000000..1d58a11872 --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/strings.xml @@ -0,0 +1,9 @@ +<?xml version="1.0" encoding="utf-8"?> +<resources> + + <string name="app_name">Cukeulator</string> + <string name="hello_world">Hello world!</string> + <string name="action_settings">Settings</string> + <string name="txt_calc_display_hint">0.0</string> + +</resources> diff --git a/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/styles.xml b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/styles.xml new file mode 100644 index 0000000000..ff6c9d2c0f --- /dev/null +++ b/test_projects/android/cucumber_sample_app/cukeulator/src/main/res/values/styles.xml @@ -0,0 +1,8 @@ +<resources> + + <!-- Base application theme. --> + <style name="AppTheme" parent="android:Theme.Holo.Light.DarkActionBar"> + <!-- Customize your theme here. --> + </style> + +</resources> diff --git a/test_projects/android/multi-modules/multiapp/.gitignore b/test_projects/android/multi-modules/multiapp/.gitignore index 796b96d1c4..cfabd204f6 100644 --- a/test_projects/android/multi-modules/multiapp/.gitignore +++ b/test_projects/android/multi-modules/multiapp/.gitignore @@ -1 +1,2 @@ /build +*.apk diff --git a/test_projects/android/ops.sh b/test_projects/android/ops.sh old mode 100644 new mode 100755 index fc98c7de9d..00636bef56 --- a/test_projects/android/ops.sh +++ b/test_projects/android/ops.sh @@ -113,4 +113,24 @@ function multi_module_apks() { esac done } +function cucumber_sample_app() { + local dir=$TEST_PROJECTS_ANDROID + local outputDir="$FLANK_FIXTURES_TMP/apk/cucumber_sample_app/" + + for arg in "$@"; do case "$arg" in + + '--generate' | '-g') + "$dir/gradlew" -p "$dir" \ + :cucumber_sample_app:cukeulator:assemble \ + :cucumber_sample_app:cukeulator:assembleAndroidTest + ;; + + '--copy' | '-c') + mkdir -p "$outputDir" + find "$dir/cucumber_sample_app" -type f -name "*.apk" -exec cp {} "$outputDir" \; + ;; + + esac done +} + echo "Android test projects ops loaded" diff --git a/test_projects/android/settings.gradle b/test_projects/android/settings.gradle index c60d26caa1..8a4647ef91 100644 --- a/test_projects/android/settings.gradle +++ b/test_projects/android/settings.gradle @@ -27,3 +27,7 @@ include ':app', ':dir1:testModule', ':dir2:testModule', ':dir3:testModule' + + +include ':cucumber_sample_app' +include ':cucumber_sample_app:cucumber-android', ':cucumber_sample_app:cukeulator' diff --git a/test_projects/ops.sh b/test_projects/ops.sh old mode 100644 new mode 100755