diff options
author | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2021-07-15 01:40:55 +0000 |
---|---|---|
committer | Android Build Coastguard Worker <android-build-coastguard-worker@google.com> | 2021-07-15 01:40:55 +0000 |
commit | 25958d002011d586da7b2ab5fae142f1270f57a2 (patch) | |
tree | 13ff98e650cd3c8e0fdee0e6d88b15ef88300fe2 | |
parent | 5c86fc9b9b042528f9af6ea7c53c22f892ed3b2f (diff) | |
parent | a85afa03f8b5e8e413533e318ae6fb5bc5c55dd8 (diff) | |
download | csuite-android-mainline-12.0.0_r53.tar.gz |
Snap for 7550844 from a85afa03f8b5e8e413533e318ae6fb5bc5c55dd8 to mainline-tethering-releaseandroid-mainline-12.0.0_r95android-mainline-12.0.0_r82android-mainline-12.0.0_r66android-mainline-12.0.0_r53android-mainline-12.0.0_r38android-mainline-12.0.0_r18android-mainline-12.0.0_r125android-mainline-12.0.0_r110aml_tet_311811050android12-mainline-tethering-release
Change-Id: Icfb5bbd122137fc886d70fc1d3b059e53c7249a2
74 files changed, 6047 insertions, 1933 deletions
diff --git a/Android.bp b/Android.bp new file mode 100644 index 0000000..5295e32 --- /dev/null +++ b/Android.bp @@ -0,0 +1,29 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +python_defaults { + name: "csuite_python_defaults", + version: { + py2: { + enabled: false, + }, + py3: { + enabled: true, + }, + }, +} @@ -0,0 +1,5 @@ +adirao@google.com +fdeng@google.com +hzalek@google.com +yuexima@google.com +zhuoyao@google.com diff --git a/PREUPLOAD.cfg b/PREUPLOAD.cfg index 948fa79..91c7914 100644 --- a/PREUPLOAD.cfg +++ b/PREUPLOAD.cfg @@ -1,5 +1,6 @@ [Builtin Hooks] bpfmt = true +gofmt = true google_java_format = true pylint = true xmllint = true diff --git a/harness/Android.bp b/harness/Android.bp index 2dec437..5111c1b 100644 --- a/harness/Android.bp +++ b/harness/Android.bp @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + java_library_host { name: "csuite-harness", srcs: [ @@ -23,6 +27,9 @@ java_library_host { libs: [ "tradefed", ], + static_libs: [ + "compatibility-tradefed", + ] } java_test_host { @@ -30,14 +37,17 @@ java_test_host { srcs: [ "src/test/java/**/*.java", ], - libs: [ - "csuite-harness", - "tradefed", - ], static_libs: [ + "tradefed", + "csuite-harness", + "compatibility-tradefed", + "guava-testlib", + "jimfs", "mockito-host", "objenesis", "testng", ], - test_suites: ["general-tests"], + test_options: { + unit_test: true, + }, } diff --git a/harness/AndroidTest.xml b/harness/AndroidTest.xml deleted file mode 100644 index f3fb637..0000000 --- a/harness/AndroidTest.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2019 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> -<configuration description="Executes the C-Suite harness unit tests"> - <test class="com.android.tradefed.testtype.HostTest" > - <option name="class" value="com.android.compatibility.CSuiteUnitTests" /> - </test> -</configuration> diff --git a/harness/TEST_MAPPING b/harness/TEST_MAPPING deleted file mode 100644 index 50ed1cd..0000000 --- a/harness/TEST_MAPPING +++ /dev/null @@ -1,8 +0,0 @@ -{ - "postsubmit": [ - { - "name": "csuite-harness-tests", - "host": true - } - ] -} diff --git a/harness/src/main/java/com/android/compatibility/AppCompatibilityTest.java b/harness/src/main/java/com/android/compatibility/AppCompatibilityTest.java deleted file mode 100644 index 74c4907..0000000 --- a/harness/src/main/java/com/android/compatibility/AppCompatibilityTest.java +++ /dev/null @@ -1,671 +0,0 @@ -/* - * Copyright (C) 2012 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.compatibility; - -import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; - -import com.android.tradefed.config.ConfigurationException; -import com.android.tradefed.config.IConfiguration; -import com.android.tradefed.config.IConfigurationReceiver; -import com.android.tradefed.config.Option; -import com.android.tradefed.config.OptionCopier; -import com.android.tradefed.device.DeviceNotAvailableException; -import com.android.tradefed.device.DeviceUnresponsiveException; -import com.android.tradefed.device.ITestDevice; -import com.android.tradefed.device.LogcatReceiver; -import com.android.tradefed.log.LogUtil.CLog; -import com.android.tradefed.metrics.proto.MetricMeasurement.Metric; -import com.android.tradefed.result.ByteArrayInputStreamSource; -import com.android.tradefed.result.CompatibilityTestResult; -import com.android.tradefed.result.ITestInvocationListener; -import com.android.tradefed.result.InputStreamSource; -import com.android.tradefed.result.LogDataType; -import com.android.tradefed.result.TestDescription; -import com.android.tradefed.testtype.IDeviceTest; -import com.android.tradefed.testtype.IRemoteTest; -import com.android.tradefed.testtype.IShardableTest; -import com.android.tradefed.testtype.ITestFilterReceiver; -import com.android.tradefed.testtype.InstrumentationTest; -import com.android.tradefed.util.AaptParser; -import com.android.tradefed.util.FileUtil; -import com.android.tradefed.util.IRunUtil; -import com.android.tradefed.util.PublicApkUtil; -import com.android.tradefed.util.PublicApkUtil.ApkInfo; -import com.android.tradefed.util.RunUtil; -import com.android.tradefed.util.StreamUtil; - -import com.google.common.base.Strings; - -import org.json.JSONException; -import org.junit.Assert; - -import java.io.ByteArrayOutputStream; -import java.io.File; -import java.io.IOException; -import java.util.ArrayList; -import java.util.Collection; -import java.util.Collections; -import java.util.HashMap; -import java.util.HashSet; -import java.util.List; -import java.util.Set; - -/** - * Test that determines application compatibility. The test iterates through the apks in a given - * directory. The test installs, launches, and uninstalls each apk. - */ -public abstract class AppCompatibilityTest - implements IDeviceTest, - IRemoteTest, - IShardableTest, - IConfigurationReceiver, - ITestFilterReceiver { - - @Option( - name = "product", - description = "The product, corresponding to the borgcron job product arg.") - private String mProduct; - - @Option( - name = "base-dir", - description = "The directory of the results excluding the date.", - importance = Option.Importance.ALWAYS) - // TODO(b/36786754): Add `mandatory = true` when cmdfiles are moved over - private File mBaseDir; - - @Option( - name = "date", - description = - "The date to run, in the form YYYYMMDD. If not set, then the latest " - + "results will be used.") - private String mDate; - - @Option(name = "test-label", description = "Unique test identifier label.") - private String mTestLabel = "AppCompatibility"; - - @Option( - name = "reboot-after-apks", - description = "Reboot the device after a centain number of apks. 0 means no reboot.") - private int mRebootNumber = 100; - - @Option( - name = "fallback-to-apk-scan", - description = - "Fallback to scanning for apks in base directory if ranking information " - + "is missing.") - private boolean mFallbackToApkScan = false; - - @Option( - name = "retry-count", - description = "Number of times to retry a failed test case. 0 means no retry.") - private int mRetryCount = 5; - - @Option(name = "include-filter", description = "The include filter of the test names to run.") - protected Set<String> mIncludeFilters = new HashSet<>(); - - @Option(name = "exclude-filter", description = "The exclude filter of the test names to run.") - protected Set<String> mExcludeFilters = new HashSet<>(); - - private static final long DOWNLOAD_TIMEOUT_MS = 60 * 1000; - private static final int DOWNLOAD_RETRIES = 3; - private static final long JOIN_TIMEOUT_MS = 5 * 60 * 1000; - private static final int LOGCAT_SIZE_BYTES = 20 * 1024 * 1024; - - private ITestDevice mDevice; - private LogcatReceiver mLogcat; - private IConfiguration mConfiguration; - - // The number of tests run so far - private int mTestCount = 0; - - // indicates the current sharding setup - private int mShardCount = 1; - private int mShardIndex = 0; - - protected final String mLauncherPackage; - protected final String mRunnerClass; - protected final String mPackageBeingTestedKey; - - protected AppCompatibilityTest( - String launcherPackage, String runnerClass, String packageBeingTestedKey) { - this.mLauncherPackage = launcherPackage; - this.mRunnerClass = runnerClass; - this.mPackageBeingTestedKey = packageBeingTestedKey; - } - - /** - * Creates and sets up an instrumentation test with information about the test runner as well as - * the package being tested (provided as a parameter). - */ - protected abstract InstrumentationTest createInstrumentationTest(String packageBeingTested); - - /** Sets up some default aspects of the instrumentation test. */ - protected final InstrumentationTest createDefaultInstrumentationTest( - String packageBeingTested) { - InstrumentationTest instrTest = new InstrumentationTest(); - instrTest.setPackageName(mLauncherPackage); - instrTest.setConfiguration(mConfiguration); - instrTest.addInstrumentationArg(mPackageBeingTestedKey, packageBeingTested); - instrTest.setRunnerName(mRunnerClass); - return instrTest; - } - - /* - * {@inheritDoc} - */ - @Override - public final void run(ITestInvocationListener listener) throws DeviceNotAvailableException { - CLog.d("Start of launch test run method. base-dir: %s", mBaseDir); - Assert.assertNotNull("Base dir cannot be null", mBaseDir); - Assert.assertTrue("Base dir should be a directory", mBaseDir.isDirectory()); - - if (mProduct == null) { - mProduct = mDevice.getProductType(); - CLog.i("\"--product\" not specified, using property from device instead: %s", mProduct); - } - Assert.assertTrue( - String.format( - "Shard index out of range: expected [0, %d), got %d", - mShardCount, mShardIndex), - mShardIndex >= 0 && mShardIndex < mShardCount); - - File apkDir = null; - try { - apkDir = PublicApkUtil.constructApkDir(mBaseDir.getPath(), mDate); - } catch (IOException e) { - CLog.e(e); - throw new RuntimeException(e); - } - CLog.d("apkDir: %s.", apkDir); - Assert.assertNotNull("Could not find the output dir", apkDir); - List<ApkInfo> apkList = null; - try { - apkList = shardApkList(PublicApkUtil.getApkList(mProduct, apkDir, mFallbackToApkScan)); - } catch (IOException e) { - CLog.e(e); - throw new RuntimeException(e); - } - CLog.d("Completed sharding apkList. Number of items: %s", apkList.size()); - Assert.assertNotNull("Could not download apk list", apkList); - - apkList = filterApk(apkList); - CLog.d("Completed filtering apkList. Number of items: %s", apkList.size()); - - long start = System.currentTimeMillis(); - listener.testRunStarted(mTestLabel, apkList.size()); - mLogcat = new LogcatReceiver(getDevice(), LOGCAT_SIZE_BYTES, 0); - mLogcat.start(); - - try { - downloadAndTestApks(listener, apkDir, apkList); - } catch (InterruptedException e) { - CLog.e(e); - throw new RuntimeException(e); - } finally { - mLogcat.stop(); - listener.testRunEnded( - System.currentTimeMillis() - start, new HashMap<String, Metric>()); - } - } - - /** - * Downloads and tests all the APKs in the apk list. - * - * @param listener The {@link ITestInvocationListener}. - * @param kharonDir The {@link File} of the CNS dir containing the APKs. - * @param apkList The sharded list of {@link ApkInfo} objects. - * @throws DeviceNotAvailableException - * @throws InterruptedException if a download thread was interrupted. - */ - private void downloadAndTestApks( - ITestInvocationListener listener, File kharonDir, List<ApkInfo> apkList) - throws DeviceNotAvailableException, InterruptedException { - CLog.d("Started downloading and testing apks."); - ApkInfo testingApk = null; - File testingFile = null; - for (ApkInfo downloadingApk : apkList) { - ApkDownloadRunnable downloader = new ApkDownloadRunnable(kharonDir, downloadingApk); - Thread downloadThread = new Thread(downloader); - downloadThread.start(); - - testApk(listener, testingApk, testingFile); - - try { - downloadThread.join(JOIN_TIMEOUT_MS); - } catch (InterruptedException e) { - FileUtil.deleteFile(downloader.getDownloadedFile()); - throw e; - } - testingApk = downloadingApk; - testingFile = downloader.getDownloadedFile(); - } - // One more time since the first time through the loop we don't test - testApk(listener, testingApk, testingFile); - CLog.d("Completed downloading and testing apks."); - } - - /** - * Attempts to install and launch an APK and reports the results. - * - * @param listener The {@link ITestInvocationListener}. - * @param apkInfo The {@link ApkInfo} to run the test against. - * @param apkFile The downloaded {@link File}. - * @throws DeviceNotAvailableException - */ - private void testApk(ITestInvocationListener listener, ApkInfo apkInfo, File apkFile) - throws DeviceNotAvailableException { - if (apkInfo == null || apkFile == null) { - CLog.d("apkInfo or apkFile is null."); - FileUtil.deleteFile(apkFile); - return; - } - CLog.d( - "Started testing package: %s, apk file: %s.", - apkInfo.packageName, apkFile.getAbsolutePath()); - - mTestCount++; - if (mRebootNumber != 0 && mTestCount % mRebootNumber == 0) { - mDevice.reboot(); - } - mLogcat.clear(); - - TestDescription testId = createTestDescription(apkInfo.packageName); - listener.testStarted(testId, System.currentTimeMillis()); - - CompatibilityTestResult result = new CompatibilityTestResult(); - result.rank = apkInfo.rank; - // Default to package name since name is a required field. This will be replaced by - // AaptParser in installApk() - result.name = apkInfo.packageName; - result.packageName = apkInfo.packageName; - result.versionString = apkInfo.versionString; - result.versionCode = apkInfo.versionCode; - - try { - // Install the app, and also skip aapt check if we've fell back to apk scan - installApk(result, apkFile, mFallbackToApkScan); - boolean installationSuccess = result.status == null; - - for (int i = 0; i <= mRetryCount; i++) { - if (installationSuccess) { - // Clear test result between retries - result.status = null; - result.message = null; - launchApk(result); - mDevice.executeShellCommand( - String.format("am force-stop %s", apkInfo.packageName)); - } - if (result.status == null) { - result.status = CompatibilityTestResult.STATUS_SUCCESS; - break; - } - } - - if (installationSuccess) { - mDevice.uninstallPackage(result.packageName); - } - } finally { - reportResult(listener, testId, result); - try { - postLogcat(result, listener); - } catch (JSONException e) { - CLog.w("Posting failed: %s.", e.getMessage()); - } - listener.testEnded( - testId, System.currentTimeMillis(), Collections.<String, String>emptyMap()); - FileUtil.deleteFile(apkFile); - CLog.d("Completed testing package: %s.", apkInfo.packageName); - } - } - - /** - * Checks that the file is correct and attempts to install it. - * - * <p>Will set the result status to error if the APK could not be installed or if it contains - * conflicting information. - * - * @param result the {@link CompatibilityTestResult} containing the APK info. - * @param apkFile the APK file to install. - * @throws DeviceNotAvailableException - */ - private void installApk(CompatibilityTestResult result, File apkFile, boolean skipAaptCheck) - throws DeviceNotAvailableException { - if (!skipAaptCheck) { - CLog.d("Parsing apk file: %s.", apkFile.getAbsolutePath()); - AaptParser parser = AaptParser.parse(apkFile); - if (parser == null) { - CLog.d( - "Failed to parse apk file: %s, package: %s, error: %s.", - apkFile.getAbsolutePath(), result.packageName, result.message); - result.status = CompatibilityTestResult.STATUS_ERROR; - result.message = "aapt fail"; - return; - } - - result.name = parser.getLabel(); - - if (!equalsOrNull(result.packageName, parser.getPackageName()) - || !equalsOrNull(result.versionString, parser.getVersionName()) - || !equalsOrNull(result.versionCode, parser.getVersionCode())) { - CLog.d( - "Package info mismatch: want %s v%s (%s), got %s v%s (%s)", - result.packageName, - result.versionCode, - result.versionString, - parser.getPackageName(), - parser.getVersionCode(), - parser.getVersionName()); - result.status = CompatibilityTestResult.STATUS_ERROR; - result.message = "package info mismatch"; - return; - } - CLog.d("Completed parsing apk file: %s.", apkFile.getAbsolutePath()); - } - - try { - String error = mDevice.installPackage(apkFile, true); - if (error != null) { - result.status = CompatibilityTestResult.STATUS_ERROR; - result.message = error; - CLog.d( - "Failed to install apk file: %s, package: %s, error: %s.", - apkFile.getAbsolutePath(), result.packageName, result.message); - return; - } - } catch (DeviceUnresponsiveException e) { - result.status = CompatibilityTestResult.STATUS_ERROR; - result.message = "install timeout"; - CLog.d( - "Installing apk file %s timed out, package: %s, error: %s.", - apkFile.getAbsolutePath(), result.packageName, result.message); - return; - } - CLog.d("Completed installing apk file %s.", apkFile.getAbsolutePath()); - } - - /** - * Method which attempts to launch an APK. - * - * <p>Will set the result status to failure if the APK could not be launched. - * - * @param result the {@link CompatibilityTestResult} containing the APK info. - * @throws DeviceNotAvailableException - */ - private void launchApk(CompatibilityTestResult result) throws DeviceNotAvailableException { - CLog.d("Lauching package: %s.", result.packageName); - InstrumentationTest instrTest = createInstrumentationTest(result.packageName); - instrTest.setDevice(mDevice); - - FailureCollectingListener failureListener = new FailureCollectingListener(); - instrTest.run(failureListener); - - if (failureListener.getStackTrace() != null) { - CLog.w("Failed to launch package: %s.", result.packageName); - result.status = CompatibilityTestResult.STATUS_FAILURE; - result.message = failureListener.getStackTrace(); - } - - CLog.d("Completed launching package: %s", result.packageName); - } - - /** Helper method which reports a test failed if the status is either a failure or an error. */ - private void reportResult( - ITestInvocationListener listener, TestDescription id, CompatibilityTestResult result) { - String message = result.message != null ? result.message : "unknown"; - if (CompatibilityTestResult.STATUS_ERROR.equals(result.status)) { - listener.testFailed(id, "ERROR:" + message); - } else if (CompatibilityTestResult.STATUS_FAILURE.equals(result.status)) { - listener.testFailed(id, "FAILURE:" + message); - } - } - - /** Helper method which posts the logcat. */ - private void postLogcat(CompatibilityTestResult result, ITestInvocationListener listener) - throws JSONException { - InputStreamSource stream = null; - String header = - String.format( - "%s%s%s\n", - CompatibilityTestResult.SEPARATOR, - result.toJsonString(), - CompatibilityTestResult.SEPARATOR); - ByteArrayOutputStream baos = new ByteArrayOutputStream(); - try (InputStreamSource logcatData = mLogcat.getLogcatData()) { - try { - baos.write(header.getBytes()); - StreamUtil.copyStreams(logcatData.createInputStream(), baos); - stream = new ByteArrayInputStreamSource(baos.toByteArray()); - baos.flush(); - baos.close(); - } catch (IOException e) { - CLog.e("error inserting compatibility test result into logcat"); - CLog.e(e); - // fallback to logcat data - stream = logcatData; - } - listener.testLog("logcat_" + result.packageName, LogDataType.LOGCAT, stream); - } finally { - StreamUtil.cancel(stream); - } - } - - /** Helper method which takes a list of {@link ApkInfo} objects and returns the sharded list. */ - private List<ApkInfo> shardApkList(List<ApkInfo> apkList) { - List<ApkInfo> shardedList = new ArrayList<>(apkList.size() / mShardCount + 1); - for (int i = mShardIndex; i < apkList.size(); i += mShardCount) { - shardedList.add(apkList.get(i)); - } - return shardedList; - } - - /** - * Helper method which takes a list of {@link ApkInfo} objects and returns the filtered list. - */ - protected List<ApkInfo> filterApk(List<ApkInfo> apkList) { - List<ApkInfo> filteredList = new ArrayList<>(); - - for (ApkInfo apk : apkList) { - if (filterTest(apk.packageName)) { - filteredList.add(apk); - } - } - - return filteredList; - } - - /** - * Return true if a test matches one or more of the include filters AND does not match any of - * the exclude filters. If no include filters are given all tests should return true as long as - * they do not match any of the exclude filters. - */ - protected boolean filterTest(String testName) { - if (mExcludeFilters.contains(testName)) { - return false; - } - if (mIncludeFilters.size() == 0 || mIncludeFilters.contains(testName)) { - return true; - } - return false; - } - - /** Returns true if either object is null or if both objects are equal. */ - private static boolean equalsOrNull(Object a, Object b) { - return a == null || b == null || a.equals(b); - } - - /** Helper {@link Runnable} which downloads a file, and can be used in another thread. */ - private class ApkDownloadRunnable implements Runnable { - private final File mKharonDir; - private final ApkInfo mApkInfo; - - private File mDownloadedFile = null; - - ApkDownloadRunnable(File kharonDir, ApkInfo apkInfo) { - mKharonDir = kharonDir; - mApkInfo = apkInfo; - } - - @Override - public void run() { - // No-op if mApkInfo is null - if (mApkInfo == null) { - CLog.d("ApkInfo is null."); - return; - } - - File sourceFile = new File(mKharonDir, mApkInfo.fileName); - try { - mDownloadedFile = - PublicApkUtil.downloadFile( - sourceFile, DOWNLOAD_TIMEOUT_MS, DOWNLOAD_RETRIES); - } catch (IOException e) { - // Log and ignore - CLog.e("Could not download apk from %s.", sourceFile); - CLog.e(e); - } - CLog.d("Completed downloading apk file: %s.", mDownloadedFile.getAbsolutePath()); - } - - public File getDownloadedFile() { - return mDownloadedFile; - } - } - - @Override - public void setConfiguration(IConfiguration configuration) { - mConfiguration = configuration; - } - - /* - * {@inheritDoc} - */ - @Override - public void setDevice(ITestDevice device) { - mDevice = device; - } - - /* - * {@inheritDoc} - */ - @Override - public ITestDevice getDevice() { - return mDevice; - } - - /** Return a {@link IRunUtil} instance to execute commands with. */ - IRunUtil getRunUtil() { - return RunUtil.getDefault(); - } - - private IRemoteTest getTestShard(int shardCount, int shardIndex) { - AppCompatibilityTest shard; - try { - shard = getClass().newInstance(); - } catch (InstantiationException | IllegalAccessException e) { - throw new IllegalStateException( - "The class " - + getClass().getName() - + " has no public constructor with no arguments, but all subclasses of " - + AppCompatibilityTest.class.getName() - + " should", - e); - } - try { - OptionCopier.copyOptions(this, shard); - } catch (ConfigurationException e) { - CLog.e("Failed to copy test options: %s.", e.getMessage()); - } - shard.mShardIndex = shardIndex; - shard.mShardCount = shardCount; - return shard; - } - - /** {@inheritDoc} */ - @Override - public Collection<IRemoteTest> split(int shardCountHint) { - if (shardCountHint <= 1) { - // cannot shard or already sharded - return null; - } - Collection<IRemoteTest> shards = new ArrayList<>(shardCountHint); - for (int index = 0; index < shardCountHint; index++) { - shards.add(getTestShard(shardCountHint, index)); - } - return shards; - } - - /** - * Get a test description for use in logging. For compatibility with logs, this should be - * TestDescription(launcher package, package being run). - */ - private TestDescription createTestDescription(String packageBeingTested) { - return new TestDescription(mLauncherPackage, packageBeingTested); - } - - /** {@inheritDoc} */ - @Override - public void addIncludeFilter(String filter) { - checkArgument(!Strings.isNullOrEmpty(filter), "Include filter cannot be null or empty."); - mIncludeFilters.add(filter); - } - - /** {@inheritDoc} */ - @Override - public void addAllIncludeFilters(Set<String> filters) { - checkNotNull(filters, "Include filters cannot be null."); - mIncludeFilters.addAll(filters); - } - - /** {@inheritDoc} */ - @Override - public void clearIncludeFilters() { - mIncludeFilters.clear(); - } - - /** {@inheritDoc} */ - @Override - public Set<String> getIncludeFilters() { - return Collections.unmodifiableSet(mIncludeFilters); - } - - /** {@inheritDoc} */ - @Override - public void addExcludeFilter(String filter) { - checkArgument(!Strings.isNullOrEmpty(filter), "Exclude filter cannot be null or empty."); - mExcludeFilters.add(filter); - } - - /** {@inheritDoc} */ - @Override - public void addAllExcludeFilters(Set<String> filters) { - checkNotNull(filters, "Exclude filters cannot be null."); - mExcludeFilters.addAll(filters); - } - - /** {@inheritDoc} */ - @Override - public void clearExcludeFilters() { - mExcludeFilters.clear(); - } - - /** {@inheritDoc} */ - @Override - public Set<String> getExcludeFilters() { - return Collections.unmodifiableSet(mExcludeFilters); - } -} diff --git a/harness/src/main/java/com/android/compatibility/AppCrawlerCompatibilityTest.java b/harness/src/main/java/com/android/compatibility/AppCrawlerCompatibilityTest.java deleted file mode 100644 index 5c0fe11..0000000 --- a/harness/src/main/java/com/android/compatibility/AppCrawlerCompatibilityTest.java +++ /dev/null @@ -1,82 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.compatibility; - -import com.android.tradefed.config.Option; -import com.android.tradefed.config.OptionClass; -import com.android.tradefed.result.TestDescription; -import com.android.tradefed.testtype.InstrumentationTest; - -import java.util.ArrayList; -import java.util.Arrays; - -@OptionClass(alias = "app-compatibility-crawler") -public final class AppCrawlerCompatibilityTest extends AppCompatibilityTest { - private static final String WALKMAN_RUN_MS_LABEL = "maxDuration"; - private static final String WALKMAN_STEPS_LABEL = "maxSteps"; - - @Option( - name = "walkman-run-ms", - description = "Time to run walkman in msecs (only used if test-strategy=walkman).") - private int mWalkmanRunMs = 60 * 1000; - - @Option( - name = "walkman-steps", - description = - "Max number of steps to run walkman (only used if test-strategy=walkman)." - + " -1 for no limit") - private int mWalkmanSteps = -1; - - public AppCrawlerCompatibilityTest() { - super( - "com.google.android.apps.common.walkman.apps", - "com.google.android.apps.common.testing.testrunner" - + ".Google3InstrumentationTestRunner", - /* - * We are using /google/data/ro/teams/walkman/walkman.apk which has parameter - * "packages" unlike the up-to-date version in source which uses "package" - * see: com.google.android.apps.common.walkman.apps.EngineFactory::getCrawlEngine. - * This currently works with the up-to-date version in source, as well. - * - * Neither of these should be confused with "package_to_launch", which is used by - * AppCompatibilityRunner - */ - "packages"); - } - - @Override - public InstrumentationTest createInstrumentationTest(String packageBeingTested) { - InstrumentationTest instrTest = createDefaultInstrumentationTest(packageBeingTested); - - instrTest.addInstrumentationArg(WALKMAN_RUN_MS_LABEL, Integer.toString(mWalkmanRunMs)); - instrTest.addInstrumentationArg(WALKMAN_STEPS_LABEL, Integer.toString(mWalkmanSteps)); - - String launcherClass = mLauncherPackage + ".WalkmanInstrumentationEntry"; - instrTest.setClassName(launcherClass); - /* - * InstrumentationTest can't deduce the exact test to run, so we specify it - * manually. Note that the TestDescription we use here is a different one from - * the one returned by {@link TestStrategy#createTestDescription}. - * - * This list is required to be mutable, so we wrap in ArrayList. - */ - instrTest.setTestsToRun( - new ArrayList<>(Arrays.asList(new TestDescription(launcherClass, "testEntry")))); - - return instrTest; - } -} diff --git a/harness/src/main/java/com/android/compatibility/AppLaunchCompatibilityTest.java b/harness/src/main/java/com/android/compatibility/AppLaunchCompatibilityTest.java deleted file mode 100644 index dd1f648..0000000 --- a/harness/src/main/java/com/android/compatibility/AppLaunchCompatibilityTest.java +++ /dev/null @@ -1,55 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.compatibility; - -import com.android.tradefed.config.Option; -import com.android.tradefed.config.OptionClass; -import com.android.tradefed.testtype.InstrumentationTest; - -/** Uses AppCompatibilityRunner to check if the app starts correctly. */ -@OptionClass(alias = "app-compatibility") -public final class AppLaunchCompatibilityTest extends AppCompatibilityTest { - private static final String APP_LAUNCH_TIMEOUT_LABEL = "app_launch_timeout_ms"; - private static final String WORKSPACE_LAUNCH_TIMEOUT_LABEL = "workspace_launch_timeout_ms"; - - @Option( - name = "app-launch-timeout-ms", - description = "Time to wait for app to launch in msecs.") - private int mAppLaunchTimeoutMs = 15000; - - @Option( - name = "workspace-launch-timeout-ms", - description = "Time to wait when launched back into the workspace in msecs.") - private int mWorkspaceLaunchTimeoutMs = 2000; - - public AppLaunchCompatibilityTest() { - super( - "com.android.compatibilitytest", - "com.android.compatibilitytest.AppCompatibilityRunner", - "package_to_launch"); - } - - @Override - public InstrumentationTest createInstrumentationTest(String packageBeingTested) { - InstrumentationTest instrTest = createDefaultInstrumentationTest(packageBeingTested); - instrTest.addInstrumentationArg( - APP_LAUNCH_TIMEOUT_LABEL, Integer.toString(mAppLaunchTimeoutMs)); - instrTest.addInstrumentationArg( - WORKSPACE_LAUNCH_TIMEOUT_LABEL, Integer.toString(mWorkspaceLaunchTimeoutMs)); - return instrTest; - } -} diff --git a/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparer.java b/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparer.java index b742c2f..12e30f7 100644 --- a/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparer.java +++ b/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparer.java @@ -17,99 +17,230 @@ package com.android.compatibility.targetprep; import static com.google.common.base.Preconditions.checkArgument; -import static com.google.common.base.Preconditions.checkNotNull; +import com.android.csuite.core.SystemPackageUninstaller; import com.android.tradefed.build.IBuildInfo; +import com.android.tradefed.config.ConfigurationException; import com.android.tradefed.config.Option; +import com.android.tradefed.config.OptionSetter; import com.android.tradefed.device.DeviceNotAvailableException; import com.android.tradefed.device.ITestDevice; import com.android.tradefed.invoker.TestInformation; +import com.android.tradefed.log.LogUtil.CLog; import com.android.tradefed.targetprep.BuildError; import com.android.tradefed.targetprep.ITargetPreparer; import com.android.tradefed.targetprep.TargetSetupError; import com.android.tradefed.targetprep.TestAppInstallSetup; +import com.android.tradefed.util.AaptParser.AaptVersion; import com.google.common.annotations.VisibleForTesting; +import com.google.common.util.concurrent.SimpleTimeLimiter; +import com.google.common.util.concurrent.TimeLimiter; +import com.google.common.util.concurrent.UncheckedTimeoutException; import java.io.File; -import java.io.IOException; -import java.nio.file.Files; -import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; import java.util.List; -import java.util.stream.Collectors; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; /** A Tradefed preparer that downloads and installs an app on the target device. */ public final class AppSetupPreparer implements ITargetPreparer { - public static final String OPTION_GCS_APK_DIR = "gcs-apk-dir"; + @VisibleForTesting + static final String OPTION_WAIT_FOR_DEVICE_AVAILABLE_SECONDS = + "wait-for-device-available-seconds"; + + @VisibleForTesting + static final String OPTION_EXPONENTIAL_BACKOFF_MULTIPLIER_SECONDS = + "exponential-backoff-multiplier-seconds"; - @Option(name = "package-name", description = "Package name of the app being tested.") + @VisibleForTesting static final String OPTION_TEST_FILE_NAME = "test-file-name"; + @VisibleForTesting static final String OPTION_INSTALL_ARG = "install-arg"; + @VisibleForTesting static final String OPTION_SETUP_TIMEOUT_MILLIS = "setup-timeout-millis"; + @VisibleForTesting static final String OPTION_MAX_RETRY = "max-retry"; + @VisibleForTesting static final String OPTION_AAPT_VERSION = "aapt-version"; + @VisibleForTesting static final String OPTION_INCREMENTAL_INSTALL = "incremental"; + + @Option(name = "package-name", description = "Package name of testing app.") private String mPackageName; - private final TestAppInstallSetup mAppInstallSetup; + @Option( + name = OPTION_TEST_FILE_NAME, + description = "the name of an apk file to be installed on device. Can be repeated.") + private final List<File> mTestFiles = new ArrayList<>(); + + @Option(name = OPTION_AAPT_VERSION, description = "The version of AAPT for APK parsing.") + private AaptVersion mAaptVersion = AaptVersion.AAPT2; + + @Option( + name = OPTION_INSTALL_ARG, + description = + "Additional arguments to be passed to install command, " + + "including leading dash, e.g. \"-d\"") + private final List<String> mInstallArgs = new ArrayList<>(); + + @Option( + name = OPTION_INCREMENTAL_INSTALL, + description = "Enable packages to be installed incrementally.") + private boolean mIncrementalInstallation = false; + + @Option(name = OPTION_MAX_RETRY, description = "Max number of retries upon TargetSetupError.") + private int mMaxRetry = 0; + + @Option( + name = OPTION_EXPONENTIAL_BACKOFF_MULTIPLIER_SECONDS, + description = + "The exponential backoff multiplier for retries in seconds. " + + "A value n means the preparer will wait for n^(retry_count) " + + "seconds between retries.") + private int mExponentialBackoffMultiplierSeconds = 0; + + @Option( + name = OPTION_WAIT_FOR_DEVICE_AVAILABLE_SECONDS, + description = + "Timeout value for waiting for device available in seconds. " + + "A negative value means not to check device availability.") + private int mWaitForDeviceAvailableSeconds = -1; + + @Option( + name = OPTION_SETUP_TIMEOUT_MILLIS, + description = + "Timeout value for a setUp operation. " + + "Note that the timeout is not a global timeout and will " + + "be applied to each retry attempt.") + private long mSetupOnceTimeoutMillis = TimeUnit.MINUTES.toMillis(10); + + private final TestAppInstallSetup mTestAppInstallSetup; + private final Sleeper mSleeper; + private final TimeLimiter mTimeLimiter = + SimpleTimeLimiter.create(Executors.newCachedThreadPool()); public AppSetupPreparer() { - this(null, new TestAppInstallSetup()); + this(new TestAppInstallSetup(), Sleepers.DefaultSleeper.INSTANCE); } @VisibleForTesting - public AppSetupPreparer(String packageName, TestAppInstallSetup appInstallSetup) { - this.mPackageName = packageName; - this.mAppInstallSetup = appInstallSetup; + public AppSetupPreparer(TestAppInstallSetup testAppInstallSetup, Sleeper sleeper) { + mTestAppInstallSetup = testAppInstallSetup; + mSleeper = sleeper; } /** {@inheritDoc} */ @Override public void setUp(ITestDevice device, IBuildInfo buildInfo) throws DeviceNotAvailableException, BuildError, TargetSetupError { - // TODO(b/147159584): Use a utility to get dynamic options. - String gcsApkDirOption = buildInfo.getBuildAttributes().get(OPTION_GCS_APK_DIR); - checkNotNull(gcsApkDirOption, "Option %s is not set.", OPTION_GCS_APK_DIR); - - File apkDir = new File(gcsApkDirOption); - checkArgument( - apkDir.isDirectory(), - String.format("GCS Apk Directory %s is not a directory", apkDir)); - - File packageDir = new File(apkDir.getPath(), mPackageName); - checkArgument( - packageDir.isDirectory(), - String.format("Package directory %s is not a directory", packageDir)); + checkArgumentNonNegative(mMaxRetry, OPTION_MAX_RETRY); + checkArgumentNonNegative( + mExponentialBackoffMultiplierSeconds, + OPTION_EXPONENTIAL_BACKOFF_MULTIPLIER_SECONDS); + checkArgumentNonNegative(mSetupOnceTimeoutMillis, OPTION_SETUP_TIMEOUT_MILLIS); + + int runCount = 0; + while (true) { + TargetSetupError currentException; + try { + runCount++; + + ITargetPreparer handler = + mTimeLimiter.newProxy( + new ITargetPreparer() { + @Override + public void setUp(ITestDevice device, IBuildInfo buildInfo) + throws DeviceNotAvailableException, BuildError, + TargetSetupError { + setUpOnce(device, buildInfo); + } + }, + ITargetPreparer.class, + mSetupOnceTimeoutMillis, + TimeUnit.MILLISECONDS); + handler.setUp(device, buildInfo); + + break; + } catch (TargetSetupError e) { + currentException = e; + } catch (UncheckedTimeoutException e) { + currentException = new TargetSetupError(e.getMessage(), e); + } + + waitForDeviceAvailable(device); + if (runCount > mMaxRetry) { + throw currentException; + } + CLog.w("setUp failed: %s. Run count: %d. Retrying...", currentException, runCount); + + try { + mSleeper.sleep( + Duration.ofSeconds( + (int) Math.pow(mExponentialBackoffMultiplierSeconds, runCount))); + } catch (InterruptedException e) { + Thread.currentThread().interrupt(); + throw new TargetSetupError(e.getMessage(), e); + } + } + } - mAppInstallSetup.setAltDir(packageDir); + private void setUpOnce(ITestDevice device, IBuildInfo buildInfo) + throws DeviceNotAvailableException, BuildError, TargetSetupError { + mTestAppInstallSetup.setAaptVersion(mAaptVersion); - List<String> apkFilePaths; try { - apkFilePaths = listApkFilePaths(packageDir); - } catch (IOException e) { - throw new TargetSetupError( - String.format("Failed to access files in %s.", packageDir), e); + OptionSetter setter = new OptionSetter(mTestAppInstallSetup); + setter.setOptionValue("incremental", String.valueOf(mIncrementalInstallation)); + } catch (ConfigurationException e) { + throw new TargetSetupError(e.getMessage(), e); } - if (apkFilePaths.isEmpty()) { - throw new TargetSetupError( - String.format("Failed to find apk files in %s.", packageDir)); + if (mPackageName != null) { + SystemPackageUninstaller.uninstallPackage(mPackageName, device); } - if (apkFilePaths.size() == 1) { - mAppInstallSetup.addTestFileName(apkFilePaths.get(0)); - } else { - mAppInstallSetup.addSplitApkFileNames(String.join(",", apkFilePaths)); + for (File testFile : mTestFiles) { + mTestAppInstallSetup.addTestFile(testFile); } - mAppInstallSetup.setUp(device, buildInfo); + for (String installArg : mInstallArgs) { + mTestAppInstallSetup.addInstallArg(installArg); + } + + mTestAppInstallSetup.setUp(device, buildInfo); } /** {@inheritDoc} */ @Override public void tearDown(TestInformation testInfo, Throwable e) throws DeviceNotAvailableException { - mAppInstallSetup.tearDown(testInfo, e); + mTestAppInstallSetup.tearDown(testInfo, e); } - private List<String> listApkFilePaths(File downloadDir) throws IOException { - return Files.walk(Paths.get(downloadDir.getPath())) - .map(x -> x.getFileName().toString()) - .filter(s -> s.endsWith(".apk")) - .collect(Collectors.toList()); + private void waitForDeviceAvailable(ITestDevice device) throws DeviceNotAvailableException { + if (mWaitForDeviceAvailableSeconds < 0) { + return; + } + + device.waitForDeviceAvailable(1000L * mWaitForDeviceAvailableSeconds); + } + + private void checkArgumentNonNegative(long val, String name) { + checkArgument(val >= 0, "%s (%s) must not be negative", name, val); + } + + @VisibleForTesting + interface Sleeper { + void sleep(Duration duration) throws InterruptedException; + } + + static class Sleepers { + enum DefaultSleeper implements Sleeper { + INSTANCE; + + @Override + public void sleep(Duration duration) throws InterruptedException { + Thread.sleep(duration.toMillis()); + } + } + + private Sleepers() {} } } diff --git a/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiver.java b/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiver.java deleted file mode 100644 index 7e50f3f..0000000 --- a/harness/src/main/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiver.java +++ /dev/null @@ -1,56 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.compatibility.targetprep; - -import com.android.tradefed.build.IBuildInfo; -import com.android.tradefed.config.Option; -import com.android.tradefed.device.ITestDevice; -import com.android.tradefed.targetprep.ITargetPreparer; - -import com.google.common.annotations.VisibleForTesting; - -import java.io.File; - -/** - * A Tradefed preparer that receives module preparer options and stores the values in IBuildInfo. - */ -public final class AppSetupPreparerConfigurationReceiver implements ITargetPreparer { - - @Option( - name = AppSetupPreparer.OPTION_GCS_APK_DIR, - description = "GCS path where the test apk files are located.") - private File mOptionGcsApkDir; - - public AppSetupPreparerConfigurationReceiver() { - this(null); - } - - @VisibleForTesting - public AppSetupPreparerConfigurationReceiver(File optionGcsApkDir) { - mOptionGcsApkDir = optionGcsApkDir; - } - - /** {@inheritDoc} */ - @Override - public void setUp(ITestDevice device, IBuildInfo buildInfo) { - if (mOptionGcsApkDir == null) { - return; - } - buildInfo.addBuildAttribute( - AppSetupPreparer.OPTION_GCS_APK_DIR, mOptionGcsApkDir.getPath()); - } -} diff --git a/harness/src/main/java/com/android/compatibility/targetprep/CheckGmsPreparer.java b/harness/src/main/java/com/android/compatibility/targetprep/CheckGmsPreparer.java new file mode 100644 index 0000000..8d40d04 --- /dev/null +++ b/harness/src/main/java/com/android/compatibility/targetprep/CheckGmsPreparer.java @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.compatibility.targetprep; + +import com.android.tradefed.config.Option; +import com.android.tradefed.device.DeviceNotAvailableException; +import com.android.tradefed.invoker.TestInformation; +import com.android.tradefed.log.LogUtil.CLog; +import com.android.tradefed.targetprep.ITargetPreparer; +import com.android.tradefed.targetprep.TargetSetupError; +import com.android.tradefed.util.CommandResult; + +import com.google.common.annotations.VisibleForTesting; + +/** + * Checks and recover GMS on a device. + * + * <p>This preparer checks whether the GMS process is running during setUp and tearDown. If GMS is + * not running before the test, a reboot will be attempted to recover. + */ +public final class CheckGmsPreparer implements ITargetPreparer { + + private static final long WAIT_FOR_BOOT_COMPLETE_TIMEOUT_MILLIS = 1000 * 60; + @VisibleForTesting static final String CHECK_GMS_COMMAND = "pidof com.google.android.gms"; + @VisibleForTesting static final String OPTION_ENABLE = "enable"; + + @Option(name = OPTION_ENABLE, description = "Enable GMS checks.") + protected boolean mEnable = false; + + /** {@inheritDoc} */ + @Override + public void setUp(TestInformation testInfo) + throws TargetSetupError, DeviceNotAvailableException { + if (!mEnable || isGmsRunning(testInfo)) { + return; + } + + CLog.e("Did not detect a running GMS process, rebooting device to recover"); + testInfo.getDevice().reboot(); + testInfo.getDevice().waitForBootComplete(WAIT_FOR_BOOT_COMPLETE_TIMEOUT_MILLIS); + + if (!isGmsRunning(testInfo)) { + CLog.e("GMS process still not running, throwing error"); + mEnable = false; + throw new TargetSetupError( + "GMS required but did not detect a running GMS process after device reboot"); + } + } + + private static boolean isGmsRunning(TestInformation testInfo) + throws DeviceNotAvailableException { + CommandResult res = testInfo.getDevice().executeShellV2Command(CHECK_GMS_COMMAND); + if (res.getExitCode() == 0) { + CLog.d("Detected a running GMS process with PID: %s", res.getStdout()); + return true; + } + + CLog.e( + "Check GMS command returned non zero exit code. Command: %s, Result: %s", + CHECK_GMS_COMMAND, res.toString()); + return false; + } + + /** {@inheritDoc} */ + @Override + public void tearDown(TestInformation testInfo, Throwable e) throws DeviceNotAvailableException { + if (!mEnable || isGmsRunning(testInfo)) { + return; + } + + CLog.e("Did not detect a running GMS process on tearDown"); + } +} diff --git a/harness/src/main/java/com/android/compatibility/testtype/AppLaunchTest.java b/harness/src/main/java/com/android/compatibility/testtype/AppLaunchTest.java index fb34dbf..7a52735 100644 --- a/harness/src/main/java/com/android/compatibility/testtype/AppLaunchTest.java +++ b/harness/src/main/java/com/android/compatibility/testtype/AppLaunchTest.java @@ -59,6 +59,12 @@ import java.util.Set; /** A test that verifies that a single app can be successfully launched. */ public class AppLaunchTest implements IDeviceTest, IRemoteTest, IConfigurationReceiver, ITestFilterReceiver { + @VisibleForTesting static final String SCREENSHOT_AFTER_LAUNCH = "screenshot-after-launch"; + + @Option( + name = SCREENSHOT_AFTER_LAUNCH, + description = "Whether to take a screenshost after a package is launched.") + private boolean mScreenshotAfterLaunch; @Option(name = "package-name", description = "Package name of testing app.") private String mPackageName; @@ -79,6 +85,9 @@ public class AppLaunchTest @Option(name = "exclude-filter", description = "The exclude filter of the test type.") protected Set<String> mExcludeFilters = new HashSet<>(); + @Option(name = "dismiss-dialog", description = "Attempt to dismiss dialog from apps.") + protected boolean mDismissDialog = false; + @Option( name = "app-launch-timeout-ms", description = "Time to wait for app to launch in msecs.") @@ -88,18 +97,22 @@ public class AppLaunchTest "com.android.compatibilitytest.AppCompatibilityRunner"; private static final String LAUNCH_TEST_PACKAGE = "com.android.compatibilitytest"; private static final String PACKAGE_TO_LAUNCH = "package_to_launch"; + private static final String ARG_DISMISS_DIALOG = "ARG_DISMISS_DIALOG"; private static final String APP_LAUNCH_TIMEOUT_LABEL = "app_launch_timeout_ms"; private static final int LOGCAT_SIZE_BYTES = 20 * 1024 * 1024; + private static final int BASE_INSTRUMENTATION_TEST_TIMEOUT_MS = 10 * 1000; private ITestDevice mDevice; private LogcatReceiver mLogcat; private IConfiguration mConfiguration; - public AppLaunchTest() {} + public AppLaunchTest() { + this(null); + } @VisibleForTesting public AppLaunchTest(String packageName) { - mPackageName = packageName; + this(packageName, 0); } @VisibleForTesting @@ -113,17 +126,23 @@ public class AppLaunchTest * the package being tested (provided as a parameter). */ protected InstrumentationTest createInstrumentationTest(String packageBeingTested) { - InstrumentationTest instrTest = new InstrumentationTest(); - - instrTest.setPackageName(LAUNCH_TEST_PACKAGE); - instrTest.setConfiguration(mConfiguration); - instrTest.addInstrumentationArg(PACKAGE_TO_LAUNCH, packageBeingTested); - instrTest.setRunnerName(LAUNCH_TEST_RUNNER); - instrTest.setDevice(mDevice); - instrTest.addInstrumentationArg( + InstrumentationTest instrumentationTest = new InstrumentationTest(); + + instrumentationTest.setPackageName(LAUNCH_TEST_PACKAGE); + instrumentationTest.setConfiguration(mConfiguration); + instrumentationTest.addInstrumentationArg(PACKAGE_TO_LAUNCH, packageBeingTested); + instrumentationTest.setRunnerName(LAUNCH_TEST_RUNNER); + instrumentationTest.setDevice(mDevice); + instrumentationTest.addInstrumentationArg( APP_LAUNCH_TIMEOUT_LABEL, Integer.toString(mAppLaunchTimeoutMs)); + instrumentationTest.addInstrumentationArg( + ARG_DISMISS_DIALOG, Boolean.toString(mDismissDialog)); - return instrTest; + int testTimeoutMs = BASE_INSTRUMENTATION_TEST_TIMEOUT_MS + mAppLaunchTimeoutMs * 2; + instrumentationTest.setShellTimeout(testTimeoutMs); + instrumentationTest.setTestTimeout(testTimeoutMs); + + return instrumentationTest; } /* @@ -188,7 +207,21 @@ public class AppLaunchTest // Clear test result between retries. launchPackage(testInfo, result); if (result.status == CompatibilityTestResult.STATUS_SUCCESS) { - return; + break; + } + } + + if (mScreenshotAfterLaunch) { + try (InputStreamSource screenSource = mDevice.getScreenshot()) { + listener.testLog( + mPackageName + "_screenshot_" + mDevice.getSerialNumber(), + LogDataType.PNG, + screenSource); + } catch (DeviceNotAvailableException e) { + CLog.e( + "Device %s became unavailable while capturing screenshot, %s", + mDevice.getSerialNumber(), e.toString()); + throw e; } } } finally { diff --git a/harness/src/main/java/com/android/csuite/config/AppRemoteFileResolver.java b/harness/src/main/java/com/android/csuite/config/AppRemoteFileResolver.java new file mode 100644 index 0000000..82fddb8 --- /dev/null +++ b/harness/src/main/java/com/android/csuite/config/AppRemoteFileResolver.java @@ -0,0 +1,282 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.csuite.config; + +import com.android.tradefed.build.BuildRetrievalError; +import com.android.tradefed.config.ConfigurationException; +import com.android.tradefed.config.DynamicRemoteFileResolver; +import com.android.tradefed.config.Option; +import com.android.tradefed.config.OptionClass; +import com.android.tradefed.config.OptionSetter; +import com.android.tradefed.config.remote.IRemoteFileResolver; +import com.android.tradefed.device.ITestDevice; +import com.android.tradefed.log.LogUtil.CLog; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.base.Preconditions; +import com.google.common.base.Stopwatch; +import com.google.common.base.Strings; +import com.google.common.collect.ImmutableMap; + +import java.io.File; +import java.net.URI; +import java.net.URISyntaxException; +import java.util.Map; +import java.util.Objects; +import java.util.regex.Matcher; +import java.util.regex.Pattern; + +import javax.annotation.Nullable; +import javax.annotation.concurrent.NotThreadSafe; + +/** + * An implementation of {@code IRemoteFileResolver} for downloading Android apps. + * + * <p>The scheme supported by this resolver allows Trade Federation test configs to abstract the + * actual service used to download Android app APK files. Note that this is a 'meta' resolver that + * resolves abstract 'app://' URIs into a URI with a different scheme using a custom template. The + * actual downloading of this resolved URI is then delegated to another registered {@link + * IRemoteFileResolver} implementation. Variable placeholders in the URI template string are + * expanded with corresponding values. + * + * <h2>Syntax and usage</h2> + * + * <p>References to apps in TradeFed test configs must have the following syntax: + * + * <blockquote> + * + * <b>{@code app://}</b><i>package-name</i> + * + * </blockquote> + * + * where <i>package-name</i> is the name of the application package such as: + * + * <blockquote> + * + * <table cellpadding=0 cellspacing=0 summary="layout"> + * <tr><td>{@code app://com.example.myapp}<td></tr> + * </table> + * + * </blockquote> + * + * App APK files are downloaded to a directory and must be used in contexts that can handle File + * objects pointing to directories. + * + * <h2>Configuration</h2> + * + * <p>The URI template to use is specified using the {@code dynamic-download-args} TradeFed + * command-line argument: + * + * <blockquote> + * + * <pre> + * --dynamic-download-args app:uri-template=file:///app_files/{package} + * </pre> + * + * </blockquote> + * + * <p>Where {package} expands to the actual package name being downloaded. Any illegal URI + * characters must also be properly escaped as expected by {@link java.net.URI}. + * + * <p><span style="font-weight: bold; padding-right: 1em">Usage Note:</span> The {@code + * --enable-module-dynamic-download} flag must be set to {@code true} when used in test suites. + * + * @see com.android.tradefed.config.Option + */ +@NotThreadSafe +@OptionClass(alias = "app") +public final class AppRemoteFileResolver implements IRemoteFileResolver { + + private static final String URI_SCHEME = "app"; + private static final Pattern PLACEHOLDER_PATTERN = Pattern.compile("\\{(\\w+)\\}"); + + @VisibleForTesting static final String URI_TEMPLATE_OPTION = "uri-template"; + + @Option(name = URI_TEMPLATE_OPTION) + private String mUriTemplate; + + @Nullable private ITestDevice mPrimaryDevice; + + @Override + public String getSupportedProtocol() { + return URI_SCHEME; + } + + @Override + public void setPrimaryDevice(@Nullable ITestDevice primaryDevice) { + this.mPrimaryDevice = primaryDevice; + } + + @Override + public File resolveRemoteFiles(File uriSchemeAndPathAsFile) throws BuildRetrievalError { + // Note that this method is not really supported or even called by the framework. We + // only override it to simplify automated null pointer testing. + return resolveRemoteFiles(uriSchemeAndPathAsFile, ImmutableMap.of()); + } + + @Override + public File resolveRemoteFiles( + File uriSchemeAndPathAsFile, Map<String, String> uriQueryAndExtraParameters) + throws BuildRetrievalError { + URI appUri = checkAppUri(toUri(uriSchemeAndPathAsFile)); + Objects.requireNonNull(uriQueryAndExtraParameters); + + // TODO(hzalek): Remove this and make the corresponding option mandatory once test configs + // are using app URIs. + if (mUriTemplate == null) { + CLog.w("Resolver is not properly configured, skipping resolution of URI (%s)", appUri); + return null; + } + + Preconditions.checkState( + !mUriTemplate.isEmpty(), + String.format("%s=%s is empty", URI_TEMPLATE_OPTION, mUriTemplate)); + + String packageName = appUri.getAuthority(); + String expanded = expandVars(mUriTemplate, ImmutableMap.of("package", packageName)); + + URI uri; + try { + uri = new URI(expanded); + } catch (URISyntaxException e) { + throw new IllegalStateException( + String.format( + "URI template (%s) did not expand to a a valid URI (%s)", + URI_TEMPLATE_OPTION, mUriTemplate, expanded), + e); + } + + if (URI_SCHEME.equals(uri.getScheme())) { + throw new BuildRetrievalError( + String.format( + "Providers must return URIs with a scheme different than '%s': %s > %s", + URI_SCHEME, appUri, uri)); + } + + return resolveUriToFile(packageName, uri, uriQueryAndExtraParameters); + } + + private static URI toUri(File uriSchemeAndPathAsFile) { + try { + // TradeFed forces a URI into a File instance which is lossy and forces us to attempt + // restoring the original format here so we don't have to use regular expressions. Be + // warned that using getAbsolutePath() will incorrectly strip the scheme. + String path = uriSchemeAndPathAsFile.getPath(); + // Restore the original URI form since the first two forward slashes in the URI string + // get normalized into one when stored as a file. + path = path.replaceFirst(":/", "://"); + return new URI(path); + } catch (URISyntaxException e) { + throw new IllegalArgumentException("Could not parse provided URI", e); + } + } + + private static URI checkAppUri(URI uri) { + String uriScheme = uri.getScheme(); + if (!URI_SCHEME.equals(uriScheme)) { + throw new IllegalArgumentException( + String.format("Unsupported scheme (%s) in provided URI (%s)", uriScheme, uri)); + } + + // Note that the below code accesses the 'authority' component of the URI and not 'path' + // like the dynamic resolver implementation. The latter has to do so because the authority + // component is no longer defined once the '//' gets converted to a single '/'. + String packageName = uri.getAuthority(); + if (Strings.isNullOrEmpty(packageName)) { + throw new IllegalArgumentException( + String.format( + "Invalid package name (%s) in provided URI (%s)", packageName, uri)); + } + + if (!Strings.isNullOrEmpty(uri.getPath())) { + throw new IllegalArgumentException( + String.format( + "Path component (%s) incorrectly specified in provided URI (%s); " + + "app URIs must be of the form 'app://com.example.app'", + uri.getPath(), uri)); + } + + return uri; + } + + private static String expandVars(CharSequence template, Map<String, String> vars) { + StringBuilder sb = new StringBuilder(); + Matcher matcher = PLACEHOLDER_PATTERN.matcher(template); + int position = 0; + + while (matcher.find()) { + sb.append(template.subSequence(position, matcher.start(0))); + + String varName = matcher.group(1); + String varValue = vars.get(varName); + + if (varValue == null) { + throw new IllegalStateException( + String.format( + "URI template (%s) contains a placeholder for undefined var (%s)", + template, varName)); + } + + sb.append(varValue); + position = matcher.end(0); + } + + sb.append(template.subSequence(position, template.length())); + String expanded = sb.toString(); + + CLog.i("Template (%s) expanded (%s) using vars (%s)", template, expanded, vars); + return expanded; + } + + private File resolveUriToFile(String packageName, URI uri, Map<String, String> params) + throws BuildRetrievalError { + DynamicRemoteFileResolver resolver = new DynamicRemoteFileResolver(); + resolver.setDevice(mPrimaryDevice); + resolver.addExtraArgs(params); + + FileOptionSource optionSource = new FileOptionSource(); + Stopwatch stopwatch = Stopwatch.createStarted(); + + try { + OptionSetter setter = new OptionSetter(optionSource); + setter.setOptionValue(FileOptionSource.OPTION_NAME, uri.toString()); + setter.validateRemoteFilePath(resolver); + CLog.i("Resolution of files took %d ms", stopwatch.elapsed().toMillis()); + } catch (BuildRetrievalError e) { + throw new BuildRetrievalError( + String.format("Could not resolve URI (%s) for package '%s'", uri, packageName), + e); + } catch (ConfigurationException impossible) { + throw new AssertionError(impossible); + } + + if (!optionSource.file.exists()) { + CLog.w("URI (%s) resolved to non-existent local file (%s)", uri, optionSource.file); + } else { + CLog.i("URI (%s) resolved to local file (%s)", uri, optionSource.file); + } + + return optionSource.file; + } + + /** This is required to resolve URIs since the remote resolver only deals with options. */ + private static final class FileOptionSource { + static final String OPTION_NAME = "file"; + + @Option(name = OPTION_NAME, mandatory = true) + public File file; + } +} diff --git a/harness/src/main/java/com/android/csuite/config/ModuleGenerator.java b/harness/src/main/java/com/android/csuite/config/ModuleGenerator.java new file mode 100644 index 0000000..0760085 --- /dev/null +++ b/harness/src/main/java/com/android/csuite/config/ModuleGenerator.java @@ -0,0 +1,257 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.config; + +import com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper; +import com.android.csuite.core.PackageNameProvider; +import com.android.tradefed.build.IBuildInfo; +import com.android.tradefed.config.IConfiguration; +import com.android.tradefed.config.IConfigurationReceiver; +import com.android.tradefed.config.Option; +import com.android.tradefed.config.Option.Importance; +import com.android.tradefed.device.DeviceNotAvailableException; +import com.android.tradefed.invoker.TestInformation; +import com.android.tradefed.log.LogUtil.CLog; +import com.android.tradefed.result.ITestInvocationListener; +import com.android.tradefed.targetprep.ITargetPreparer; +import com.android.tradefed.testtype.IBuildReceiver; +import com.android.tradefed.testtype.IRemoteTest; +import com.android.tradefed.testtype.IShardableTest; + +import com.google.common.annotations.VisibleForTesting; +import com.google.common.io.Resources; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystem; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Collection; +import java.util.HashSet; +import java.util.Set; + +/** + * A tool for generating TradeFed suite modules during runtime. + * + * <p>This class generates module config files into TradeFed's test directory at runtime using a + * template. Since the content of the test directory relies on what is being generated in a test + * run, there can only be one instance executing at a given time. + * + * <p>The intention of this class is to generate test modules at the beginning of a test run and + * cleans up after all tests finish, which resembles a target preparer. However, a target preparer + * is executed after the sharding process has finished. The only way to make the generated modules + * available for sharding without making changes to TradeFed's core code is to disguise this module + * generator as an instance of IShardableTest and declare it separately in test plan config. This is + * hacky, and in the long term a TradeFed centered solution is desired. For more details, see + * go/sharding-hack-for-module-gen. Note that since the generate step is executed as a test instance + * and cleanup step is executed as a target preparer, there should be no saved states between + * generating and cleaning up module files. + * + * <p>This module generator collects package names from all PackageNameProvider objects specified in + * the test configs. + * + * <h2>Syntax and usage</h2> + * + * <p>References to package name providers in TradeFed test configs must have the following syntax: + * + * <blockquote> + * + * <b><object type="PACKAGE_NAME_PROVIDER" class="</b><i>provider_class_name</i><b>"/></b> + * + * </blockquote> + * + * where <i>provider_class_name</i> is the fully-qualified class name of an PackageNameProvider + * implementation class. + */ +public final class ModuleGenerator + implements IRemoteTest, + IShardableTest, + IBuildReceiver, + ITargetPreparer, + IConfigurationReceiver { + + @VisibleForTesting static final String MODULE_FILE_EXTENSION = ".config"; + @VisibleForTesting static final String OPTION_TEMPLATE = "template"; + @VisibleForTesting static final String PACKAGE_NAME_PROVIDER = "PACKAGE_NAME_PROVIDER"; + private static final String TEMPLATE_PACKAGE_PATTERN = "\\{package\\}"; + private static final Collection<IRemoteTest> NOT_SPLITABLE = null; + + @Option( + name = OPTION_TEMPLATE, + description = "Module config template resource path.", + importance = Importance.ALWAYS) + private String mTemplate; + + private final TestDirectoryProvider mTestDirectoryProvider; + private final ResourceLoader mResourceLoader; + private final FileSystem mFileSystem; + private IBuildInfo mBuildInfo; + private IConfiguration mConfiguration; + + @Override + public void setConfiguration(IConfiguration configuration) { + mConfiguration = configuration; + } + + public ModuleGenerator() { + this(FileSystems.getDefault()); + } + + private ModuleGenerator(FileSystem fileSystem) { + this( + fileSystem, + new CompatibilityTestDirectoryProvider(fileSystem), + new ClassResourceLoader()); + } + + @VisibleForTesting + ModuleGenerator( + FileSystem fileSystem, + TestDirectoryProvider testDirectoryProvider, + ResourceLoader resourceLoader) { + mFileSystem = fileSystem; + mTestDirectoryProvider = testDirectoryProvider; + mResourceLoader = resourceLoader; + } + + @Override + public void run(final TestInformation testInfo, final ITestInvocationListener listener) { + // Intentionally left blank since this class is not really a test. + } + + @Override + public void setUp(TestInformation testInfo) { + // Intentionally left blank. + } + + @Override + public void setBuild(IBuildInfo buildInfo) { + mBuildInfo = buildInfo; + } + + /** + * Generates test modules. Note that the implementation of this method is not related to + * sharding in any way. + */ + @Override + public Collection<IRemoteTest> split() { + try { + // Executes the generate step. + generateModules(); + } catch (IOException e) { + throw new UncheckedIOException("Failed to generate modules", e); + } + + return NOT_SPLITABLE; + } + + /** Cleans up generated test modules. */ + @Override + public void tearDown(TestInformation testInfo, Throwable e) throws DeviceNotAvailableException { + // Gets build info from test info as when the class is executed as a ITargetPreparer + // preparer, it is not considered as a IBuildReceiver instance. + mBuildInfo = testInfo.getBuildInfo(); + + try { + // Executes the clean up step. + cleanUpModules(); + } catch (IOException ioException) { + throw new UncheckedIOException("Failed to clean up generated modules", ioException); + } + } + + private Set<String> getPackageNames() throws IOException { + Set<String> packages = new HashSet<>(); + for (Object provider : mConfiguration.getConfigurationObjectList(PACKAGE_NAME_PROVIDER)) { + packages.addAll(((PackageNameProvider) provider).get()); + } + return packages; + } + + private void generateModules() throws IOException { + String templateContent = mResourceLoader.load(mTemplate); + + for (String packageName : getPackageNames()) { + validatePackageName(packageName); + Files.write( + getModulePath(packageName), + templateContent.replaceAll(TEMPLATE_PACKAGE_PATTERN, packageName).getBytes()); + } + } + + private void cleanUpModules() throws IOException { + getPackageNames() + .forEach( + packageName -> { + try { + Files.delete(getModulePath(packageName)); + } catch (IOException ioException) { + CLog.e( + "Failed to delete the generated module for package " + + packageName, + ioException); + } + }); + } + + private Path getModulePath(String packageName) throws IOException { + Path testsDir = mTestDirectoryProvider.get(mBuildInfo); + return testsDir.resolve(packageName + MODULE_FILE_EXTENSION); + } + + private static void validatePackageName(String packageName) { + if (packageName.isEmpty() || packageName.matches(".*" + TEMPLATE_PACKAGE_PATTERN + ".*")) { + throw new IllegalArgumentException( + "Package name cannot be empty or contains package placeholder: " + + TEMPLATE_PACKAGE_PATTERN); + } + } + + @VisibleForTesting + interface ResourceLoader { + String load(String resourceName) throws IOException; + } + + private static final class ClassResourceLoader implements ResourceLoader { + @Override + public String load(String resourceName) throws IOException { + return Resources.toString( + getClass().getClassLoader().getResource(resourceName), StandardCharsets.UTF_8); + } + } + + @VisibleForTesting + interface TestDirectoryProvider { + Path get(IBuildInfo buildInfo) throws IOException; + } + + private static final class CompatibilityTestDirectoryProvider implements TestDirectoryProvider { + private final FileSystem mFileSystem; + + private CompatibilityTestDirectoryProvider(FileSystem fileSystem) { + mFileSystem = fileSystem; + } + + @Override + public Path get(IBuildInfo buildInfo) throws IOException { + return mFileSystem.getPath( + new CompatibilityBuildHelper(buildInfo).getTestsDir().getPath()); + } + } +} diff --git a/harness/src/main/java/com/android/csuite/core/CommandLinePackageNameProvider.java b/harness/src/main/java/com/android/csuite/core/CommandLinePackageNameProvider.java new file mode 100644 index 0000000..33426ed --- /dev/null +++ b/harness/src/main/java/com/android/csuite/core/CommandLinePackageNameProvider.java @@ -0,0 +1,39 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.core; + +import com.android.tradefed.config.Option; +import com.android.tradefed.config.OptionClass; + +import com.google.common.annotations.VisibleForTesting; + +import java.util.HashSet; +import java.util.Set; + +/** A package name provider that accepts package names via a command line option. */ +@OptionClass(alias = "command-line-package-name-provider") +public final class CommandLinePackageNameProvider implements PackageNameProvider { + @VisibleForTesting static final String PACKAGE = "package"; + + @Option(name = PACKAGE, description = "App package names.") + private final Set<String> mPackages = new HashSet<>(); + + @Override + public Set<String> get() { + return mPackages; + } +} diff --git a/harness/src/main/java/com/android/csuite/core/FileBasedPackageNameProvider.java b/harness/src/main/java/com/android/csuite/core/FileBasedPackageNameProvider.java new file mode 100644 index 0000000..6f6af06 --- /dev/null +++ b/harness/src/main/java/com/android/csuite/core/FileBasedPackageNameProvider.java @@ -0,0 +1,60 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.core; + +import com.android.tradefed.config.Option; + +import com.google.common.annotations.VisibleForTesting; + +import java.io.File; +import java.io.IOException; +import java.nio.file.Files; +import java.util.HashSet; +import java.util.Set; +import java.util.stream.Collectors; + +/** A package name provider that accepts files that contains package names. */ +public final class FileBasedPackageNameProvider implements PackageNameProvider { + @VisibleForTesting static final String PACKAGES_FILE = "packages-file"; + @VisibleForTesting static final String COMMENT_LINE_PREFIX = "#"; + + @Option( + name = PACKAGES_FILE, + description = + "File paths that contain package names separated by newline characters." + + " Comment lines are supported only if the lines start with double slash." + + " Trailing comments are not supported. Empty lines are ignored.") + private final Set<File> mPackagesFiles = new HashSet<>(); + + @Override + public Set<String> get() throws IOException { + Set<String> packages = new HashSet<>(); + for (File packagesFile : mPackagesFiles) { + packages.addAll( + Files.readAllLines(packagesFile.toPath()).parallelStream() + .map(String::trim) + .filter(this::isPackageName) + .collect(Collectors.toSet())); + } + return packages; + } + + private boolean isPackageName(String text) { + // Check the text is not an empty string and not a comment line. + return !text.isEmpty() && !text.startsWith(COMMENT_LINE_PREFIX); + } +} diff --git a/harness/src/main/java/com/android/csuite/core/PackageNameProvider.java b/harness/src/main/java/com/android/csuite/core/PackageNameProvider.java new file mode 100644 index 0000000..05709a1 --- /dev/null +++ b/harness/src/main/java/com/android/csuite/core/PackageNameProvider.java @@ -0,0 +1,31 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.core; + +import java.io.IOException; +import java.util.Set; + +/** Provides a list of package names. */ +public interface PackageNameProvider { + /** + * Returns a set of package names. + * + * @return the package names. An empty set is returned if no package names are to be provided. + * @throws IOException if failed to get package names. + */ + Set<String> get() throws IOException; +} diff --git a/harness/src/main/java/com/android/csuite/core/SystemPackageUninstaller.java b/harness/src/main/java/com/android/csuite/core/SystemPackageUninstaller.java new file mode 100644 index 0000000..4ed6efe --- /dev/null +++ b/harness/src/main/java/com/android/csuite/core/SystemPackageUninstaller.java @@ -0,0 +1,296 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.core; + +import static com.google.common.base.Preconditions.checkNotNull; + +import com.android.tradefed.device.DeviceNotAvailableException; +import com.android.tradefed.device.ITestDevice; +import com.android.tradefed.log.LogUtil.CLog; +import com.android.tradefed.targetprep.TargetSetupError; +import com.android.tradefed.util.CommandResult; +import com.android.tradefed.util.CommandStatus; + +import com.google.common.annotations.VisibleForTesting; + +import java.nio.file.Paths; +import java.util.Arrays; + +/** + * Uninstalls a system app. + * + * <p>This utility class may not restore the uninstalled system app after test completes. + * + * <p>The class may disable dm verity on some devices, and it does not re-enable it after + * uninstalling a system app. + */ +public final class SystemPackageUninstaller { + @VisibleForTesting static final String OPTION_PACKAGE_NAME = "package-name"; + static final String SYSPROP_DEV_BOOTCOMPLETE = "dev.bootcomplete"; + static final String SYSPROP_SYS_BOOT_COMPLETED = "sys.boot_completed"; + static final long WAIT_FOR_BOOT_COMPLETE_TIMEOUT_MILLIS = 1000 * 60; + @VisibleForTesting static final int MAX_NUMBER_OF_UPDATES = 100; + @VisibleForTesting static final String PM_CHECK_COMMAND = "pm path android"; + + public static void uninstallPackage(String packageName, ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + checkNotNull(packageName); + + if (!isPackageManagerRunning(device)) { + CLog.w( + "Package manager is not available on the device." + + " Attempting to recover it by restarting the framework."); + runAsRoot( + device, + () -> { + stopFramework(device); + startFramework(device); + }); + if (!isPackageManagerRunning(device)) { + throw new TargetSetupError("The package manager failed to start."); + } + } + + if (!isPackageInstalled(packageName, device)) { + CLog.i("Package %s is not installed.", packageName); + return; + } + + // Attempts to uninstall the package/updates from user partition. + // This method should be called before the other methods and requires + // the framework to be running. + removePackageUpdates(packageName, device); + + if (!isPackageInstalled(packageName, device)) { + CLog.i("Package %s has been removed.", packageName); + return; + } + + String packageInstallDirectory = getPackageInstallDirectory(packageName, device); + CLog.d("Install directory for package %s is %s", packageName, packageInstallDirectory); + + if (!isPackagePathSystemApp(packageInstallDirectory)) { + CLog.w("%s is not a system app, skipping", packageName); + return; + } + + CLog.i("Uninstalling system app %s", packageName); + + runWithWritableFilesystem( + device, + () -> + runWithFrameworkOff( + device, + () -> { + removePackageInstallDirectory(packageInstallDirectory, device); + removePackageData(packageName, device); + })); + } + + private interface PreparerTask { + void run() throws TargetSetupError, DeviceNotAvailableException; + } + + private static void runWithFrameworkOff(ITestDevice device, PreparerTask action) + throws TargetSetupError, DeviceNotAvailableException { + stopFramework(device); + + try { + action.run(); + } finally { + startFramework(device); + } + } + + private static void runWithWritableFilesystem(ITestDevice device, PreparerTask action) + throws TargetSetupError, DeviceNotAvailableException { + runAsRoot( + device, + () -> { + // TODO(yuexima): The remountSystemWritable method may internally disable dm + // verity on some devices. Consider restoring verity which would require a + // reboot. + device.remountSystemWritable(); + + try { + action.run(); + } finally { + remountSystemReadOnly(device); + } + }); + } + + private static void runAsRoot(ITestDevice device, PreparerTask action) + throws TargetSetupError, DeviceNotAvailableException { + boolean disableRootAfterUninstall = false; + + if (!device.isAdbRoot()) { + if (!device.enableAdbRoot()) { + throw new TargetSetupError("Failed to enable adb root"); + } + + disableRootAfterUninstall = true; + } + + try { + action.run(); + } finally { + if (disableRootAfterUninstall && !device.disableAdbRoot()) { + throw new TargetSetupError("Failed to disable adb root"); + } + } + } + + private static void stopFramework(ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + // 'stop' is a blocking command. + executeShellCommandOrThrow(device, "stop", "Failed to stop framework"); + // Set the boot complete flags to false. When the framework is started again, both flags + // will be set to true by the system upon the completion of restarting. This allows + // ITestDevice#waitForBootComplete to wait for framework start, and it only works + // when adb is rooted. + device.setProperty(SYSPROP_SYS_BOOT_COMPLETED, "0"); + device.setProperty(SYSPROP_DEV_BOOTCOMPLETE, "0"); + } + + private static void startFramework(ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + // 'start' is a non-blocking command. + executeShellCommandOrThrow(device, "start", "Failed to start framework"); + // This wait only blocks if the boot completed flags are set to 0. + device.waitForBootComplete(WAIT_FOR_BOOT_COMPLETE_TIMEOUT_MILLIS); + } + + private static CommandResult executeShellCommandOrThrow( + ITestDevice device, String command, String failureMessage) + throws TargetSetupError, DeviceNotAvailableException { + CommandResult commandResult = device.executeShellV2Command(command); + + if (commandResult.getStatus() != CommandStatus.SUCCESS) { + throw new TargetSetupError( + String.format("%s; Command result: %s", failureMessage, commandResult)); + } + + return commandResult; + } + + private static CommandResult executeShellCommandOrLog( + ITestDevice device, String command, String failureMessage) + throws DeviceNotAvailableException { + CommandResult commandResult = device.executeShellV2Command(command); + if (commandResult.getStatus() != CommandStatus.SUCCESS) { + CLog.e("%s. Command result: %s", failureMessage, commandResult); + } + + return commandResult; + } + + private static void remountSystemReadOnly(ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + executeShellCommandOrThrow( + device, + "mount -o ro,remount /system", + "Failed to remount system partition as read only"); + } + + private static boolean isPackagePathSystemApp(String packagePath) { + return packagePath.startsWith("/system/") || packagePath.startsWith("/product/"); + } + + private static void removePackageInstallDirectory( + String packageInstallDirectory, ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + CLog.i("Removing package install directory %s", packageInstallDirectory); + executeShellCommandOrThrow( + device, + String.format("rm -r %s", packageInstallDirectory), + String.format( + "Failed to remove system app package path %s", packageInstallDirectory)); + } + + private static void removePackageUpdates(String packageName, ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + CLog.i("Removing package updates for %s", packageName); + + // A system package may have update packages. If so, each `adb uninstall` call + // only uninstalls the latest update. To remove all update packages we can + // call uninstall repeatedly until the command fails. + for (int i = 0; i < MAX_NUMBER_OF_UPDATES; i++) { + String errMsg = device.uninstallPackage(packageName); + if (errMsg != null) { + CLog.d("Completed removing updates as the uninstall command returned: %s", errMsg); + return; + } + CLog.i("Removed an update package for %s", packageName); + } + + throw new TargetSetupError("Too many updates were uninstalled. Something must be wrong."); + } + + private static void removePackageData(String packageName, ITestDevice device) + throws DeviceNotAvailableException { + String dataPath = String.format("/data/data/%s", packageName); + CLog.i("Removing package data directory for %s", dataPath); + executeShellCommandOrLog( + device, + String.format("rm -r %s", dataPath), + String.format( + "Failed to remove system app data %s from %s", packageName, dataPath)); + } + + private static boolean isPackageManagerRunning(ITestDevice device) + throws DeviceNotAvailableException { + return device.executeShellV2Command(PM_CHECK_COMMAND).getStatus() == CommandStatus.SUCCESS; + } + + private static boolean isPackageInstalled(String packageName, ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + CommandResult commandResult = + executeShellCommandOrThrow( + device, + String.format("pm list packages %s", packageName), + "Failed to execute pm command"); + + if (commandResult.getStdout() == null) { + throw new TargetSetupError( + String.format( + "Failed to get pm command output: %s", commandResult.getStdout())); + } + + return Arrays.asList(commandResult.getStdout().split("\\r?\\n")) + .contains(String.format("package:%s", packageName)); + } + + private static String getPackageInstallDirectory(String packageName, ITestDevice device) + throws TargetSetupError, DeviceNotAvailableException { + CommandResult commandResult = + executeShellCommandOrThrow( + device, + String.format("pm path %s", packageName), + "Failed to execute pm command"); + + if (commandResult.getStdout() == null + || !commandResult.getStdout().startsWith("package:")) { + throw new TargetSetupError( + String.format( + "Failed to get pm path command output %s", commandResult.getStdout())); + } + + String packageInstallPath = commandResult.getStdout().substring("package:".length()); + return Paths.get(packageInstallPath).getParent().toString(); + } +} diff --git a/harness/src/main/resources/META-INF/services/com.android.tradefed.config.remote.IRemoteFileResolver b/harness/src/main/resources/META-INF/services/com.android.tradefed.config.remote.IRemoteFileResolver new file mode 100644 index 0000000..be67f9b --- /dev/null +++ b/harness/src/main/resources/META-INF/services/com.android.tradefed.config.remote.IRemoteFileResolver @@ -0,0 +1 @@ +com.android.csuite.config.AppRemoteFileResolver diff --git a/harness/src/main/resources/config/csuite-base.xml b/harness/src/main/resources/config/csuite-base.xml index 6431154..03e418b 100644 --- a/harness/src/main/resources/config/csuite-base.xml +++ b/harness/src/main/resources/config/csuite-base.xml @@ -28,5 +28,12 @@ <result_reporter class="com.android.compatibility.common.tradefed.result.suite.CompatibilityProtoResultReporter" /> <result_reporter class="com.android.tradefed.result.suite.SuiteResultReporter" /> - <target_preparer class="com.android.compatibility.targetprep.AppSetupPreparerConfigurationReceiver" /> + <target_preparer class="com.android.compatibility.targetprep.AppSetupPreparer"> + <option name="test-file-name" value="csuite-launch-instrumentation.apk"/> + </target_preparer> + <!-- Cleans generated module files after test --> + <target_preparer class="com.android.csuite.config.ModuleGenerator" /> + + <object type="PACKAGE_NAME_PROVIDER" class="com.android.csuite.core.CommandLinePackageNameProvider" /> + <object type="PACKAGE_NAME_PROVIDER" class="com.android.csuite.core.FileBasedPackageNameProvider" /> </configuration> diff --git a/harness/src/test/java/com/android/compatibility/AppCompatibilityTestTest.java b/harness/src/test/java/com/android/compatibility/AppCompatibilityTestTest.java deleted file mode 100644 index 18f3368..0000000 --- a/harness/src/test/java/com/android/compatibility/AppCompatibilityTestTest.java +++ /dev/null @@ -1,334 +0,0 @@ -/* - * Copyright (C) 2019 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.compatibility; - -import static org.junit.Assert.assertEquals; -import static org.junit.Assert.assertFalse; -import static org.junit.Assert.assertTrue; - -import com.android.tradefed.testtype.InstrumentationTest; -import com.android.tradefed.util.PublicApkUtil.ApkInfo; - - -import org.junit.Before; -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import java.util.ArrayList; -import java.util.Arrays; -import java.util.List; -import java.util.Set; -import java.util.TreeSet; - -@RunWith(JUnit4.class) -public final class AppCompatibilityTestTest { - - private ConcreteAppCompatibilityTest mSut; - - private class ConcreteAppCompatibilityTest extends AppCompatibilityTest { - - ConcreteAppCompatibilityTest() { - super(null, null, null); - } - - @Override - protected InstrumentationTest createInstrumentationTest(String packageBeingTested) { - return null; - } - } - - @Before - public void setUp() { - mSut = new ConcreteAppCompatibilityTest(); - } - - @Test(expected = IllegalArgumentException.class) - public void addIncludeFilter_nullIncludeFilter_throwsException() { - mSut.addIncludeFilter(null); - } - - @Test(expected = IllegalArgumentException.class) - public void addIncludeFilter_emptyIncludeFilter_throwsException() { - mSut.addIncludeFilter(""); - } - - @Test - public void addIncludeFilter_validIncludeFilter() { - mSut.addIncludeFilter("test_filter"); - - assertTrue(mSut.mIncludeFilters.contains("test_filter")); - } - - @Test(expected = NullPointerException.class) - public void addAllIncludeFilters_nullIncludeFilter_throwsException() { - mSut.addAllIncludeFilters(null); - } - - @Test - public void addAllIncludeFilters_validIncludeFilters() { - Set<String> test_filters = new TreeSet<>(); - test_filters.add("filter_one"); - test_filters.add("filter_two"); - - mSut.addAllIncludeFilters(test_filters); - - assertTrue(mSut.mIncludeFilters.contains("filter_one")); - assertTrue(mSut.mIncludeFilters.contains("filter_two")); - } - - @Test - public void clearIncludeFilters() { - mSut.addIncludeFilter("filter_test"); - - mSut.clearIncludeFilters(); - - assertTrue(mSut.mIncludeFilters.isEmpty()); - } - - @Test - public void getIncludeFilters() { - mSut.addIncludeFilter("filter_test"); - - assertEquals(mSut.mIncludeFilters, mSut.getIncludeFilters()); - } - - @Test(expected = IllegalArgumentException.class) - public void addExcludeFilter_nullExcludeFilter_throwsException() { - mSut.addExcludeFilter(null); - } - - @Test(expected = IllegalArgumentException.class) - public void addExcludeFilter_emptyExcludeFilter_throwsException() { - mSut.addExcludeFilter(""); - } - - @Test - public void addExcludeFilter_validExcludeFilter() { - mSut.addExcludeFilter("test_filter"); - - assertTrue(mSut.mExcludeFilters.contains("test_filter")); - } - - @Test(expected = NullPointerException.class) - public void addAllExcludeFilters_nullExcludeFilters_throwsException() { - mSut.addAllExcludeFilters(null); - } - - @Test - public void addAllExcludeFilters_validExcludeFilters() { - Set<String> test_filters = new TreeSet<>(); - test_filters.add("filter_one"); - test_filters.add("filter_two"); - - mSut.addAllExcludeFilters(test_filters); - - assertTrue(mSut.mExcludeFilters.contains("filter_one")); - assertTrue(mSut.mExcludeFilters.contains("filter_two")); - } - - @Test - public void clearExcludeFilters() { - mSut.addExcludeFilter("filter_test"); - - mSut.clearExcludeFilters(); - - assertTrue(mSut.mExcludeFilters.isEmpty()); - } - - @Test - public void getExcludeFilters() { - mSut.addExcludeFilter("filter_test"); - - assertEquals(mSut.mExcludeFilters, mSut.getExcludeFilters()); - } - - @Test - public void filterApk_withNoFilter() { - List<ApkInfo> testList = createApkList(); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertEquals(filteredList, testList); - } - - @Test - public void filterApk_withRelatedIncludeFilters() { - List<ApkInfo> testList = createApkList(); - mSut.addIncludeFilter("filter_one"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertEquals(convertList(filteredList), Arrays.asList("filter_one")); - } - - @Test - public void filterApk_withUnrelatedIncludeFilters() { - List<ApkInfo> testList = createApkList(); - mSut.addIncludeFilter("filter_three"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertTrue(filteredList.isEmpty()); - } - - @Test - public void filterApk_withRelatedExcludeFilters() { - List<ApkInfo> testList = createApkList(); - mSut.addExcludeFilter("filter_one"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertEquals(convertList(filteredList), Arrays.asList("filter_two")); - } - - @Test - public void filterApk_withUnrelatedExcludeFilters() { - List<ApkInfo> testList = createApkList(); - mSut.addExcludeFilter("filter_three"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertEquals(filteredList, testList); - } - - @Test - public void filterApk_withSameIncludeAndExcludeFilters() { - List<ApkInfo> testList = createApkList(); - mSut.addIncludeFilter("filter_one"); - mSut.addExcludeFilter("filter_one"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertTrue(filteredList.isEmpty()); - } - - @Test - public void filterApk_withDifferentIncludeAndExcludeFilter() { - List<ApkInfo> testList = createApkList(); - mSut.addIncludeFilter("filter_one"); - mSut.addIncludeFilter("filter_two"); - mSut.addExcludeFilter("filter_two"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertEquals(convertList(filteredList), Arrays.asList("filter_one")); - } - - @Test - public void filterApk_withUnrelatedIncludeFilterAndRelatedExcludeFilter() { - List<ApkInfo> testList = createApkList(); - mSut.addIncludeFilter("filter_three"); - mSut.addExcludeFilter("filter_two"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertTrue(filteredList.isEmpty()); - } - - @Test - public void filterApk_withRelatedIncludeFilterAndUnrelatedExcludeFilter() { - List<ApkInfo> testList = createApkList(); - mSut.addIncludeFilter("filter_one"); - mSut.addExcludeFilter("filter_three"); - - List<ApkInfo> filteredList = mSut.filterApk(testList); - - assertEquals(convertList(filteredList), Arrays.asList("filter_one")); - } - - private List<ApkInfo> createApkList() { - List<ApkInfo> testList = new ArrayList<>(); - ApkInfo apk_info_one = new ApkInfo(0, "filter_one", "", "", ""); - ApkInfo apk_info_two = new ApkInfo(0, "filter_two", "", "", ""); - testList.add(apk_info_one); - testList.add(apk_info_two); - return testList; - } - - private List<String> convertList(List<ApkInfo> apkList) { - List<String> convertedList = new ArrayList<>(); - for (ApkInfo apkInfo : apkList) { - convertedList.add(apkInfo.packageName); - } - return convertedList; - } - - @Test - public void filterTest_withEmptyFilter() { - assertTrue(mSut.filterTest("filter_one")); - } - - @Test - public void filterTest_withRelatedIncludeFilter() { - mSut.addIncludeFilter("filter_one"); - - assertTrue(mSut.filterTest("filter_one")); - } - - @Test - public void filterTest_withUnrelatedIncludeFilter() { - mSut.addIncludeFilter("filter_two"); - - assertFalse(mSut.filterTest("filter_one")); - } - - @Test - public void filterTest_withRelatedExcludeFilter() { - mSut.addExcludeFilter("filter_one"); - - assertFalse(mSut.filterTest("filter_one")); - } - - @Test - public void filterTest_withUnrelatedExcludeFilter() { - mSut.addExcludeFilter("filter_two"); - - assertTrue(mSut.filterTest("filter_one")); - } - - @Test - public void filterTest_withSameIncludeAndExcludeFilters() { - mSut.addIncludeFilter("filter_one"); - mSut.addExcludeFilter("filter_one"); - - assertFalse(mSut.filterTest("filter_one")); - } - - @Test - public void filterTest_withUnrelatedIncludeFilterAndRelatedExcludeFilter() { - mSut.addIncludeFilter("filter_one"); - mSut.addExcludeFilter("filter_two"); - - assertFalse(mSut.filterTest("filter_two")); - } - - @Test - public void filterTest_withRelatedIncludeFilterAndUnrelatedExcludeFilter() { - mSut.addIncludeFilter("filter_one"); - mSut.addExcludeFilter("filter_two"); - - assertTrue(mSut.filterTest("filter_one")); - } - - @Test - public void filterTest_withUnRelatedIncludeFilterAndUnrelatedExcludeFilter() { - mSut.addIncludeFilter("filter_one"); - mSut.addExcludeFilter("filter_two"); - - assertFalse(mSut.filterTest("filter_three")); - } -} diff --git a/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiverTest.java b/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiverTest.java deleted file mode 100644 index e708a67..0000000 --- a/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerConfigurationReceiverTest.java +++ /dev/null @@ -1,57 +0,0 @@ -/* - * Copyright (C) 2020 The Android Open Source Project - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ -package com.android.compatibility.targetprep; - -import com.android.tradefed.build.BuildInfo; -import com.android.tradefed.build.IBuildInfo; - -import static com.google.common.truth.Truth.assertThat; - -import org.junit.Test; -import org.junit.runner.RunWith; -import org.junit.runners.JUnit4; - -import java.io.File; - -@RunWith(JUnit4.class) -public final class AppSetupPreparerConfigurationReceiverTest { - - @Test - public void setUp_noneNullGcsApkDirOption_putsInBuildInfo() { - File optionGcsApkDir = new File("dir"); - AppSetupPreparerConfigurationReceiver preparer = - new AppSetupPreparerConfigurationReceiver(optionGcsApkDir); - IBuildInfo buildInfo = new BuildInfo(); - - preparer.setUp(null, buildInfo); - - assertThat(buildInfo.getBuildAttributes()) - .containsEntry(AppSetupPreparer.OPTION_GCS_APK_DIR, optionGcsApkDir.getPath()); - } - - @Test - public void setUp_nullGcsApkDirOption_doesNotPutInBuildInfo() { - File optionGcsApkDir = null; - AppSetupPreparerConfigurationReceiver preparer = - new AppSetupPreparerConfigurationReceiver(optionGcsApkDir); - IBuildInfo buildInfo = new BuildInfo(); - - preparer.setUp(null, buildInfo); - - assertThat(buildInfo.getBuildAttributes()) - .doesNotContainKey(AppSetupPreparer.OPTION_GCS_APK_DIR); - } -} diff --git a/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerTest.java b/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerTest.java index 7614f05..e2e4352 100644 --- a/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerTest.java +++ b/harness/src/test/java/com/android/compatibility/targetprep/AppSetupPreparerTest.java @@ -15,125 +15,492 @@ */ package com.android.compatibility.targetprep; -import com.android.tradefed.build.BuildInfo; import com.android.tradefed.build.IBuildInfo; +import com.android.tradefed.config.ArgsOptionParser; +import com.android.tradefed.config.ConfigurationException; +import com.android.tradefed.config.OptionSetter; import com.android.tradefed.device.DeviceNotAvailableException; import com.android.tradefed.device.ITestDevice; import com.android.tradefed.invoker.TestInformation; import com.android.tradefed.targetprep.TargetSetupError; import com.android.tradefed.targetprep.TestAppInstallSetup; +import com.android.tradefed.util.AaptParser.AaptVersion; + +import static com.google.common.truth.Truth.assertThat; import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyLong; +import static org.mockito.Mockito.doAnswer; +import static org.mockito.Mockito.doNothing; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.mock; -import static org.mockito.Mockito.times; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; import static org.testng.Assert.assertThrows; +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ListMultimap; + import org.junit.Rule; import org.junit.Test; import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; +import org.mockito.ArgumentCaptor; +import org.mockito.internal.stubbing.answers.AnswersWithDelay; +import org.mockito.stubbing.Answer; import java.io.File; import java.io.IOException; import java.nio.file.Files; +import java.nio.file.Path; import java.nio.file.Paths; +import java.time.Duration; +import java.util.ArrayList; +import java.util.Map; @RunWith(JUnit4.class) -public class AppSetupPreparerTest { - - private static final String OPTION_GCS_APK_DIR = "gcs-apk-dir"; - public static final ITestDevice NULL_DEVICE = null; +public final class AppSetupPreparerTest { + private static final ITestDevice NULL_DEVICE = null; + private static final IBuildInfo NULL_BUILD_INFO = null; + private static final String TEST_PACKAGE_NAME = "test.package.name"; + private static final Answer<Object> EMPTY_ANSWER = (i) -> null; @Rule public final TemporaryFolder tempFolder = new TemporaryFolder(); - private final IBuildInfo mBuildInfo = new BuildInfo(); - private final TestAppInstallSetup mMockAppInstallSetup = mock(TestAppInstallSetup.class); - private final AppSetupPreparer mPreparer = - new AppSetupPreparer("package_name", mMockAppInstallSetup); + @Test + public void setUp_unresolvedAppUri_installs() throws Exception { + String appUri = "app://com.example.app"; + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_TEST_FILE_NAME, appUri) + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + + verify(installer).addTestFile(new File(appUri)); + } + + @Test + public void tearDown_forwardsToInstaller() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + AppSetupPreparer preparer = new PreparerBuilder().setInstaller(installer).build(); + TestInformation testInfo = TestInformation.newBuilder().build(); + + preparer.tearDown(testInfo, null); + + verify(installer).tearDown(testInfo, null); + } + + @Test + public void setUp_withinRetryLimit_doesNotThrowException() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doThrow(new TargetSetupError("Still failing")) + .doNothing() + .when(installer) + .setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "1") + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + } + + @Test + public void setUp_exceedsRetryLimit_throwsException() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doThrow(new TargetSetupError("Still failing")) + .doThrow(new TargetSetupError("Still failing")) + .doNothing() + .when(installer) + .setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "1") + .build(); + + assertThrows(TargetSetupError.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); + } + + @Test + public void setUp_negativeTimeout_throwsException() throws Exception { + AppSetupPreparer preparer = + new PreparerBuilder() + .setOption(AppSetupPreparer.OPTION_SETUP_TIMEOUT_MILLIS, "-1") + .build(); + + assertThrows( + IllegalArgumentException.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); + } + + @Test + public void setUp_withinTimeout_doesNotThrowException() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doAnswer(new AnswersWithDelay(10, EMPTY_ANSWER)).when(installer).setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_SETUP_TIMEOUT_MILLIS, "1000") + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + } + + @Test + public void setUp_exceedsTimeout_throwsException() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doAnswer(new AnswersWithDelay(10, EMPTY_ANSWER)).when(installer).setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_SETUP_TIMEOUT_MILLIS, "5") + .build(); + + assertThrows(TargetSetupError.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); + } + + @Test + public void setUp_timesOutWithoutExceedingRetryLimit_doesNotThrowException() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doAnswer(new AnswersWithDelay(10, EMPTY_ANSWER)) + .doNothing() + .when(installer) + .setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "1") + .setOption(AppSetupPreparer.OPTION_SETUP_TIMEOUT_MILLIS, "5") + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + } + + @Test + public void setUp_timesOutAndExceedsRetryLimit_doesNotThrowException() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doAnswer(new AnswersWithDelay(10, EMPTY_ANSWER)).when(installer).setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "1") + .setOption(AppSetupPreparer.OPTION_SETUP_TIMEOUT_MILLIS, "5") + .build(); + + assertThrows(TargetSetupError.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); + } + + @Test + public void setUp_zeroMaxRetry_runsOnce() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doNothing().when(installer).setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "0") + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + + verify(installer).setUp(any(), any()); + } + + @Test + public void setUp_positiveMaxRetryButNoException_runsOnlyOnce() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doNothing().when(installer).setUp(any(), any()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "1") + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + + verify(installer).setUp(any(), any()); + } @Test - public void setUp_gcsApkDirIsNull_throwsException() - throws DeviceNotAvailableException, TargetSetupError { - mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, null); + public void setUp_negativeMaxRetry_throwsException() throws Exception { + AppSetupPreparer preparer = + new PreparerBuilder().setOption(AppSetupPreparer.OPTION_MAX_RETRY, "-1").build(); - assertThrows(NullPointerException.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo)); + assertThrows( + IllegalArgumentException.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); } @Test - public void setUp_gcsApkDirIsNotDir_throwsException() - throws IOException, DeviceNotAvailableException, TargetSetupError { - File tempFile = tempFolder.newFile("temp_file_name"); - mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, tempFile.getPath()); + public void setUp_deviceNotAvailableAndWaitEnabled_throwsDeviceNotAvailableException() + throws Exception { + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller( + mockInstallerThatThrows( + new TargetSetupError("Connection reset by peer."))) + .setOption(AppSetupPreparer.OPTION_WAIT_FOR_DEVICE_AVAILABLE_SECONDS, "1") + .build(); + ITestDevice device = createUnavailableDevice(); assertThrows( - IllegalArgumentException.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo)); + DeviceNotAvailableException.class, () -> preparer.setUp(device, NULL_BUILD_INFO)); } @Test - public void setUp_packageDirDoesNotExist_throwsError() - throws IOException, DeviceNotAvailableException, TargetSetupError { - File gcsApkDir = tempFolder.newFolder("gcs_apk_dir"); - mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath()); + public void setUp_deviceAvailableAndWaitEnabled_doesNotChangeException() throws Exception { + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller( + mockInstallerThatThrows( + new TargetSetupError("Connection reset by peer."))) + .setOption(AppSetupPreparer.OPTION_WAIT_FOR_DEVICE_AVAILABLE_SECONDS, "1") + .build(); + ITestDevice device = createAvailableDevice(); + + assertThrows(TargetSetupError.class, () -> preparer.setUp(device, NULL_BUILD_INFO)); + } + + @Test + public void setUp_deviceNotAvailableAndWaitDisabled_doesNotChangeException() throws Exception { + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller( + mockInstallerThatThrows( + new TargetSetupError("Connection reset by peer."))) + .setOption(AppSetupPreparer.OPTION_WAIT_FOR_DEVICE_AVAILABLE_SECONDS, "-1") + .build(); + ITestDevice device = createUnavailableDevice(); + + assertThrows(TargetSetupError.class, () -> preparer.setUp(device, NULL_BUILD_INFO)); + } + + @Test + public void setUp_negativeExponentialBackoffMultiplier_throwsIllegalArgumentException() + throws Exception { + AppSetupPreparer preparer = + new PreparerBuilder() + .setOption( + AppSetupPreparer.OPTION_EXPONENTIAL_BACKOFF_MULTIPLIER_SECONDS, + "-1") + .build(); assertThrows( - IllegalArgumentException.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo)); + IllegalArgumentException.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); } @Test - public void setUp_apkDoesNotExist() throws Exception { - File gcsApkDir = tempFolder.newFolder("gcs_apk_dir"); - createPackageFile(gcsApkDir, "package_name", "non_apk_file"); - mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath()); + public void setUp_testFileNameOptionSet_forwardsToInstaller() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + ArgumentCaptor<File> captor = ArgumentCaptor.forClass(File.class); + doNothing().when(installer).addTestFile(captor.capture()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_TEST_FILE_NAME, "additional1.apk") + .setOption(AppSetupPreparer.OPTION_TEST_FILE_NAME, "additional2.apk") + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); - assertThrows(TargetSetupError.class, () -> mPreparer.setUp(NULL_DEVICE, mBuildInfo)); + assertThat(captor.getAllValues()) + .containsAtLeast(new File("additional1.apk"), new File("additional2.apk")); } @Test - public void setUp_installSplitApk() throws Exception { - File gcsApkDir = tempFolder.newFolder("gcs_apk_dir"); - File packageDir = new File(gcsApkDir.getPath(), "package_name"); - createPackageFile(gcsApkDir, "package_name", "apk_name_1.apk"); - createPackageFile(gcsApkDir, "package_name", "apk_name_2.apk"); - mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath()); + public void setUp_installArgOptionSet_forwardsToInstaller() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + ArgumentCaptor<String> captor = ArgumentCaptor.forClass(String.class); + doNothing().when(installer).addInstallArg(captor.capture()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_INSTALL_ARG, "-arg1") + .setOption(AppSetupPreparer.OPTION_INSTALL_ARG, "-arg2") + .build(); - mPreparer.setUp(NULL_DEVICE, mBuildInfo); + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); - verify(mMockAppInstallSetup).setAltDir(packageDir); - verify(mMockAppInstallSetup).addSplitApkFileNames("apk_name_2.apk,apk_name_1.apk"); - verify(mMockAppInstallSetup).setUp(any(), any()); + assertThat(captor.getAllValues()).containsExactly("-arg1", "-arg2"); } @Test - public void setUp_installNonSplitApk() throws Exception { - File gcsApkDir = tempFolder.newFolder("gcs_apk_dir"); - File packageDir = new File(gcsApkDir.getPath(), "package_name"); - createPackageFile(gcsApkDir, "package_name", "apk_name_1.apk"); - mBuildInfo.addBuildAttribute(OPTION_GCS_APK_DIR, gcsApkDir.getPath()); + public void setUp_installIncrementalOptionSet_forwardsToInstaller() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); - mPreparer.setUp(NULL_DEVICE, mBuildInfo); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_INCREMENTAL_INSTALL, "true") + .build(); - verify(mMockAppInstallSetup).setAltDir(packageDir); - verify(mMockAppInstallSetup).addTestFileName("apk_name_1.apk"); - verify(mMockAppInstallSetup).setUp(any(), any()); + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + String result = ArgsOptionParser.getOptionHelp(false, installer); + System.out.println(result); + + assertThat(result).contains("incremental"); } @Test - public void tearDown() throws Exception { - TestInformation testInfo = TestInformation.newBuilder().build(); + public void setUp_aaptVersionOptionSet_forwardsToInstaller() throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + ArgumentCaptor<AaptVersion> captor = ArgumentCaptor.forClass(AaptVersion.class); + doNothing().when(installer).setAaptVersion(captor.capture()); + AppSetupPreparer preparer = + new PreparerBuilder() + .setInstaller(installer) + .setOption(AppSetupPreparer.OPTION_AAPT_VERSION, "AAPT2") + .build(); + + preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO); + + assertThat(captor.getValue()).isEqualTo(AaptVersion.AAPT2); + } + + @Test + public void setUp_zeroExponentialBackoffMultiplier_noSleepBetweenRetries() throws Exception { + FakeSleeper fakeSleeper = new FakeSleeper(); + AppSetupPreparer preparer = + new PreparerBuilder() + .setSleeper(fakeSleeper) + .setInstaller(mockInstallerThatThrows(new TargetSetupError("Oops"))) + .setOption( + AppSetupPreparer.OPTION_EXPONENTIAL_BACKOFF_MULTIPLIER_SECONDS, "0") + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "1") + .build(); + + assertThrows(TargetSetupError.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); + assertThat(fakeSleeper.getSleepHistory().get(0)).isEqualTo(Duration.ofSeconds(0)); + } - mPreparer.tearDown(testInfo, null); + @Test + public void setUp_positiveExponentialBackoffMultiplier_sleepsBetweenRetries() throws Exception { + FakeSleeper fakeSleeper = new FakeSleeper(); + AppSetupPreparer preparer = + new PreparerBuilder() + .setSleeper(fakeSleeper) + .setInstaller(mockInstallerThatThrows(new TargetSetupError("Oops"))) + .setOption( + AppSetupPreparer.OPTION_EXPONENTIAL_BACKOFF_MULTIPLIER_SECONDS, "3") + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "3") + .build(); - verify(mMockAppInstallSetup, times(1)).tearDown(testInfo, null); + assertThrows(TargetSetupError.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); + assertThat(fakeSleeper.getSleepHistory().get(0)).isEqualTo(Duration.ofSeconds(3)); + assertThat(fakeSleeper.getSleepHistory().get(1)).isEqualTo(Duration.ofSeconds(9)); + assertThat(fakeSleeper.getSleepHistory().get(2)).isEqualTo(Duration.ofSeconds(27)); } - private File createPackageFile(File parentDir, String packageName, String apkName) - throws IOException { - File packageDir = - Files.createDirectories(Paths.get(parentDir.getAbsolutePath(), packageName)) - .toFile(); + @Test + public void setUp_interruptedDuringBackoff_throwsException() throws Exception { + FakeSleeper fakeSleeper = new FakeInterruptedSleeper(); + AppSetupPreparer preparer = + new PreparerBuilder() + .setSleeper(fakeSleeper) + .setInstaller(mockInstallerThatThrows(new TargetSetupError("Oops"))) + .setOption( + AppSetupPreparer.OPTION_EXPONENTIAL_BACKOFF_MULTIPLIER_SECONDS, "3") + .setOption(AppSetupPreparer.OPTION_MAX_RETRY, "3") + .build(); + + try { + assertThrows( + TargetSetupError.class, () -> preparer.setUp(NULL_DEVICE, NULL_BUILD_INFO)); + assertThat(Thread.currentThread().isInterrupted()).isTrue(); + assertThat(fakeSleeper.getSleepHistory().size()).isEqualTo(1); + } finally { + // Clear interrupt to not interfere with future tests. + Thread.interrupted(); + } + } + + private TestAppInstallSetup mockInstallerThatThrows(Exception e) throws Exception { + TestAppInstallSetup installer = mock(TestAppInstallSetup.class); + doThrow(e).when(installer).setUp(any(), any()); + return installer; + } + + private File createPackageFile(String packageName, String apkName) throws IOException { + Path packageDir = + Files.createDirectories( + Paths.get(tempFolder.newFolder("any").getAbsolutePath(), packageName)); + Files.createFile(packageDir.resolve(apkName)); + + return packageDir.toFile(); + } + + private static ITestDevice createUnavailableDevice() throws Exception { + ITestDevice device = mock(ITestDevice.class); + when(device.getProperty(any())).thenReturn(null); + doThrow(new DeviceNotAvailableException("_", "serial")) + .when(device) + .waitForDeviceAvailable(anyLong()); + return device; + } + + private static ITestDevice createAvailableDevice() throws Exception { + ITestDevice device = mock(ITestDevice.class); + when(device.getProperty(any())).thenReturn(""); + when(device.waitForDeviceShell(anyLong())).thenReturn(true); + doNothing().when(device).waitForDeviceAvailable(anyLong()); + + return device; + } + + private static class FakeSleeper implements AppSetupPreparer.Sleeper { + private ArrayList<Duration> mSleepHistory = new ArrayList<>(); + + @Override + public void sleep(Duration duration) throws InterruptedException { + mSleepHistory.add(duration); + } + + ArrayList<Duration> getSleepHistory() { + return mSleepHistory; + } + } + + private static class FakeInterruptedSleeper extends FakeSleeper { + @Override + public void sleep(Duration duration) throws InterruptedException { + super.sleep(duration); + throw new InterruptedException("_"); + } + } + + private static final class PreparerBuilder { + + private AppSetupPreparer.Sleeper mSleeper = new FakeSleeper(); + private TestAppInstallSetup mInstaller = mock(TestAppInstallSetup.class); + private final ListMultimap<String, String> mOptions = ArrayListMultimap.create(); + + PreparerBuilder setSleeper(AppSetupPreparer.Sleeper sleeper) { + this.mSleeper = sleeper; + return this; + } + + PreparerBuilder setInstaller(TestAppInstallSetup installer) { + this.mInstaller = installer; + return this; + } + + PreparerBuilder setOption(String key, String value) { + mOptions.put(key, value); + return this; + } + + AppSetupPreparer build() throws ConfigurationException { + AppSetupPreparer preparer = new AppSetupPreparer(mInstaller, mSleeper); + OptionSetter optionSetter = new OptionSetter(preparer); + + for (Map.Entry<String, String> e : mOptions.entries()) { + optionSetter.setOptionValue(e.getKey(), e.getValue()); + } - return Files.createFile(Paths.get(packageDir.getAbsolutePath(), apkName)).toFile(); + return preparer; + } } } diff --git a/harness/src/test/java/com/android/compatibility/targetprep/CheckGmsPreparerTest.java b/harness/src/test/java/com/android/compatibility/targetprep/CheckGmsPreparerTest.java new file mode 100644 index 0000000..09f2141 --- /dev/null +++ b/harness/src/test/java/com/android/compatibility/targetprep/CheckGmsPreparerTest.java @@ -0,0 +1,289 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.compatibility.targetprep; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import com.android.ddmlib.Log; +import com.android.ddmlib.Log.ILogOutput; +import com.android.ddmlib.Log.LogLevel; +import com.android.tradefed.build.BuildInfo; +import com.android.tradefed.config.OptionSetter; +import com.android.tradefed.device.ITestDevice; +import com.android.tradefed.invoker.IInvocationContext; +import com.android.tradefed.invoker.InvocationContext; +import com.android.tradefed.invoker.TestInformation; +import com.android.tradefed.targetprep.TargetSetupError; +import com.android.tradefed.util.CommandResult; +import com.android.tradefed.util.CommandStatus; + +import com.google.common.truth.Correspondence; + +import org.junit.After; +import org.junit.Before; +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mockito; + +import java.util.ArrayList; + +@RunWith(JUnit4.class) +public final class CheckGmsPreparerTest { + + private CheckGmsPreparer mPreparer; + private LogCaptor mLogCaptor; + + @Before + public void setUp() throws Exception { + mPreparer = new CheckGmsPreparer(); + new OptionSetter(mPreparer).setOptionValue(CheckGmsPreparer.OPTION_ENABLE, "true"); + + mLogCaptor = new LogCaptor(); + Log.addLogger(mLogCaptor); + } + + @After + public void tearDown() { + Log.removeLogger(mLogCaptor); + } + + @Test + public void setUp_checkDisabledAndGmsAbsent_doesNotReboot() throws Exception { + ITestDevice device = createDeviceWithGmsAbsent(); + disablePreparer(mPreparer); + + mPreparer.setUp(createTestInfo(device)); + + Mockito.verify(device, Mockito.never()).reboot(); + assertThat(mLogCaptor.getLogItems()) + .comparingElementsUsing(createContainsErrorLogCorrespondence()) + .doesNotContain("GMS"); + } + + @Test + public void tearDown_checkDisabledAndGmsAbsent_doesNotLog() throws Exception { + ITestDevice device = createDeviceWithGmsAbsent(); + disablePreparer(mPreparer); + + mPreparer.tearDown(createTestInfo(device), null); + + assertThat(mLogCaptor.getLogItems()) + .comparingElementsUsing(createContainsErrorLogCorrespondence()) + .doesNotContain("GMS"); + } + + @Test + public void tearDown_setUpThrows_doesNotCheck() throws Exception { + ITestDevice device = createDeviceWithGmsAbsent(); + TestInformation testInfo = createTestInfo(device); + assertThrows(TargetSetupError.class, () -> mPreparer.setUp(testInfo)); + mLogCaptor.reset(); + Mockito.reset(device); + Mockito.when(device.executeShellV2Command(Mockito.any())) + .thenReturn(createFailedCommandResult()); + + mPreparer.tearDown(testInfo, null); + + Mockito.verify(device, Mockito.never()).executeShellV2Command(Mockito.any()); + } + + @Test + public void tearDown_setUpRecoveredGms_checksGms() throws Exception { + ITestDevice device = createDeviceWithGmsAbsentAndRecoverable(); + TestInformation testInfo = createTestInfo(device); + mPreparer.setUp(testInfo); + mLogCaptor.reset(); + Mockito.reset(device); + Mockito.when(device.executeShellV2Command(CheckGmsPreparer.CHECK_GMS_COMMAND)) + .thenReturn(createSuccessfulCommandResult()); + + mPreparer.tearDown(testInfo, null); + + Mockito.verify(device, Mockito.atLeast(1)) + .executeShellV2Command(CheckGmsPreparer.CHECK_GMS_COMMAND); + } + + @Test + public void tearDown_setUpFoundGms_checksGms() throws Exception { + ITestDevice device = createDeviceWithGmsPresent(); + TestInformation testInfo = createTestInfo(device); + mPreparer.setUp(testInfo); + Mockito.reset(device); + mLogCaptor.reset(); + Mockito.when(device.executeShellV2Command(CheckGmsPreparer.CHECK_GMS_COMMAND)) + .thenReturn(createSuccessfulCommandResult()); + + mPreparer.tearDown(testInfo, null); + + Mockito.verify(device, Mockito.atLeast(1)) + .executeShellV2Command(CheckGmsPreparer.CHECK_GMS_COMMAND); + } + + @Test + public void setUp_gmsPresent_doesNotReboot() throws Exception { + ITestDevice device = createDeviceWithGmsPresent(); + + mPreparer.setUp(createTestInfo(device)); + + Mockito.verify(device, Mockito.never()).reboot(); + assertThat(mLogCaptor.getLogItems()) + .comparingElementsUsing(createContainsErrorLogCorrespondence()) + .doesNotContain("GMS"); + } + + @Test + public void setUp_gmsProcessRecoveredAfterReboot_doesNotThrow() throws Exception { + ITestDevice device = createDeviceWithGmsAbsentAndRecoverable(); + + mPreparer.setUp(createTestInfo(device)); + + Mockito.verify(device, Mockito.times(1)).reboot(); + assertThat(mLogCaptor.getLogItems()) + .comparingElementsUsing(createContainsErrorLogCorrespondence()) + .contains("GMS"); + } + + @Test + public void setUp_gmsProcessNotRecoveredAfterReboot_throwsException() throws Exception { + ITestDevice device = createDeviceWithGmsAbsent(); + + assertThrows(TargetSetupError.class, () -> mPreparer.setUp(createTestInfo(device))); + Mockito.verify(device, Mockito.times(1)).reboot(); + assertThat(mLogCaptor.getLogItems()) + .comparingElementsUsing(createContainsErrorLogCorrespondence()) + .contains("GMS"); + } + + @Test + public void tearDown_gmsProcessPresent_doesNotLog() throws Exception { + ITestDevice device = createDeviceWithGmsPresent(); + + mPreparer.tearDown(createTestInfo(device), null); + + assertThat(mLogCaptor.getLogItems()) + .comparingElementsUsing(createContainsErrorLogCorrespondence()) + .doesNotContain("GMS"); + } + + @Test + public void tearDown_gmsProcessAbsent_logsError() throws Exception { + ITestDevice device = createDeviceWithGmsAbsent(); + + mPreparer.tearDown(createTestInfo(device), null); + + assertThat(mLogCaptor.getLogItems()) + .comparingElementsUsing(createContainsErrorLogCorrespondence()) + .contains("GMS"); + } + + private static void disablePreparer(CheckGmsPreparer preparer) throws Exception { + new OptionSetter(preparer).setOptionValue(CheckGmsPreparer.OPTION_ENABLE, "false"); + } + + private static Correspondence<LogItem, String> createContainsErrorLogCorrespondence() { + return Correspondence.from( + (LogItem actual, String expected) -> { + return actual.getLogLevel() == LogLevel.ERROR + && actual.getMessage().contains(expected); + }, + "has an error log that contains"); + } + + private static ITestDevice createDeviceWithGmsAbsentAndRecoverable() throws Exception { + ITestDevice device = Mockito.mock(ITestDevice.class); + Mockito.doReturn(createFailedCommandResult()) + .doReturn(createSuccessfulCommandResult()) + .when(device) + .executeShellV2Command(CheckGmsPreparer.CHECK_GMS_COMMAND); + return device; + } + + private static ITestDevice createDeviceWithGmsPresent() throws Exception { + ITestDevice device = Mockito.mock(ITestDevice.class); + Mockito.when(device.executeShellV2Command(CheckGmsPreparer.CHECK_GMS_COMMAND)) + .thenReturn(createSuccessfulCommandResult()); + return device; + } + + private static ITestDevice createDeviceWithGmsAbsent() throws Exception { + ITestDevice device = Mockito.mock(ITestDevice.class); + Mockito.when(device.executeShellV2Command(CheckGmsPreparer.CHECK_GMS_COMMAND)) + .thenReturn(createFailedCommandResult()); + return device; + } + + private static final class LogCaptor implements ILogOutput { + private ArrayList<LogItem> mLogItems = new ArrayList<>(); + + void reset() { + mLogItems.clear(); + } + + ArrayList<LogItem> getLogItems() { + return mLogItems; + } + + @Override + public void printLog(LogLevel logLevel, String tag, String message) { + mLogItems.add(new LogItem(logLevel, tag, message)); + } + + @Override + public void printAndPromptLog(LogLevel logLevel, String tag, String message) { + printLog(logLevel, tag, message); + } + } + + private static final class LogItem { + private LogLevel mLogLevel; + private String mMessage; + + LogLevel getLogLevel() { + return mLogLevel; + } + + String getMessage() { + return mMessage; + } + + LogItem(LogLevel logLevel, String tag, String message) { + mLogLevel = logLevel; + mMessage = message; + } + } + + private static TestInformation createTestInfo(ITestDevice device) { + IInvocationContext context = new InvocationContext(); + context.addAllocatedDevice("device1", device); + context.addDeviceBuildInfo("device1", new BuildInfo()); + return TestInformation.newBuilder().setInvocationContext(context).build(); + } + + private static CommandResult createSuccessfulCommandResult() { + CommandResult commandResult = new CommandResult(CommandStatus.SUCCESS); + commandResult.setExitCode(0); + return commandResult; + } + + private static CommandResult createFailedCommandResult() { + CommandResult commandResult = new CommandResult(CommandStatus.FAILED); + commandResult.setExitCode(1); + return commandResult; + } +} diff --git a/harness/src/test/java/com/android/compatibility/testtype/AppLaunchTestTest.java b/harness/src/test/java/com/android/compatibility/testtype/AppLaunchTestTest.java index 4f9aa35..0c23cc3 100644 --- a/harness/src/test/java/com/android/compatibility/testtype/AppLaunchTestTest.java +++ b/harness/src/test/java/com/android/compatibility/testtype/AppLaunchTestTest.java @@ -15,16 +15,20 @@ */ package com.android.compatibility.testtype; +import com.android.tradefed.config.OptionSetter; import com.android.tradefed.device.DeviceNotAvailableException; import com.android.tradefed.device.ITestDevice; import com.android.tradefed.invoker.TestInformation; import com.android.tradefed.metrics.proto.MetricMeasurement.Metric; +import com.android.tradefed.result.FileInputStreamSource; import com.android.tradefed.result.ITestInvocationListener; +import com.android.tradefed.result.InputStreamSource; import com.android.tradefed.result.TestDescription; import com.android.tradefed.testtype.InstrumentationTest; import com.android.tradefed.util.CommandResult; import com.android.tradefed.util.CommandStatus; + import static org.junit.Assert.assertEquals; import static org.junit.Assert.assertFalse; import static org.junit.Assert.assertTrue; @@ -39,10 +43,13 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.times; import static org.mockito.Mockito.when; +import org.junit.Rule; import org.junit.Test; +import org.junit.rules.TemporaryFolder; import org.junit.runner.RunWith; import org.junit.runners.JUnit4; import org.mockito.InOrder; +import org.mockito.Mockito; import java.util.HashMap; import java.util.HashSet; @@ -55,6 +62,7 @@ public final class AppLaunchTestTest { private final ITestInvocationListener mMockListener = mock(ITestInvocationListener.class); private static final String TEST_PACKAGE_NAME = "package_name"; private static final TestInformation NULL_TEST_INFORMATION = null; + @Rule public final TemporaryFolder tempFolder = new TemporaryFolder(); @Test public void run_testFailed() throws DeviceNotAvailableException { @@ -77,10 +85,28 @@ public final class AppLaunchTestTest { } @Test + public void run_takeScreenShot_savesToTestLog() throws Exception { + InstrumentationTest instrumentationTest = createPassingInstrumentationTest(); + AppLaunchTest appLaunchTest = createLaunchTestWithInstrumentation(instrumentationTest); + new OptionSetter(appLaunchTest) + .setOptionValue(AppLaunchTest.SCREENSHOT_AFTER_LAUNCH, "true"); + ITestDevice mMockDevice = mock(ITestDevice.class); + appLaunchTest.setDevice(mMockDevice); + InputStreamSource screenshotData = new FileInputStreamSource(tempFolder.newFile()); + when(mMockDevice.getScreenshot()).thenReturn(screenshotData); + when(mMockDevice.getSerialNumber()).thenReturn("SERIAL"); + + appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener); + + Mockito.verify(mMockListener, times(1)) + .testLog(Mockito.contains("screenshot"), Mockito.any(), Mockito.eq(screenshotData)); + } + + @Test public void run_packageResetSuccess() throws DeviceNotAvailableException { ITestDevice mMockDevice = mock(ITestDevice.class); when(mMockDevice.executeShellV2Command(String.format("pm clear %s", TEST_PACKAGE_NAME))) - .thenReturn(new CommandResult(CommandStatus.SUCCESS)); + .thenReturn(createSuccessfulCommandResult()); AppLaunchTest appLaunchTest = createLaunchTestWithMockDevice(mMockDevice); appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener); @@ -92,7 +118,7 @@ public final class AppLaunchTestTest { public void run_packageResetError() throws DeviceNotAvailableException { ITestDevice mMockDevice = mock(ITestDevice.class); when(mMockDevice.executeShellV2Command(String.format("pm clear %s", TEST_PACKAGE_NAME))) - .thenReturn(new CommandResult(CommandStatus.FAILED)); + .thenReturn(createFailedCommandResult()); AppLaunchTest appLaunchTest = createLaunchTestWithMockDevice(mMockDevice); appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener); @@ -103,7 +129,7 @@ public final class AppLaunchTestTest { @Test public void run_testRetry_passedAfterTwoFailings() throws Exception { InstrumentationTest instrumentationTest = createPassingInstrumentationTestAfterFailing(2); - AppLaunchTest appLaunchTest = createLaunchTestWithRetry(instrumentationTest, 2); + AppLaunchTest appLaunchTest = createLaunchTestWithInstrumentation(instrumentationTest, 2); appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener); @@ -113,7 +139,7 @@ public final class AppLaunchTestTest { @Test public void run_testRetry_failedAfterThreeFailings() throws Exception { InstrumentationTest instrumentationTest = createPassingInstrumentationTestAfterFailing(3); - AppLaunchTest appLaunchTest = createLaunchTestWithRetry(instrumentationTest, 2); + AppLaunchTest appLaunchTest = createLaunchTestWithInstrumentation(instrumentationTest, 2); appLaunchTest.run(NULL_TEST_INFORMATION, mMockListener); @@ -369,24 +395,10 @@ public final class AppLaunchTestTest { } private AppLaunchTest createLaunchTestWithInstrumentation(InstrumentationTest instrumentation) { - AppLaunchTest appLaunchTest = - new AppLaunchTest(TEST_PACKAGE_NAME) { - @Override - protected InstrumentationTest createInstrumentationTest( - String packageBeingTested) { - return instrumentation; - } - - @Override - protected CommandResult resetPackage() throws DeviceNotAvailableException { - return new CommandResult(CommandStatus.SUCCESS); - } - }; - appLaunchTest.setDevice(mock(ITestDevice.class)); - return appLaunchTest; + return createLaunchTestWithInstrumentation(instrumentation, 0); } - private AppLaunchTest createLaunchTestWithRetry( + private AppLaunchTest createLaunchTestWithInstrumentation( InstrumentationTest instrumentation, int retryCount) { AppLaunchTest appLaunchTest = new AppLaunchTest(TEST_PACKAGE_NAME, retryCount) { @@ -398,7 +410,7 @@ public final class AppLaunchTestTest { @Override protected CommandResult resetPackage() throws DeviceNotAvailableException { - return new CommandResult(CommandStatus.SUCCESS); + return createSuccessfulCommandResult(); } }; appLaunchTest.setDevice(mock(ITestDevice.class)); @@ -406,7 +418,7 @@ public final class AppLaunchTestTest { } private AppLaunchTest createLaunchTestWithMockDevice(ITestDevice device) { - AppLaunchTest appLaunchTest = new AppLaunchTest(TEST_PACKAGE_NAME); + AppLaunchTest appLaunchTest = new AppLaunchTest(TEST_PACKAGE_NAME, 0); appLaunchTest.setDevice(device); return appLaunchTest; } @@ -430,4 +442,16 @@ public final class AppLaunchTestTest { .testEnded(anyObject(), anyLong(), (Map<String, String>) any()); inOrder.verify(listener, times(1)).testRunEnded(anyLong(), (HashMap<String, Metric>) any()); } + + private CommandResult createSuccessfulCommandResult() { + CommandResult commandResult = new CommandResult(CommandStatus.SUCCESS); + commandResult.setExitCode(0); + return commandResult; + } + + private CommandResult createFailedCommandResult() { + CommandResult commandResult = new CommandResult(CommandStatus.FAILED); + commandResult.setExitCode(1); + return commandResult; + } } diff --git a/harness/src/test/java/com/android/compatibility/CSuiteUnitTests.java b/harness/src/test/java/com/android/csuite/CSuiteUnitTests.java index b87402e..6eb1103 100644 --- a/harness/src/test/java/com/android/compatibility/CSuiteUnitTests.java +++ b/harness/src/test/java/com/android/csuite/CSuiteUnitTests.java @@ -13,11 +13,7 @@ * See the License for the specific language governing permissions and * limitations under the License. */ -package com.android.compatibility; - -import com.android.compatibility.targetprep.AppSetupPreparerConfigurationReceiverTest; -import com.android.compatibility.targetprep.AppSetupPreparerTest; -import com.android.compatibility.testtype.AppLaunchTestTest; +package com.android.csuite; import org.junit.runner.RunWith; import org.junit.runners.Suite; @@ -25,10 +21,16 @@ import org.junit.runners.Suite.SuiteClasses; @RunWith(Suite.class) @SuiteClasses({ - AppCompatibilityTestTest.class, - AppLaunchTestTest.class, - AppSetupPreparerTest.class, - AppSetupPreparerConfigurationReceiverTest.class, + com.android.compatibility.targetprep.AppSetupPreparerTest.class, + com.android.compatibility.targetprep.CheckGmsPreparerTest.class, + com.android.compatibility.testtype.AppLaunchTestTest.class, + com.android.csuite.config.AppRemoteFileResolverTest.class, + com.android.csuite.config.ModuleGeneratorTest.class, + com.android.csuite.core.CommandLinePackageNameProviderTest.class, + com.android.csuite.core.FileBasedPackageNameProviderTest.class, + com.android.csuite.core.SystemAppUninstallerTest.class, + com.android.csuite.testing.CorrespondencesTest.class, + com.android.csuite.testing.MoreAssertsTest.class, }) public final class CSuiteUnitTests { // Intentionally empty. diff --git a/harness/src/test/java/com/android/csuite/config/AppRemoteFileResolverTest.java b/harness/src/test/java/com/android/csuite/config/AppRemoteFileResolverTest.java new file mode 100644 index 0000000..01c6ecf --- /dev/null +++ b/harness/src/test/java/com/android/csuite/config/AppRemoteFileResolverTest.java @@ -0,0 +1,279 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.csuite.config; + +import static com.android.csuite.testing.Correspondences.instanceOf; +import static com.android.csuite.testing.MoreAsserts.assertThrows; + +import static com.google.common.truth.Truth.assertThat; + +import static java.nio.charset.StandardCharsets.UTF_8; + +import com.android.tradefed.build.BuildRetrievalError; +import com.android.tradefed.config.ConfigurationException; +import com.android.tradefed.config.Option; +import com.android.tradefed.config.OptionSetter; +import com.android.tradefed.config.remote.IRemoteFileResolver; + +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableMap; +import com.google.common.testing.NullPointerTester; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.io.File; +import java.io.FileOutputStream; +import java.io.IOException; +import java.io.OutputStreamWriter; +import java.io.PrintWriter; +import java.net.URL; +import java.net.URLClassLoader; +import java.util.ServiceLoader; +import java.util.jar.JarEntry; +import java.util.jar.JarOutputStream; + +@RunWith(JUnit4.class) +public final class AppRemoteFileResolverTest { + + private static final String PACKAGE_NAME = "com.example.app"; + private static final File APP_URI_FILE = uriToFile("app://" + PACKAGE_NAME); + private static final ImmutableMap<String, String> EMPTY_PARAMS = ImmutableMap.of(); + + @Rule public final TemporaryFolder temporaryFolder = new TemporaryFolder(); + + // Class sanity tests. + + @Test + public void isServiceLoadable() throws Exception { + ClassLoader classLoader = classLoaderWithProviders(AppRemoteFileResolver.class.getName()); + + ServiceLoader<IRemoteFileResolver> serviceLoader = + ServiceLoader.load(IRemoteFileResolver.class, classLoader); + + // Copy the list to provide better failure error messages since ServiceLoader's string + // representation is not very informative. + assertThat(ImmutableList.copyOf(serviceLoader)) + .comparingElementsUsing(instanceOf()) + .contains(AppRemoteFileResolver.class); + } + + @Test + public void nullPointers() { + NullPointerTester tester = new NullPointerTester(); + tester.setDefault(File.class, APP_URI_FILE); + tester.testAllPublicConstructors(AppRemoteFileResolver.class); + tester.testAllPublicInstanceMethods(new AppRemoteFileResolver()); + } + + // URI validation tests. + + @Test + public void unsupportedUriScheme_throwsException() throws Exception { + AppRemoteFileResolver resolver = newResolverWithAnyTemplate(); + String uri = "gs://" + PACKAGE_NAME; + File f = uriToFile(uri); + + Throwable thrown = + assertThrows( + IllegalArgumentException.class, + () -> resolver.resolveRemoteFiles(f, EMPTY_PARAMS)); + + assertThat(thrown).hasMessageThat().contains("(gs)"); + assertThat(thrown).hasMessageThat().contains(uri); + } + + @Test + public void opaqueUri_throwsException() throws Exception { + AppRemoteFileResolver resolver = newResolverWithAnyTemplate(); + File uri = uriToFile("app:" + PACKAGE_NAME); + + Throwable thrown = + assertThrows( + IllegalArgumentException.class, + () -> resolver.resolveRemoteFiles(uri, EMPTY_PARAMS)); + + assertThat(thrown).hasMessageThat().contains("package name"); + } + + @Test + public void uriHasPathComponent_throwsException() throws Exception { + AppRemoteFileResolver resolver = newResolverWithAnyTemplate(); + File uri = uriToFile("app://" + PACKAGE_NAME + "/invalid"); + + Throwable thrown = + assertThrows( + IllegalArgumentException.class, + () -> resolver.resolveRemoteFiles(uri, EMPTY_PARAMS)); + + assertThat(thrown).hasMessageThat().contains("invalid"); + } + + // Template validation and expansion tests. + + @Test + public void templateNotSet_returnsNull() throws Exception { + AppRemoteFileResolver resolver = new AppRemoteFileResolver(); + + File actual = resolver.resolveRemoteFiles(APP_URI_FILE, EMPTY_PARAMS); + + assertThat(actual).isNull(); + } + + @Test + public void emptyTemplate_throwsException() throws Exception { + AppRemoteFileResolver resolver = newResolverWithTemplate(""); + + Throwable thrown = + assertThrows( + IllegalStateException.class, + () -> resolver.resolveRemoteFiles(APP_URI_FILE, EMPTY_PARAMS)); + + assertThat(thrown).hasMessageThat().contains(AppRemoteFileResolver.URI_TEMPLATE_OPTION); + } + + @Test + public void templateHasNoPlaceholders_returnsFileWithoutExpansion() throws Exception { + File expected = temporaryFolder.newFolder(); + AppRemoteFileResolver resolver = newResolverWithTemplate(expected.toURI().toString()); + + File actual = resolver.resolveRemoteFiles(APP_URI_FILE, EMPTY_PARAMS); + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void templateContainsPlaceholderForUndefinedVar_throwsException() throws Exception { + AppRemoteFileResolver resolver = newResolverWithTemplate("file://{undefined}"); + + Throwable thrown = + assertThrows( + IllegalStateException.class, + () -> resolver.resolveRemoteFiles(APP_URI_FILE, EMPTY_PARAMS)); + + assertThat(thrown).hasMessageThat().contains("undefined"); + } + + @Test + public void templateExpandsToInvalidUri_throwsException() throws Exception { + AppRemoteFileResolver resolver = newResolverWithTemplate("file:\\{package}"); + + Throwable thrown = + assertThrows( + IllegalStateException.class, + () -> resolver.resolveRemoteFiles(APP_URI_FILE, EMPTY_PARAMS)); + + assertThat(thrown).hasMessageThat().contains(AppRemoteFileResolver.URI_TEMPLATE_OPTION); + } + + @Test + public void templateContainsPlaceholder_resolvesUriToFile() throws Exception { + File parent = temporaryFolder.newFolder(); + File expected = new File(parent, PACKAGE_NAME); + String template = new File(parent, "{package}").toString(); + AppRemoteFileResolver resolver = newResolverWithTemplate(template); + File uri = uriToFile("app://" + PACKAGE_NAME); + + File actual = resolver.resolveRemoteFiles(uri, EMPTY_PARAMS); + + assertThat(actual).isEqualTo(expected); + } + + @Test + public void templateExpandsToAppUri_throwsException() throws Exception { + AppRemoteFileResolver resolver = newResolverWithTemplate("app://{package}"); + + Throwable thrown = + assertThrows( + BuildRetrievalError.class, + () -> resolver.resolveRemoteFiles(APP_URI_FILE, EMPTY_PARAMS)); + + assertThat(thrown).hasMessageThat().contains("'app'"); + } + + @Test + public void templateExpandsToUriWithUnsupportedScheme_returnsExpandedUri() throws Exception { + String uri = "unsupported://" + PACKAGE_NAME; + AppRemoteFileResolver resolver = newResolverWithTemplate(uri); + File expected = uriToFile(uri); + + File actual = resolver.resolveRemoteFiles(APP_URI_FILE, EMPTY_PARAMS); + + assertThat(actual).isEqualTo(expected); + } + + // Utility classes and methods. + + /** + * Constructs a File from a URI string using the same logic TradeFed uses since it's tricky and + * has some gotchas such as stripping slashes. + */ + private static File uriToFile(String str) { + FileOptionSource optionSource = new FileOptionSource(); + + try { + OptionSetter setter = new OptionSetter(optionSource); + setter.setOptionValue(FileOptionSource.OPTION_NAME, str); + } catch (ConfigurationException e) { + throw new RuntimeException(e); + } + + return optionSource.file; + } + + private static final class FileOptionSource { + static final String OPTION_NAME = "file"; + + @Option(name = OPTION_NAME) + public File file; + } + + private static AppRemoteFileResolver newResolverWithAnyTemplate() + throws ConfigurationException { + return newResolverWithTemplate("file:///tmp/{package}"); + } + + private static AppRemoteFileResolver newResolverWithTemplate(String uriTemplate) + throws ConfigurationException { + AppRemoteFileResolver resolver = new AppRemoteFileResolver(); + OptionSetter setter = new OptionSetter(resolver); + setter.setOptionValue("app:" + AppRemoteFileResolver.URI_TEMPLATE_OPTION, uriTemplate); + return resolver; + } + + private ClassLoader classLoaderWithProviders(String... lines) throws IOException { + String service = IRemoteFileResolver.class.getName(); + File jar = temporaryFolder.newFile(); + + try (JarOutputStream out = new JarOutputStream(new FileOutputStream(jar))) { + JarEntry jarEntry = new JarEntry("META-INF/services/" + service); + + out.putNextEntry(jarEntry); + PrintWriter writer = new PrintWriter(new OutputStreamWriter(out, UTF_8)); + + for (String line : lines) { + writer.println(line); + } + + writer.flush(); + } + + return new URLClassLoader(new URL[] {jar.toURI().toURL()}); + } +} diff --git a/harness/src/test/java/com/android/csuite/config/ModuleGeneratorTest.java b/harness/src/test/java/com/android/csuite/config/ModuleGeneratorTest.java new file mode 100644 index 0000000..a679828 --- /dev/null +++ b/harness/src/test/java/com/android/csuite/config/ModuleGeneratorTest.java @@ -0,0 +1,321 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.config; + +import static com.google.common.truth.Truth.assertThat; + +import static org.testng.Assert.assertThrows; + +import com.android.csuite.core.PackageNameProvider; +import com.android.tradefed.build.BuildInfo; +import com.android.tradefed.config.Configuration; +import com.android.tradefed.config.IConfiguration; +import com.android.tradefed.config.OptionSetter; +import com.android.tradefed.device.ITestDevice; +import com.android.tradefed.invoker.IInvocationContext; +import com.android.tradefed.invoker.InvocationContext; +import com.android.tradefed.invoker.TestInformation; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableList; +import com.google.common.collect.ImmutableSet; +import com.google.common.collect.ListMultimap; +import com.google.common.jimfs.Jimfs; +import com.google.common.truth.IterableSubject; +import com.google.common.truth.StringSubject; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.Mockito; + +import java.io.IOException; +import java.io.UncheckedIOException; +import java.nio.file.FileSystem; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +@RunWith(JUnit4.class) +public final class ModuleGeneratorTest { + private static final String TEST_PACKAGE_NAME1 = "test.package.name1"; + private static final String TEST_PACKAGE_NAME2 = "test.package.name2"; + private static final String PACKAGE_PLACEHOLDER = "{package}"; + private static final Exception NO_EXCEPTION = null; + + private final FileSystem mFileSystem = + Jimfs.newFileSystem(com.google.common.jimfs.Configuration.unix()); + + @Test + public void tearDown_packageNamesProvided_deletesGeneratedModules() throws Exception { + Path testsDir = createTestsDir(); + ModuleGenerator generator1 = + createGeneratorBuilder() + .setTestsDir(testsDir) + .addPackage(TEST_PACKAGE_NAME1) + .addPackage(TEST_PACKAGE_NAME2) + .build(); + generator1.split(); + ModuleGenerator generator2 = + createGeneratorBuilder() + .setTestsDir(testsDir) + .addPackage(TEST_PACKAGE_NAME1) + .addPackage(TEST_PACKAGE_NAME2) + .build(); + + generator2.tearDown(createTestInfo(), NO_EXCEPTION); + + assertThatListDirectory(testsDir).isEmpty(); + } + + @Test + public void tearDown_packageNamesNotProvided_doesNotThrowError() throws Exception { + ModuleGenerator generator = createGeneratorBuilder().setTestsDir(createTestsDir()).build(); + generator.split(); + + generator.tearDown(createTestInfo(), NO_EXCEPTION); + } + + @Test + public void split_packageNameIsEmptyString_throwsError() throws Exception { + ModuleGenerator generator = createGeneratorBuilder().addPackage("").build(); + + assertThrows(IllegalArgumentException.class, () -> generator.split()); + } + + @Test + public void split_packageNameContainsPlaceholder_throwsError() throws Exception { + ModuleGenerator generator = + createGeneratorBuilder().addPackage("a" + PACKAGE_PLACEHOLDER + "b").build(); + + assertThrows(IllegalArgumentException.class, () -> generator.split()); + } + + @Test + public void split_multiplePackageNameProviders_generateModulesForAll() throws Exception { + Path testsDir = createTestsDir(); + ModuleGenerator generator = + createGeneratorBuilder() + .setTestsDir(testsDir) + .addPackageNameProvider(() -> ImmutableSet.of(TEST_PACKAGE_NAME1)) + // Simulates package providers providing duplicated package names. + .addPackageNameProvider(() -> ImmutableSet.of(TEST_PACKAGE_NAME1)) + .addPackageNameProvider(() -> ImmutableSet.of(TEST_PACKAGE_NAME2)) + .build(); + + generator.split(); + + assertThatListDirectory(testsDir) + .containsExactly( + getModuleConfigFile(testsDir, TEST_PACKAGE_NAME1), + getModuleConfigFile(testsDir, TEST_PACKAGE_NAME2)); + } + + @Test + public void split_packageNameProviderThrowsException_throwsException() throws Exception { + Path testsDir = createTestsDir(); + ModuleGenerator generator = + createGeneratorBuilder() + .setTestsDir(testsDir) + .addPackageNameProvider( + () -> { + throw new IOException(); + }) + .build(); + + assertThrows(UncheckedIOException.class, () -> generator.split()); + } + + @Test + public void split_packageNamesNotProvided_doesNotGenerate() throws Exception { + Path testsDir = createTestsDir(); + ModuleGenerator generator = createGeneratorBuilder().setTestsDir(testsDir).build(); + + generator.split(); + + assertThatListDirectory(testsDir).isEmpty(); + } + + @Test + public void split_templateContainsPlaceholders_replacesPlaceholdersInOutput() throws Exception { + Path testsDir = createTestsDir(); + String content = "hello placeholder%s%s world"; + ModuleGenerator generator = + createGeneratorBuilder() + .setTestsDir(testsDir) + .addPackage(TEST_PACKAGE_NAME1) + .addPackage(TEST_PACKAGE_NAME2) + .setTemplateContent( + String.format(content, PACKAGE_PLACEHOLDER, PACKAGE_PLACEHOLDER)) + .build(); + + generator.split(); + + assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME1) + .isEqualTo(String.format(content, TEST_PACKAGE_NAME1, TEST_PACKAGE_NAME1)); + assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME2) + .isEqualTo(String.format(content, TEST_PACKAGE_NAME2, TEST_PACKAGE_NAME2)); + } + + @Test + public void split_templateDoesNotContainPlaceholder_outputsTemplateContent() throws Exception { + Path testsDir = createTestsDir(); + String content = "no placeholder"; + ModuleGenerator generator = + createGeneratorBuilder() + .setTestsDir(testsDir) + .addPackage(TEST_PACKAGE_NAME1) + .addPackage(TEST_PACKAGE_NAME2) + .setTemplateContent(content) + .build(); + + generator.split(); + + assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME1).isEqualTo(content); + assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME2).isEqualTo(content); + } + + @Test + public void split_templateContentIsEmpty_outputsTemplateContent() throws Exception { + Path testsDir = createTestsDir(); + String content = ""; + ModuleGenerator generator = + createGeneratorBuilder() + .setTestsDir(testsDir) + .addPackage(TEST_PACKAGE_NAME1) + .addPackage(TEST_PACKAGE_NAME2) + .setTemplateContent(content) + .build(); + + generator.split(); + + assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME1).isEqualTo(content); + assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME2).isEqualTo(content); + } + + private static StringSubject assertThatModuleConfigFileContent( + Path testsDir, String packageName) throws IOException { + return assertThat( + new String(Files.readAllBytes(getModuleConfigFile(testsDir, packageName)))); + } + + private static IterableSubject assertThatListDirectory(Path dir) throws IOException { + // Convert stream to list because com.google.common.truth.Truth8 is not available. + return assertThat( + Files.walk(dir) + .filter(p -> !p.equals(dir)) + .collect(ImmutableList.toImmutableList())); + } + + private static Path getModuleConfigFile(Path baseDir, String packageName) { + return baseDir.resolve(packageName + ".config"); + } + + private Path createTestsDir() throws IOException { + Path rootPath = mFileSystem.getPath("csuite"); + Files.createDirectories(rootPath); + return Files.createTempDirectory(rootPath, "testDir"); + } + + private static TestInformation createTestInfo() { + IInvocationContext context = new InvocationContext(); + context.addAllocatedDevice("device1", Mockito.mock(ITestDevice.class)); + context.addDeviceBuildInfo("device1", new BuildInfo()); + return TestInformation.newBuilder().setInvocationContext(context).build(); + } + + private GeneratorBuilder createGeneratorBuilder() throws IOException { + return new GeneratorBuilder() + .setFileSystem(mFileSystem) + .setTemplateContent(MODULE_TEMPLATE_CONTENT) + .setOption(ModuleGenerator.OPTION_TEMPLATE, "empty_path"); + } + + private static final class GeneratorBuilder { + private final ListMultimap<String, String> mOptions = ArrayListMultimap.create(); + private final Set<String> mPackages = new HashSet<>(); + private final List<PackageNameProvider> mPackageNameProviders = new ArrayList<>(); + private Path mTestsDir; + private String mTemplateContent; + private FileSystem mFileSystem; + + GeneratorBuilder addPackage(String packageName) { + mPackages.add(packageName); + return this; + } + + GeneratorBuilder addPackageNameProvider(PackageNameProvider packageNameProvider) { + mPackageNameProviders.add(packageNameProvider); + return this; + } + + GeneratorBuilder setFileSystem(FileSystem fileSystem) { + mFileSystem = fileSystem; + return this; + } + + GeneratorBuilder setTemplateContent(String templateContent) { + mTemplateContent = templateContent; + return this; + } + + GeneratorBuilder setTestsDir(Path testsDir) { + mTestsDir = testsDir; + return this; + } + + GeneratorBuilder setOption(String key, String value) { + mOptions.put(key, value); + return this; + } + + ModuleGenerator build() throws Exception { + ModuleGenerator generator = + new ModuleGenerator( + mFileSystem, buildInfo -> mTestsDir, resourcePath -> mTemplateContent); + + OptionSetter optionSetter = new OptionSetter(generator); + for (Map.Entry<String, String> entry : mOptions.entries()) { + optionSetter.setOptionValue(entry.getKey(), entry.getValue()); + } + + List<PackageNameProvider> packageNameProviders = new ArrayList<>(mPackageNameProviders); + packageNameProviders.add(() -> mPackages); + + IConfiguration configuration = new Configuration("name", "description"); + configuration.setConfigurationObjectList( + ModuleGenerator.PACKAGE_NAME_PROVIDER, packageNameProviders); + generator.setConfiguration(configuration); + + return generator; + } + } + + private static final String MODULE_TEMPLATE_CONTENT = + "<?xml version=\"1.0\" encoding=\"utf-8\"?>\n" + + "<configuration description=\"description\">\n" + + " <option name=\"package-name\" value=\"{package}\"/>\n" + + " <target_generator class=\"some.generator.class\">\n" + + " <option name=\"test-file-name\" value=\"app://{package}\"/>\n" + + " </target_generator>\n" + + " <test class=\"some.test.class\"/>\n" + + "</configuration>"; +} diff --git a/harness/src/test/java/com/android/csuite/core/CommandLinePackageNameProviderTest.java b/harness/src/test/java/com/android/csuite/core/CommandLinePackageNameProviderTest.java new file mode 100644 index 0000000..41e6dc1 --- /dev/null +++ b/harness/src/test/java/com/android/csuite/core/CommandLinePackageNameProviderTest.java @@ -0,0 +1,45 @@ +/* + * Copyright (C) 2021 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.core; + +import static com.google.common.truth.Truth.assertThat; + +import com.android.tradefed.config.OptionSetter; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.util.Set; + +@RunWith(JUnit4.class) +public final class CommandLinePackageNameProviderTest { + + @Test + public void get_packageNamesProvided_returnsPackageNames() throws Exception { + CommandLinePackageNameProvider provider = new CommandLinePackageNameProvider(); + String package1 = "package.name1"; + String package2 = "package.name2"; + OptionSetter optionSetter = new OptionSetter(provider); + optionSetter.setOptionValue(CommandLinePackageNameProvider.PACKAGE, package1); + optionSetter.setOptionValue(CommandLinePackageNameProvider.PACKAGE, package2); + + Set<String> packageNames = provider.get(); + + assertThat(packageNames).containsExactly(package1, package2); + } +} diff --git a/harness/src/test/java/com/android/csuite/core/FileBasedPackageNameProviderTest.java b/harness/src/test/java/com/android/csuite/core/FileBasedPackageNameProviderTest.java new file mode 100644 index 0000000..ee429b2 --- /dev/null +++ b/harness/src/test/java/com/android/csuite/core/FileBasedPackageNameProviderTest.java @@ -0,0 +1,116 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.core; + +import static com.google.common.truth.Truth.assertThat; + +import com.android.tradefed.config.ConfigurationException; +import com.android.tradefed.config.OptionSetter; + +import org.junit.Rule; +import org.junit.Test; +import org.junit.rules.TemporaryFolder; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.Set; + +@RunWith(JUnit4.class) +public final class FileBasedPackageNameProviderTest { + private static final String TEST_PACKAGE_NAME1 = "test.package.name1"; + private static final String TEST_PACKAGE_NAME2 = "test.package.name2"; + private static final String PACKAGE_PLACEHOLDER = "{package}"; + private static final Exception NO_EXCEPTION = null; + + @Rule public final TemporaryFolder tempFolder = new TemporaryFolder(); + + @Test + public void get_fileNotSpecified_returnsEmptySet() throws Exception { + FileBasedPackageNameProvider provider = createProvider(); + + Set<String> packageNames = provider.get(); + + assertThat(packageNames).isEmpty(); + } + + @Test + public void get_multipleFileSpecified_returnsAllEntries() throws Exception { + String packageName1 = "a"; + String packageName2 = "b"; + String packageName3 = "c"; + String packageName4 = "d"; + FileBasedPackageNameProvider provider = + createProvider( + createPackagesFile(packageName1 + "\n" + packageName2), + createPackagesFile(packageName3 + "\n" + packageName4)); + + Set<String> packageNames = provider.get(); + + assertThat(packageNames) + .containsExactly(packageName1, packageName2, packageName3, packageName4); + } + + @Test + public void get_fileContainsEmptyLines_ignoresEmptyLines() throws Exception { + String packageName1 = "a"; + String packageName2 = "b"; + FileBasedPackageNameProvider provider = + createProvider(createPackagesFile(packageName1 + "\n \n\n" + packageName2)); + + Set<String> packageNames = provider.get(); + + assertThat(packageNames).containsExactly(packageName1, packageName2); + } + + @Test + public void get_fileContainsCommentLines_ignoresCommentLines() throws Exception { + String packageName1 = "a"; + String packageName2 = "b"; + FileBasedPackageNameProvider provider = + createProvider( + createPackagesFile( + packageName1 + + "\n" + + FileBasedPackageNameProvider.COMMENT_LINE_PREFIX + + " Some comments\n" + + packageName2)); + + Set<String> packageNames = provider.get(); + + assertThat(packageNames).containsExactly(packageName1, packageName2); + } + + private FileBasedPackageNameProvider createProvider(Path... packagesFiles) + throws IOException, ConfigurationException { + FileBasedPackageNameProvider provider = new FileBasedPackageNameProvider(); + OptionSetter optionSetter = new OptionSetter(provider); + for (Path packagesFile : packagesFiles) { + optionSetter.setOptionValue( + FileBasedPackageNameProvider.PACKAGES_FILE, packagesFile.toString()); + } + return provider; + } + + private Path createPackagesFile(String content) throws IOException { + Path tempFile = Files.createTempFile(tempFolder.getRoot().toPath(), "packages", ".txt"); + Files.write(tempFile, content.getBytes()); + return tempFile; + } +} diff --git a/harness/src/test/java/com/android/csuite/core/SystemAppUninstallerTest.java b/harness/src/test/java/com/android/csuite/core/SystemAppUninstallerTest.java new file mode 100644 index 0000000..30b77db --- /dev/null +++ b/harness/src/test/java/com/android/csuite/core/SystemAppUninstallerTest.java @@ -0,0 +1,425 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.android.csuite.core; + +import static org.testng.Assert.assertThrows; + +import com.android.tradefed.device.DeviceNotAvailableException; +import com.android.tradefed.device.ITestDevice; +import com.android.tradefed.targetprep.TargetSetupError; +import com.android.tradefed.util.CommandResult; +import com.android.tradefed.util.CommandStatus; + + + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +@RunWith(JUnit4.class) +public final class SystemAppUninstallerTest { + private static final ITestDevice NULL_DEVICE = null; + private static final String TEST_PACKAGE_NAME = "test.package.name"; + private static final String SYSTEM_APP_INSTALL_DIRECTORY = "/system/app"; + private static final String CHECK_PACKAGE_INSTALLED_COMMAND_PREFIX = "pm list packages "; + private static final String GET_PACKAGE_INSTALL_PATH_COMMAND_PREFIX = "pm path "; + private static final String REMOVE_SYSTEM_APP_COMMAND_PREFIX = + "rm -r " + SYSTEM_APP_INSTALL_DIRECTORY; + private static final String REMOVE_APP_DATA_COMMAND_PREFIX = "rm -r /data/data"; + private static final String MOUNT_COMMAND_PREFIX = "mount"; + + @Test + public void uninstallPackage_packageNameIsNull_throws() throws Exception { + assertThrows( + NullPointerException.class, + () -> + SystemPackageUninstaller.uninstallPackage( + null, createGoodDeviceWithAppNotInstalled())); + } + + @Test + public void uninstallPackage_frameworkNotRunning_startsFrameworkOrThrows() throws Exception { + ITestDevice device = createGoodDeviceWithAppNotInstalled(); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.eq(SystemPackageUninstaller.PM_CHECK_COMMAND))) + .thenReturn(createFailedCommandResult()); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + Mockito.verify(device, Mockito.times(1)).executeShellV2Command(Mockito.eq("start")); + } + + @Test + public void uninstallPackage_packageIsNotInstalled_doesNotRemove() throws Exception { + ITestDevice device = createGoodDeviceWithAppNotInstalled(); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(0)).executeShellV2Command(Mockito.startsWith("rm")); + } + + @Test + public void uninstallPackage_differentPackageWithSameNamePrefixInstalled_doesNotRemove() + throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + // Mock the device as if the test package does not exist on device + CommandResult commandResult = createSuccessfulCommandResult(); + commandResult.setStdout(String.format("package:%s_some_more_chars", TEST_PACKAGE_NAME)); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith( + CHECK_PACKAGE_INSTALLED_COMMAND_PREFIX))) + .thenReturn(commandResult); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(0)).executeShellV2Command(Mockito.startsWith("rm")); + } + + @Test + public void uninstallPackage_checkPackageInstalledCommandFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith( + CHECK_PACKAGE_INSTALLED_COMMAND_PREFIX))) + .thenReturn(createFailedCommandResult()); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_getInstallDirectoryCommandFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith( + GET_PACKAGE_INSTALL_PATH_COMMAND_PREFIX))) + .thenReturn(createFailedCommandResult()); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_packageIsNotSystemApp_doesNotRemove() throws Exception { + ITestDevice device = createGoodDeviceWithUserAppInstalled(); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(0)).executeShellV2Command(Mockito.startsWith("rm")); + } + + @Test + public void uninstallPackage_adbAlreadyRooted_doesNotRootAgain() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when(device.isAdbRoot()).thenReturn(true); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(0)).enableAdbRoot(); + } + + @Test + public void uninstallPackage_adbNotAlreadyRooted_rootAdbAndThenUnroot() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when(device.isAdbRoot()).thenReturn(false); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(1)).enableAdbRoot(); + Mockito.verify(device, Mockito.times(1)).disableAdbRoot(); + } + + @Test + public void uninstallPackage_adbRootCommandFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when(device.enableAdbRoot()).thenThrow(new DeviceNotAvailableException()); + + assertThrows( + DeviceNotAvailableException.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_adbRootFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when(device.enableAdbRoot()).thenReturn(false); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_adbDisableRootCommandFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when(device.disableAdbRoot()).thenThrow(new DeviceNotAvailableException()); + + assertThrows( + DeviceNotAvailableException.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_adbDisableRootFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when(device.disableAdbRoot()).thenReturn(false); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_adbRemountFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.doThrow(new DeviceNotAvailableException()).when(device).remountSystemWritable(); + + assertThrows( + DeviceNotAvailableException.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_adbRemounted_mountReadOnlyAfterwards() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.doNothing().when(device).remountSystemWritable(); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(1)).remountSystemWritable(); + Mockito.verify(device, Mockito.times(1)) + .executeShellV2Command(Mockito.startsWith(MOUNT_COMMAND_PREFIX)); + } + + @Test + public void uninstallPackage_mountReadOnlyFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith(MOUNT_COMMAND_PREFIX))) + .thenReturn(createFailedCommandResult()); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_removePackageInstallDirectoryFailed_throws() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith(REMOVE_SYSTEM_APP_COMMAND_PREFIX))) + .thenReturn(createFailedCommandResult()); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + @Test + public void uninstallPackage_removePackageDataDirectoryFailed_doesNotThrow() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith(REMOVE_APP_DATA_COMMAND_PREFIX))) + .thenReturn(createFailedCommandResult()); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + } + + @Test + public void uninstallPackage_packageIsSystemApp_appRemoved() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(1)) + .executeShellV2Command(Mockito.startsWith(REMOVE_SYSTEM_APP_COMMAND_PREFIX)); + Mockito.verify(device, Mockito.times(1)) + .executeShellV2Command(Mockito.startsWith(REMOVE_APP_DATA_COMMAND_PREFIX)); + } + + @Test + public void uninstallPackage_noUpdatePackagePresent_appRemoved() throws Exception { + int numberOfUpdates = 0; + ITestDevice device = createGoodDeviceWithSystemAppInstalled(numberOfUpdates); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(numberOfUpdates + 1)) + .uninstallPackage(TEST_PACKAGE_NAME); + Mockito.verify(device, Mockito.times(1)) + .executeShellV2Command(Mockito.startsWith(REMOVE_SYSTEM_APP_COMMAND_PREFIX)); + } + + @Test + public void uninstallPackage_someUpdatePackagesPresent_appRemoved() throws Exception { + int numberOfUpdates = 2; + ITestDevice device = createGoodDeviceWithSystemAppInstalled(numberOfUpdates); + + SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device); + + Mockito.verify(device, Mockito.times(numberOfUpdates + 1)) + .uninstallPackage(TEST_PACKAGE_NAME); + Mockito.verify(device, Mockito.times(1)) + .executeShellV2Command(Mockito.startsWith(REMOVE_SYSTEM_APP_COMMAND_PREFIX)); + } + + @Test + public void uninstallPackage_tooManyUpdatePackagesPresent_throwsException() throws Exception { + ITestDevice device = + createGoodDeviceWithSystemAppInstalled( + SystemPackageUninstaller.MAX_NUMBER_OF_UPDATES + 1); + + assertThrows( + TargetSetupError.class, + () -> SystemPackageUninstaller.uninstallPackage(TEST_PACKAGE_NAME, device)); + } + + private ITestDevice createGoodDeviceWithUserAppInstalled() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + + CommandResult commandResult = createSuccessfulCommandResult(); + commandResult.setStdout( + String.format("package:/data/app/%s/%s.apk", TEST_PACKAGE_NAME, TEST_PACKAGE_NAME)); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith( + GET_PACKAGE_INSTALL_PATH_COMMAND_PREFIX))) + .thenReturn(commandResult); + + return device; + } + + private ITestDevice createGoodDeviceWithAppNotInstalled() throws Exception { + ITestDevice device = createGoodDeviceWithSystemAppInstalled(); + CommandResult commandResult = createSuccessfulCommandResult(); + commandResult.setStdout(""); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith( + CHECK_PACKAGE_INSTALLED_COMMAND_PREFIX))) + .thenReturn(commandResult); + return device; + } + + private ITestDevice createGoodDeviceWithSystemAppInstalled() throws Exception { + return createGoodDeviceWithSystemAppInstalled(1); + } + + private ITestDevice createGoodDeviceWithSystemAppInstalled(int numberOfUpdatesInstalled) + throws Exception { + ITestDevice device = Mockito.mock(ITestDevice.class); + CommandResult commandResult; + + // Is framework running + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.eq(SystemPackageUninstaller.PM_CHECK_COMMAND))) + .thenReturn(createSuccessfulCommandResult()); + + // Uninstall updates + String uninstallFailureMessage = "Failure [DELETE_FAILED_INTERNAL_ERROR]"; + if (numberOfUpdatesInstalled == 0) { + Mockito.when(device.uninstallPackage(TEST_PACKAGE_NAME)) + .thenReturn(uninstallFailureMessage); + } else { + String[] uninstallResults = new String[numberOfUpdatesInstalled]; + uninstallResults[numberOfUpdatesInstalled - 1] = uninstallFailureMessage; + Mockito.when(device.uninstallPackage(TEST_PACKAGE_NAME)) + .thenReturn(null, uninstallResults); + } + + // List package + commandResult = createSuccessfulCommandResult(); + commandResult.setStdout("package:" + TEST_PACKAGE_NAME); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith( + CHECK_PACKAGE_INSTALLED_COMMAND_PREFIX))) + .thenReturn(commandResult); + + // Get package path + commandResult = createSuccessfulCommandResult(); + commandResult.setStdout( + String.format( + "package:%s/%s/%s.apk", + SYSTEM_APP_INSTALL_DIRECTORY, TEST_PACKAGE_NAME, TEST_PACKAGE_NAME)); + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith( + GET_PACKAGE_INSTALL_PATH_COMMAND_PREFIX))) + .thenReturn(commandResult); + + // Adb root + Mockito.when(device.isAdbRoot()).thenReturn(false); + Mockito.when(device.enableAdbRoot()).thenReturn(true); + + // Adb remount + Mockito.doNothing().when(device).remountSystemWritable(); + + // Remove package install directory + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith(REMOVE_SYSTEM_APP_COMMAND_PREFIX))) + .thenReturn(createSuccessfulCommandResult()); + + // Remove package data directory + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith(REMOVE_APP_DATA_COMMAND_PREFIX))) + .thenReturn(createSuccessfulCommandResult()); + + // Restart framework + Mockito.when(device.executeShellV2Command(ArgumentMatchers.eq("start"))) + .thenReturn(createSuccessfulCommandResult()); + Mockito.when(device.executeShellV2Command(ArgumentMatchers.eq("stop"))) + .thenReturn(createSuccessfulCommandResult()); + + // Disable adb root + Mockito.when(device.disableAdbRoot()).thenReturn(true); + + // Remount read only + Mockito.when( + device.executeShellV2Command( + ArgumentMatchers.startsWith(MOUNT_COMMAND_PREFIX))) + .thenReturn(createSuccessfulCommandResult()); + + return device; + } + + private static CommandResult createSuccessfulCommandResult() { + CommandResult commandResult = new CommandResult(CommandStatus.SUCCESS); + commandResult.setExitCode(0); + return commandResult; + } + + private static CommandResult createFailedCommandResult() { + CommandResult commandResult = new CommandResult(CommandStatus.FAILED); + commandResult.setExitCode(1); + return commandResult; + } +} diff --git a/harness/src/test/java/com/android/csuite/testing/Correspondences.java b/harness/src/test/java/com/android/csuite/testing/Correspondences.java new file mode 100644 index 0000000..c388e23 --- /dev/null +++ b/harness/src/test/java/com/android/csuite/testing/Correspondences.java @@ -0,0 +1,47 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.testing; + +import com.google.common.truth.Correspondence; + +/** + * Useful implementations of {@link Correspondence}. + * + * <p>Correspondences are used with {@code IterableSubject.comparingElementsUsing(Correspondence)} + * and allow for making assertions on elements besides simple equality. See {@link Correspondence} + * for more information. + */ +// TODO(hzalek): Move this into a dedicated testing library. +public final class Correspondences { + + private static final Correspondence<Object, Class<?>> INSTANCE_OF = + Correspondence.from( + (Object obj, Class<?> clazz) -> { + return clazz.isInstance(obj); + }, + "is an instance of"); + + /** + * Returns a {@link Correspondence} that determines whether elements are instances of the class + * they are compared to. + */ + public static Correspondence<Object, Class<?>> instanceOf() { + return INSTANCE_OF; + } + + private Correspondences() {} +} diff --git a/harness/src/test/java/com/android/csuite/testing/CorrespondencesTest.java b/harness/src/test/java/com/android/csuite/testing/CorrespondencesTest.java new file mode 100644 index 0000000..5a4dbcf --- /dev/null +++ b/harness/src/test/java/com/android/csuite/testing/CorrespondencesTest.java @@ -0,0 +1,50 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.testing; + +import static com.android.csuite.testing.Correspondences.instanceOf; + +import static com.google.common.truth.Truth.assertThat; + +import com.google.common.collect.ImmutableList; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class CorrespondencesTest { + + @Test(expected = AssertionError.class) + public void instanceOf_comparedToDifferentClass_fails() { + assertThat(stringList()).comparingElementsUsing(instanceOf()).contains(Integer.class); + } + + @Test + public void instanceOf_comparedToSuperType_succeeds() { + assertThat(stringList()).comparingElementsUsing(instanceOf()).contains(Object.class); + } + + @Test + public void instanceOf_comparedToSameClass_succeeds() { + assertThat(stringList()).comparingElementsUsing(instanceOf()).contains(String.class); + } + + private static ImmutableList<String> stringList() { + return ImmutableList.of("A", "B", "C"); + } +} diff --git a/harness/src/test/java/com/android/csuite/testing/MoreAsserts.java b/harness/src/test/java/com/android/csuite/testing/MoreAsserts.java new file mode 100644 index 0000000..306dad4 --- /dev/null +++ b/harness/src/test/java/com/android/csuite/testing/MoreAsserts.java @@ -0,0 +1,59 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.testing; + +import static com.google.common.truth.Truth.assertThat; + +/** An additional set of assertion methods useful for writing tests. */ +// TODO(hzalek): Move this into a dedicated testing library. +public final class MoreAsserts { + + /** + * Facilitates the use of assertThrows from Java 8 by allowing method references to void methods + * that declare checked exceptions and is not meant to be directly implemented. + */ + public interface ThrowingRunnable { + void run() throws Exception; + } + + /** + * Asserts that {@code runnable} throws an exception of type {@code expectedThrowable} when + * executed. If it does, the exception object is returned. Otherwise, if it does not throw an + * exception or does but of the unexpected type, an {@link AssertionError} is thrown. + * + * <p>This is mostly intended as a drop-in replacement of assertThrows that is only available in + * JUnit 4.13 and later. + * + * @param message the identifying message for the {@link AssertionError} + * @param expectedThrowable the expected type of the exception + * @param runnable a function that is expected to throw an exception when executed + * @return the exception thrown by {@code runnable} + */ + public static <T extends Throwable> T assertThrows( + Class<T> expectedClass, ThrowingRunnable runnable) { + try { + runnable.run(); + } catch (Throwable e) { + assertThat(e).isInstanceOf(expectedClass); + return expectedClass.cast(e); + } + throw new AssertionError( + "Did not throw any when expected instance of: " + expectedClass.getName()); + } + + private MoreAsserts() {} +} diff --git a/harness/src/test/java/com/android/csuite/testing/MoreAssertsTest.java b/harness/src/test/java/com/android/csuite/testing/MoreAssertsTest.java new file mode 100644 index 0000000..e0a968f --- /dev/null +++ b/harness/src/test/java/com/android/csuite/testing/MoreAssertsTest.java @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package com.android.csuite.testing; + +import static com.android.csuite.testing.MoreAsserts.assertThrows; + +import static com.google.common.truth.Truth.assertThat; + +import org.junit.Test; +import org.junit.runner.RunWith; +import org.junit.runners.JUnit4; + +@RunWith(JUnit4.class) +public final class MoreAssertsTest { + + @Test(expected = AssertionError.class) + public void assertThrows_noExceptionThrown_fails() { + assertThrows( + Throwable.class, + () -> { + /* This comment works around a presubmit hook warning that complains about a + * missing whitespace after the '{' which if added results in another warning + * about the file being badly formatted. */ + }); + } + + @Test(expected = AssertionError.class) + public void assertThrows_differentExceptionTypeThrown_fails() { + assertThrows( + IllegalArgumentException.class, + () -> { + throw new IllegalStateException(); + }); + } + + @Test(expected = AssertionError.class) + public void assertThrows_superTypeOfExpectedExceptionTypeThrown_fails() { + assertThrows( + IllegalArgumentException.class, + () -> { + throw new RuntimeException(); + }); + } + + @Test + public void assertThrows_expectedExceptionTypeThrown_returnsSameObject() { + IllegalArgumentException expected = new IllegalArgumentException(); + + IllegalArgumentException actual = + assertThrows( + IllegalArgumentException.class, + () -> { + throw expected; + }); + + assertThat(actual).isSameInstanceAs(expected); + } + + @Test + public void assertThrows_subTypeOfExpectedExceptionTypeThrown_returnsSameObject() { + IllegalArgumentException expected = new IllegalArgumentException(); + + RuntimeException actual = + assertThrows( + RuntimeException.class, + () -> { + throw expected; + }); + + assertThat(actual).isSameInstanceAs(expected); + } +} diff --git a/instrumentation/launch/Android.bp b/instrumentation/launch/Android.bp index 22255f6..1b45c1a 100644 --- a/instrumentation/launch/Android.bp +++ b/instrumentation/launch/Android.bp @@ -12,11 +12,18 @@ // See the License for the specific language governing permissions and // limitations under the License. -android_test { +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test_helper_app { name: "csuite-launch-instrumentation", static_libs: ["androidx.test.rules"], // Include all test java files. srcs: ["src/**/*.java"], + // The value of min sdk version chosen here is the oldest sdk version we + // have tested. Lower version numbers should also work. + min_sdk_version: "29", platform_apis: true, manifest: "src/main/AndroidManifest.xml", test_suites: [ diff --git a/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibility.java b/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibility.java index 5918a62..870076c 100644 --- a/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibility.java +++ b/instrumentation/launch/src/main/java/com/android/compatibilitytest/AppCompatibility.java @@ -34,10 +34,13 @@ import android.os.DropBoxManager; import android.os.RemoteException; import android.os.ServiceManager; import android.util.Log; +import android.view.KeyEvent; import androidx.test.InstrumentationRegistry; import androidx.test.runner.AndroidJUnit4; +import com.android.internal.util.Preconditions; + import org.junit.After; import org.junit.Assert; import org.junit.Before; @@ -51,6 +54,7 @@ import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.IntStream; /** Application Compatibility Test that launches an application and detects crashes. */ @RunWith(AndroidJUnit4.class) @@ -59,15 +63,14 @@ public final class AppCompatibility { private static final String TAG = AppCompatibility.class.getSimpleName(); private static final String PACKAGE_TO_LAUNCH = "package_to_launch"; private static final String APP_LAUNCH_TIMEOUT_MSECS = "app_launch_timeout_ms"; - private static final String WORKSPACE_LAUNCH_TIMEOUT_MSECS = "workspace_launch_timeout_ms"; + private static final String ARG_DISMISS_DIALOG = "ARG_DISMISS_DIALOG"; private static final Set<String> DROPBOX_TAGS = new HashSet<>(); private static final int MAX_CRASH_SNIPPET_LINES = 20; private static final int MAX_NUM_CRASH_SNIPPET = 3; + private static final int DELAY_AFTER_KEYEVENT_MILLIS = 500; // time waiting for app to launch private int mAppLaunchTimeout = 7000; - // time waiting for launcher home screen to show up - private int mWorkspaceLaunchTimeout = 2000; private Context mContext; private ActivityManager mActivityManager; @@ -113,10 +116,6 @@ public final class AppCompatibility { if (appLaunchTimeoutMsecs != null) { mAppLaunchTimeout = Integer.parseInt(appLaunchTimeoutMsecs); } - String workspaceLaunchTimeoutMsecs = mArgs.getString(WORKSPACE_LAUNCH_TIMEOUT_MSECS); - if (workspaceLaunchTimeoutMsecs != null) { - mWorkspaceLaunchTimeout = Integer.parseInt(workspaceLaunchTimeoutMsecs); - } mInstrumentation.getUiAutomation().setRotation(UiAutomation.ROTATION_FREEZE_0); // set activity controller to suppress crash dialogs and collects them by process name @@ -141,54 +140,59 @@ public final class AppCompatibility { @Test public void testAppStability() throws Exception { String packageName = mArgs.getString(PACKAGE_TO_LAUNCH); - if (packageName != null) { - Log.d(TAG, "Launching app " + packageName); - Intent intent = getLaunchIntentForPackage(packageName); - if (intent == null) { - Log.w(TAG, String.format("Skipping %s; no launch intent", packageName)); - return; + Preconditions.checkStringNotEmpty( + packageName, + String.format( + "Missing argument, use %s to specify the package to launch", + PACKAGE_TO_LAUNCH)); + + Log.d(TAG, "Launching app " + packageName); + Intent intent = getLaunchIntentForPackage(packageName); + if (intent == null) { + Log.w(TAG, String.format("Skipping %s; no launch intent", packageName)); + return; + } + long startTime = System.currentTimeMillis(); + launchActivity(packageName, intent); + + if (mArgs.getString(ARG_DISMISS_DIALOG, "false").equals("true")) { + // Attempt to dismiss any dialogs which some apps display to 'gracefully' handle + // errors. The dialog prevents the app from crashing thereby hiding issues. The + // first key event is to select a default button on the error dialog if any while + // the second event pushes the button. + IntStream.range(0, 2) + .forEach(i -> mInstrumentation.sendKeyDownUpSync(KeyEvent.KEYCODE_ENTER)); + // Give the app process enough time to terminate after dismissing the error. + Thread.sleep(DELAY_AFTER_KEYEVENT_MILLIS); + } + + checkDropbox(startTime, packageName); + if (mAppErrors.containsKey(packageName)) { + StringBuilder message = + new StringBuilder("Error(s) detected for package: ").append(packageName); + List<String> errors = mAppErrors.get(packageName); + for (int i = 0; i < MAX_NUM_CRASH_SNIPPET && i < errors.size(); i++) { + String err = errors.get(i); + message.append("\n\n"); + // limit the size of each crash snippet + message.append(truncate(err, MAX_CRASH_SNIPPET_LINES)); } - long startTime = System.currentTimeMillis(); - launchActivity(packageName, intent); - try { - checkDropbox(startTime, packageName); - if (mAppErrors.containsKey(packageName)) { - StringBuilder message = - new StringBuilder("Error(s) detected for package: ") - .append(packageName); - List<String> errors = mAppErrors.get(packageName); - for (int i = 0; i < MAX_NUM_CRASH_SNIPPET && i < errors.size(); i++) { - String err = errors.get(i); - message.append("\n\n"); - // limit the size of each crash snippet - message.append(truncate(err, MAX_CRASH_SNIPPET_LINES)); - } - if (errors.size() > MAX_NUM_CRASH_SNIPPET) { - message.append( - String.format( - "\n... %d more errors omitted ...", - errors.size() - MAX_NUM_CRASH_SNIPPET)); - } - Assert.fail(message.toString()); - } - // last check: see if app process is still running - Assert.assertTrue( - "app package \"" - + packageName - + "\" no longer found in running " - + "tasks, but no explicit crashes were detected; check logcat for " - + "details", - processStillUp(packageName)); - } finally { - returnHome(); + if (errors.size() > MAX_NUM_CRASH_SNIPPET) { + message.append( + String.format( + "\n... %d more errors omitted ...", + errors.size() - MAX_NUM_CRASH_SNIPPET)); } - } else { - Log.d( - TAG, - "Missing argument, use " - + PACKAGE_TO_LAUNCH - + " to specify the package to launch"); + Assert.fail(message.toString()); } + // last check: see if app process is still running + Assert.assertTrue( + "app package \"" + + packageName + + "\" no longer found in running " + + "tasks, but no explicit crashes were detected; check logcat for " + + "details", + processStillUp(packageName)); } /** @@ -243,19 +247,6 @@ public final class AppCompatibility { } } - private void returnHome() { - Intent homeIntent = new Intent(Intent.ACTION_MAIN); - homeIntent.addCategory(Intent.CATEGORY_HOME); - homeIntent.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - // Send the "home" intent and wait 2 seconds for us to get there - mContext.startActivity(homeIntent); - try { - Thread.sleep(mWorkspaceLaunchTimeout); - } catch (InterruptedException e) { - // ignore - } - } - private Intent getLaunchIntentForPackage(String packageName) { UiModeManager umm = (UiModeManager) mContext.getSystemService(Context.UI_MODE_SERVICE); boolean isLeanback = umm.getCurrentModeType() == Configuration.UI_MODE_TYPE_TELEVISION; diff --git a/integration_tests/Android.bp b/integration_tests/Android.bp new file mode 100644 index 0000000..84eaa2e --- /dev/null +++ b/integration_tests/Android.bp @@ -0,0 +1,116 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The below module creates a standalone zip that end-to-end tests can depend +// on for running the suite. This is a workaround since we can't use csuite.zip +// which is defined in an external Makefile that Soong can't depend on. +// +// Besides listing jars we know the launcher script depends on which is +// brittle, this is a hack for several reasons. First, we're listing our +// dependencies in the tools attribute when we should be using the 'srcs' +// attribute. Second, we're accessing jars using a path relative to a known +// artifact location instead of using the Soong 'location' feature. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +java_genrule_host { + name: "csuite_standalone_zip", + cmd: "ANDROID_CSUITE=$(genDir)/android-csuite && " + + "CSUITE_TOOLS=$${ANDROID_CSUITE}/tools && " + + "CSUITE_TESTCASES=$${ANDROID_CSUITE}/testcases && " + + "ANDROID_HOST_OUT=$$(dirname $(location :csuite-tradefed))/.. && " + + "rm -rf $${CSUITE_TOOLS} && mkdir -p $${CSUITE_TOOLS} && " + + "rm -rf $${CSUITE_TESTCASES} && mkdir -p $${CSUITE_TESTCASES} && " + + "cp $(location :csuite-tradefed) $${CSUITE_TOOLS} && " + + "cp $${ANDROID_HOST_OUT}/framework/csuite-tradefed.jar $${CSUITE_TOOLS} && " + + "cp $(location :tradefed) $${CSUITE_TOOLS} && " + + "cp $(location :compatibility-host-util) $${CSUITE_TOOLS} && " + + // We skip copying the csuite-tradefed-tests jar since its location is + // not straight-forward to deduce and not really necessary. + "touch $${CSUITE_TOOLS}/csuite-tradefed-tests.jar && " + + "cp $(location :csuite_generate_module) $${CSUITE_TOOLS} && " + + "cp $(location :csuite-launch-instrumentation) $${CSUITE_TESTCASES} && " + + "chmod a+x $${CSUITE_TOOLS}/csuite-tradefed && " + + "$(location soong_zip) -o $(out) -d -C $(genDir) -D $${ANDROID_CSUITE}", + out: ["csuite-standalone.zip"], + srcs: [ + ":csuite-launch-instrumentation", + ":tradefed", + ":compatibility-host-util", + ], + tools: [ + "soong_zip", + ":csuite-tradefed", + ":csuite_generate_module", + ], +} + +python_library_host { + name: "csuite_test_utils", + srcs: [ + "csuite_test_utils.py", + ], + defaults: [ + "csuite_python_defaults", + ], + java_data: [ + "csuite_standalone_zip", + ], + libs: [ + "csuite_test", + ], +} + +python_test_host { + name: "csuite_cli_test", + srcs: [ + "csuite_cli_test.py", + ], + test_config_template: "csuite_test_template.xml", + test_suites: [ + "general-tests", + ], + libs: [ + "csuite_test_utils", + ], + defaults: [ + "csuite_python_defaults", + ], +} + +python_test_host { + name: "csuite_crash_detection_test", + srcs: [ + "csuite_crash_detection_test.py", + ], + test_config_template: "csuite_test_template.xml", + test_suites: [ + "general-tests", + ], + libs: [ + "csuite_test_utils", + ], + data: [ + ":csuite_crash_on_launch_test_app", + ":csuite_no_crash_test_app", + ], + defaults: [ + "csuite_python_defaults", + ], + test_options: { + unit_test: false, + }, +} diff --git a/integration_tests/TEST_MAPPING b/integration_tests/TEST_MAPPING new file mode 100644 index 0000000..9d98a72 --- /dev/null +++ b/integration_tests/TEST_MAPPING @@ -0,0 +1,10 @@ +{ + "postsubmit": [ + { + "name": "csuite_cli_test" + }, + { + "name": "csuite_crash_detection_test" + } + ] +} diff --git a/integration_tests/csuite_cli_test.py b/integration_tests/csuite_cli_test.py new file mode 100644 index 0000000..84925e2 --- /dev/null +++ b/integration_tests/csuite_cli_test.py @@ -0,0 +1,33 @@ +# Lint as: python3 +# +# Copyright 2020, The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests the C-Suite command line interface.""" + +import csuite_test_utils + + +class CSuiteCliTest(csuite_test_utils.TestCase): + + def test_prints_suite_name(self): + with csuite_test_utils.CSuiteHarness() as harness: + completed_process = harness.run_and_wait( + ['run', 'commandAndExit', 'version']) + + self.assertEqual(0, completed_process.returncode) + self.assertIn('App Compatibility Test Suite', completed_process.stdout) + + +if __name__ == '__main__': + csuite_test_utils.main() diff --git a/integration_tests/csuite_crash_detection_test.py b/integration_tests/csuite_crash_detection_test.py new file mode 100644 index 0000000..9dd8a00 --- /dev/null +++ b/integration_tests/csuite_crash_detection_test.py @@ -0,0 +1,104 @@ +# Lint as: python3 +# +# Copyright 2020, The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests C-Suite's crash detection behavior.""" + +import csuite_test_utils + + +class CrashDetectionTest(csuite_test_utils.TestCase): + + def setUp(self): + super(CrashDetectionTest, self).setUp() + self.adb = csuite_test_utils.Adb() + self.repo = csuite_test_utils.PackageRepository() + self.harness = csuite_test_utils.CSuiteHarness() + + def tearDown(self): + super(CrashDetectionTest, self).tearDown() + self.harness.cleanup() + self.repo.cleanup() + + def test_no_crash_test_passes(self): + test_app_package = 'android.csuite.nocrashtestapp' + self.adb.run(['logcat', '-c']) + + completed_process = self.run_test( + test_app_package=test_app_package, + test_app_module='csuite_no_crash_test_app') + + self.expect_regex(completed_process.stdout, r"""PASSED\s*:\s*1""") + self.expect_app_launched(test_app_package) + self.expect_package_not_installed(test_app_package) + + def test_crash_on_launch_test_fails(self): + test_app_package = 'android.csuite.crashonlaunchtestapp' + self.adb.run(['logcat', '-c']) + + completed_process = self.run_test( + test_app_package=test_app_package, + test_app_module='csuite_crash_on_launch_test_app') + + self.expect_regex(completed_process.stdout, r"""FAILED\s*:\s*1""") + self.expect_app_launched(test_app_package) + self.expect_package_not_installed(test_app_package) + + def run_test(self, test_app_package, test_app_module): + """Set up and run the launcher for a given test app.""" + + # We don't check the return code since adb returns non-zero exit code if + # the package does not exist. + self.adb.uninstall(test_app_package, check=False) + self.assert_package_not_installed(test_app_package) + + module_name = self.harness.add_module(test_app_package) + self.repo.add_package_apks( + test_app_package, csuite_test_utils.get_test_app_apks(test_app_module)) + + file_resolver_class = 'com.android.csuite.config.AppRemoteFileResolver' + + return self.harness.run_and_wait([ + '--serial', + csuite_test_utils.get_device_serial(), + 'run', + 'commandAndExit', + 'launch', + '-m', + module_name, + '--enable-module-dynamic-download', + '--dynamic-download-args', + '%s:uri-template=file://%s/{package}' % + (file_resolver_class, self.repo.get_path()) + ]) + + def expect_regex(self, s, regex): + with self.subTest(): + self.assertRegex(s, regex) + + def assert_package_not_installed(self, package_name): + self.assertNotIn(package_name, self.adb.list_packages()) + + def expect_package_not_installed(self, package_name): + with self.subTest(): + self.assert_package_not_installed(package_name) + + def expect_app_launched(self, tag): + logcat_process = self.adb.run(['logcat', '-d', '-v', 'brief', '-s', tag]) + with self.subTest(): + self.assertIn('App launched', logcat_process.stdout) + + +if __name__ == '__main__': + csuite_test_utils.main() diff --git a/integration_tests/csuite_crash_on_launch_test_app/Android.bp b/integration_tests/csuite_crash_on_launch_test_app/Android.bp new file mode 100644 index 0000000..ec0022b --- /dev/null +++ b/integration_tests/csuite_crash_on_launch_test_app/Android.bp @@ -0,0 +1,27 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test_helper_app { + name: "csuite_crash_on_launch_test_app", + srcs: [ + "*.java", + ], + sdk_version: "test_current", + min_sdk_version: "29", + target_sdk_version: "29", +} diff --git a/integration_tests/csuite_crash_on_launch_test_app/AndroidManifest.xml b/integration_tests/csuite_crash_on_launch_test_app/AndroidManifest.xml new file mode 100755 index 0000000..496aefb --- /dev/null +++ b/integration_tests/csuite_crash_on_launch_test_app/AndroidManifest.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package='android.csuite.crashonlaunchtestapp' > + <application> + <activity android:name="android.csuite.TestAppActivity" > + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/integration_tests/csuite_crash_on_launch_test_app/TestAppActivity.java b/integration_tests/csuite_crash_on_launch_test_app/TestAppActivity.java new file mode 100644 index 0000000..2838a18 --- /dev/null +++ b/integration_tests/csuite_crash_on_launch_test_app/TestAppActivity.java @@ -0,0 +1,30 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.csuite; + +import android.app.Activity; +import android.os.Bundle; +import android.util.Log; + +public final class TestAppActivity extends Activity { + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + Log.i(getApplicationContext().getPackageName(), "App launched"); + throw new RuntimeException("Expected exception"); + } +} diff --git a/integration_tests/csuite_no_crash_test_app/Android.bp b/integration_tests/csuite_no_crash_test_app/Android.bp new file mode 100644 index 0000000..e9ee60a --- /dev/null +++ b/integration_tests/csuite_no_crash_test_app/Android.bp @@ -0,0 +1,27 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +android_test_helper_app { + name: "csuite_no_crash_test_app", + srcs: [ + "*.java", + ], + sdk_version: "test_current", + min_sdk_version: "29", + target_sdk_version: "29", +} diff --git a/integration_tests/csuite_no_crash_test_app/AndroidManifest.xml b/integration_tests/csuite_no_crash_test_app/AndroidManifest.xml new file mode 100755 index 0000000..bd76721 --- /dev/null +++ b/integration_tests/csuite_no_crash_test_app/AndroidManifest.xml @@ -0,0 +1,27 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + --> +<manifest xmlns:android="http://schemas.android.com/apk/res/android" + package='android.csuite.nocrashtestapp' > + <application> + <activity android:name="android.csuite.TestAppActivity" > + <intent-filter> + <action android:name="android.intent.action.MAIN" /> + <category android:name="android.intent.category.LAUNCHER" /> + </intent-filter> + </activity> + </application> +</manifest> diff --git a/integration_tests/csuite_no_crash_test_app/TestAppActivity.java b/integration_tests/csuite_no_crash_test_app/TestAppActivity.java new file mode 100644 index 0000000..e61f484 --- /dev/null +++ b/integration_tests/csuite_no_crash_test_app/TestAppActivity.java @@ -0,0 +1,29 @@ +/* + * Copyright (C) 2020 The Android Open Source Project + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package android.csuite; + +import android.app.Activity; +import android.os.Bundle; +import android.util.Log; + +public final class TestAppActivity extends Activity { + @Override + public void onCreate(Bundle bundle) { + super.onCreate(bundle); + Log.i(getApplicationContext().getPackageName(), "App launched"); + } +} diff --git a/harness/src/main/resources/config/launch.xml b/integration_tests/csuite_test_template.xml index a1a158d..837716a 100644 --- a/harness/src/main/resources/config/launch.xml +++ b/integration_tests/csuite_test_template.xml @@ -1,5 +1,5 @@ <?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2019 The Android Open Source Project +<!-- Copyright (C) 2020 The Android Open Source Project Licensed under the Apache License, Version 2.0 (the "License"); you may not use this file except in compliance with the License. @@ -13,10 +13,11 @@ See the License for the specific language governing permissions and limitations under the License. --> -<configuration description="C-Suite Compatibility Launch Test Plan"> - <include name="csuite-base" /> - - <option name="plan" value="launch" /> - - <option name="compatibility:module-metadata-include-filter" key="plan" value="app-launch" /> +<configuration> + <test class="com.android.tradefed.testtype.python.PythonBinaryHostTest"> + <option name="par-file-name" value="{MODULE}"/> + <option name="inject-serial-option" value="true"/> + <option name="use-test-output-file" value="true"/> + <option name="test-timeout" value="10m"/> + </test> </configuration> diff --git a/integration_tests/csuite_test_utils.py b/integration_tests/csuite_test_utils.py new file mode 100644 index 0000000..e72535f --- /dev/null +++ b/integration_tests/csuite_test_utils.py @@ -0,0 +1,283 @@ +# Lint as: python3 +# +# Copyright 2020, The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Utilities for C-Suite integration tests.""" + +import argparse +import contextlib +import logging +import os +import pathlib +import shlex +import shutil +import stat +import subprocess +import sys +import tempfile +from typing import Sequence, Text +import zipfile +import csuite_test + +# Export symbols to reduce the number of imports tests have to list. +TestCase = csuite_test.TestCase # pylint: disable=invalid-name +get_device_serial = csuite_test.get_device_serial + +# Keep any created temporary directories for debugging test failures. The +# directories do not need explicit removal since they are created using the +# system's temporary-file facility. +_KEEP_TEMP_DIRS = False + + +class CSuiteHarness(contextlib.AbstractContextManager): + """Interface class for interacting with the C-Suite harness. + + WARNING: Explicitly clean up created instances or use as a context manager. + Not doing so will result in a ResourceWarning for the implicit cleanup which + confuses the TradeFed Python test output parser. + """ + + def __init__(self): + self._suite_dir = pathlib.Path(tempfile.mkdtemp(prefix='csuite')) + logging.debug('Created harness directory: %s', self._suite_dir) + + with zipfile.ZipFile(_get_standalone_zip_path(), 'r') as f: + f.extractall(self._suite_dir) + + # Add owner-execute permission on scripts since zip does not preserve them. + self._launcher_binary = self._suite_dir.joinpath( + 'android-csuite/tools/csuite-tradefed') + _add_owner_exec_permission(self._launcher_binary) + + self._generate_module_binary = self._suite_dir.joinpath( + 'android-csuite/tools/csuite_generate_module') + _add_owner_exec_permission(self._generate_module_binary) + + self._testcases_dir = self._suite_dir.joinpath('android-csuite/testcases') + + def __exit__(self, unused_type, unused_value, unused_traceback): + self.cleanup() + + def cleanup(self): + if _KEEP_TEMP_DIRS: + return + shutil.rmtree(self._suite_dir, ignore_errors=True) + + def add_module(self, package_name: Text) -> Text: + """Generates and adds a test module for the provided package.""" + module_name = 'csuite_%s' % package_name + + with tempfile.TemporaryDirectory() as o: + out_dir = pathlib.Path(o) + package_list_path = out_dir.joinpath('packages.list') + + package_list_path.write_text(package_name + '\n') + + flags = ['--package-list', package_list_path, '--root-dir', out_dir] + + _run_command([self._generate_module_binary] + flags) + + out_file_path = self._testcases_dir.joinpath(module_name + '.config') + shutil.copy( + out_dir.joinpath(package_name, 'AndroidTest.xml'), out_file_path) + + return module_name + + def run_and_wait(self, flags: Sequence[Text]) -> subprocess.CompletedProcess: + """Starts the Tradefed launcher and waits for it to complete.""" + + env = os.environ.copy() + + # Unset environment variables that would cause the script to think it's in a + # build tree. + env.pop('ANDROID_BUILD_TOP', None) + env.pop('ANDROID_HOST_OUT', None) + + # Unset environment variables that would cause TradeFed to find test configs + # other than the ones created by the test. + env.pop('ANDROID_HOST_OUT_TESTCASES', None) + env.pop('ANDROID_TARGET_OUT_TESTCASES', None) + + # Unset environment variables that might cause the suite to pick up a + # connected device that wasn't explicitly specified. + env.pop('ANDROID_SERIAL', None) + + # Set the environment variable that TradeFed requires to find test modules. + env['ANDROID_TARGET_OUT_TESTCASES'] = self._testcases_dir + + return _run_command([self._launcher_binary] + flags, env=env) + + +class PackageRepository(contextlib.AbstractContextManager): + """A file-system based APK repository for use in tests. + + WARNING: Explicitly clean up created instances or use as a context manager. + Not doing so will result in a ResourceWarning for the implicit cleanup which + confuses the TradeFed Python test output parser. + """ + + def __init__(self): + self._root_dir = pathlib.Path(tempfile.mkdtemp(prefix='csuite_apk_dir')) + logging.info('Created repository directory: %s', self._root_dir) + + def __exit__(self, unused_type, unused_value, unused_traceback): + self.cleanup() + + def cleanup(self): + if _KEEP_TEMP_DIRS: + return + shutil.rmtree(self._root_dir, ignore_errors=True) + + def get_path(self) -> pathlib.Path: + """Returns the path to the repository's root directory.""" + return self._root_dir + + def add_package_apks(self, package_name: Text, + apk_paths: Sequence[pathlib.Path]): + """Adds the provided package APKs to the repository.""" + apk_dir = self._root_dir.joinpath(package_name) + + # Raises if the directory already exists. + apk_dir.mkdir() + for f in apk_paths: + shutil.copy(f, apk_dir) + + +class Adb: + """Encapsulates adb functionality to simplify usage in tests. + + Most methods in this class raise an exception if they fail to execute. This + behavior can be overridden by using the check parameter. + """ + + def __init__(self, + adb_binary_path: pathlib.Path = None, + device_serial: Text = None): + self._args = [adb_binary_path or 'adb'] + + device_serial = device_serial or get_device_serial() + if device_serial: + self._args.extend(['-s', device_serial]) + + def shell(self, + args: Sequence[Text], + check: bool = None) -> subprocess.CompletedProcess: + """Runs an adb shell command and waits for it to complete. + + Note that the exit code of the returned object corresponds to that of + the adb command and not the command executed in the shell. + + Args: + args: a sequence of program arguments to pass to the shell. + check: whether to raise if the process terminates with a non-zero exit + code. + + Returns: + An object representing a process that has finished and that can be + queried. + """ + return self.run(['shell'] + args, check) + + def run(self, + args: Sequence[Text], + check: bool = None) -> subprocess.CompletedProcess: + """Runs an adb command and waits for it to complete.""" + return _run_command(self._args + args, check=check) + + def uninstall(self, package_name: Text, check: bool = None): + """Uninstalls the specified package.""" + self.run(['uninstall', package_name], check=check) + + def list_packages(self) -> Sequence[Text]: + """Lists packages installed on the device.""" + p = self.shell(['pm', 'list', 'packages']) + return [l.split(':')[1] for l in p.stdout.splitlines()] + + +def _run_command(args, check=True, **kwargs) -> subprocess.CompletedProcess: + """A wrapper for subprocess.run that overrides defaults and adds logging.""" + env = kwargs.get('env', {}) + + # Log the command-line for debugging failed tests. Note that we convert + # tokens to strings for _shlex_join. + env_str = ['env', '-i'] + ['%s=%s' % (k, v) for k, v in env.items()] + args_str = [str(t) for t in args] + + # Override some defaults. Note that 'check' deviates from this pattern to + # avoid getting warnings about using subprocess.run without an explicitly set + # `check` parameter. + kwargs.setdefault('capture_output', True) + kwargs.setdefault('universal_newlines', True) + + logging.debug('Running command: %s', _shlex_join(env_str + args_str)) + + return subprocess.run(args, check=check, **kwargs) + + +def _add_owner_exec_permission(path: pathlib.Path): + path.chmod(path.stat().st_mode | stat.S_IEXEC) + + +def get_test_app_apks(app_module_name: Text) -> Sequence[pathlib.Path]: + """Returns a test app's apk file paths.""" + return [_get_test_file(app_module_name + '.apk')] + + +def _get_standalone_zip_path(): + """Returns the suite standalone zip file's path.""" + return _get_test_file('csuite-standalone.zip') + + +def _get_test_file(name: Text) -> pathlib.Path: + test_dir = _get_test_dir() + test_file = test_dir.joinpath(name) + + if not test_file.exists(): + raise RuntimeError('Unable to find the file `%s` in the test execution dir ' + '`%s`; are you missing a data dependency in the build ' + 'module?' % (name, test_dir)) + + return test_file + + +def _shlex_join(split_command: Sequence[Text]) -> Text: + """Concatenate tokens and return a shell-escaped string.""" + # This is an alternative to shlex.join that doesn't exist in Python versions + # < 3.8. + return ' '.join(shlex.quote(t) for t in split_command) + + +def _get_test_dir() -> pathlib.Path: + return pathlib.Path(__file__).parent + + +def main(): + global _KEEP_TEMP_DIRS + + parser = argparse.ArgumentParser(parents=[csuite_test.create_arg_parser()]) + parser.add_argument( + '--log-level', + choices=['DEBUG', 'INFO', 'WARNING', 'ERROR', 'CRITICAL'], + default='WARNING', + help='sets the logging level threshold') + parser.add_argument( + '--keep-temp-dirs', + type=bool, + help='keeps any created temporary directories for debugging failures') + args, unittest_argv = parser.parse_known_args(sys.argv) + + _KEEP_TEMP_DIRS = args.keep_temp_dirs + logging.basicConfig(level=getattr(logging, args.log_level)) + + csuite_test.run_tests(args, unittest_argv) diff --git a/pylib/Android.bp b/pylib/Android.bp new file mode 100644 index 0000000..319060f --- /dev/null +++ b/pylib/Android.bp @@ -0,0 +1,42 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +// The below module creates a standalone zip that end-to-end tests can depend +// on for running the suite. This is a workaround since we can't use csuite.zip +// which is defined in an external Makefile that Soong can't depend on. +// +// Besides listing jars we know the launcher script depends on which is +// brittle, this is a hack for several reasons. First, we're listing our +// dependencies in the tools attribute when we should be using the 'srcs' +// attribute. Second, we're accessing jars using a path relative to a known +// artifact location instead of using the Soong 'location' feature. +// +// Normally we would just use java_genrule_host to avoid these hacks but can't +// do that since Soong currently complains when a python_host_test depends on +// that target since, although compatible, the arch variants (x86_64 and +// common) don't exactly match. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +python_library_host { + name: "csuite_test", + srcs: [ + "csuite_test.py", + ], + defaults: [ + "csuite_python_defaults", + ], +} diff --git a/pylib/csuite_test.py b/pylib/csuite_test.py new file mode 100644 index 0000000..fa06ce3 --- /dev/null +++ b/pylib/csuite_test.py @@ -0,0 +1,107 @@ +# Lint as: python3 +# +# Copyright 2020, The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""CSuite-specific automated unit test functionality.""" + +import argparse +import sys +from typing import Any, Sequence, Text +import unittest + +# Export the TestCase class to reduce the number of imports tests have to list. +TestCase = unittest.TestCase + +_DEVICE_SERIAL = None + + +def get_device_serial() -> Text: + """Returns the serial of the connected device.""" + if not _DEVICE_SERIAL: + raise RuntimeError( + 'Device serial is unset, did you call main in your test?') + return _DEVICE_SERIAL + + +def create_arg_parser(add_help: bool = False) -> argparse.ArgumentParser: + """Creates a new parser that can handle the default command-line flags. + + The object returned by this function can be used by other modules that want to + add their own command-line flags. The returned parser is intended to be passed + to the 'parents' argument of ArgumentParser and extend the set of default + flags with additional ones. + + Args: + add_help: whether to add an option which simply displays the parser’s help + message; this is typically false when used from other modules that want to + use the returned parser as a parent argument parser. + + Returns: + A new arg parser that can handle the default flags expected by this module. + """ + + # The below flags are passed in by the TF Python test runner. + parser = argparse.ArgumentParser(add_help=add_help) + + parser.add_argument('-s', '--serial', help='the device serial') + parser.add_argument( + '--test-output-file', + help='the file in which to store the test results', + required=True) + + return parser + + +def run_tests(args: Any, unittest_argv: Sequence[Text]) -> None: + """Executes a set of Python unit tests. + + This function is typically used by modules that extend command-line flags. + Callers create their own argument parser with this module's parser as a parent + and parse the command-line. The resulting object is will contain the + attributes expected by this module and is used to call this method. + + Args: + args: an object that contains at least the set of attributes defined in + objects returned when using the default argument parser. + unittest_argv: the list of command-line arguments to forward to + unittest.main. + """ + global _DEVICE_SERIAL + + _DEVICE_SERIAL = args.serial + + with open(args.test_output_file, 'w') as test_output_file: + + # Note that we use a type and not an instance for 'testRunner' since + # TestProgram forwards its constructor arguments when creating an instance + # of the runner type. Not doing so would require us to make sure that the + # parameters passed to TestProgram are aligned with those for creating a + # runner instance. + class TestRunner(unittest.TextTestRunner): + """A test runner that writes test results to the TF-provided file.""" + + def __init__(self, *args, **kwargs): + super(TestRunner, self).__init__( + stream=test_output_file, *args, **kwargs) + + # Setting verbosity is required to generate output that the TradeFed test + # runner can parse. + unittest.TestProgram(verbosity=3, testRunner=TestRunner, argv=unittest_argv) + + +def main(): + """Executes a set of Python unit tests.""" + args, unittest_argv = create_arg_parser(add_help=True).parse_known_args( + sys.argv) + run_tests(args, unittest_argv) diff --git a/pylintrc b/pylintrc new file mode 100644 index 0000000..c8c911f --- /dev/null +++ b/pylintrc @@ -0,0 +1,427 @@ +# This Pylint rcfile contains a best-effort configuration to uphold the +# best-practices and style described in the Google Python style guide: +# https://google.github.io/styleguide/pyguide.html +# +# Its canonical open-source location is: +# https://google.github.io/styleguide/pylintrc + +[MASTER] + +# Add files or directories to the blacklist. They should be base names, not +# paths. +ignore=third_party + +# Add files or directories matching the regex patterns to the blacklist. The +# regex matches against base names, not paths. +ignore-patterns= + +# Pickle collected data for later comparisons. +persistent=no + +# List of plugins (as comma separated values of python modules names) to load, +# usually to register additional checkers. +load-plugins= + +# Use multiple processes to speed up Pylint. +jobs=4 + +# Allow loading of arbitrary C extensions. Extensions are imported into the +# active Python interpreter and may run arbitrary code. +unsafe-load-any-extension=no + +# A comma-separated list of package or module names from where C extensions may +# be loaded. Extensions are loading into the active Python interpreter and may +# run arbitrary code +extension-pkg-whitelist= + + +[MESSAGES CONTROL] + +# Only show warnings with the listed confidence levels. Leave empty to show +# all. Valid levels: HIGH, INFERENCE, INFERENCE_FAILURE, UNDEFINED +confidence= + +# Enable the message, report, category or checker with the given id(s). You can +# either give multiple identifier separated by comma (,) or put this option +# multiple time (only on the command line, not in the configuration file where +# it should appear only once). See also the "--disable" option for examples. +#enable= + +# Disable the message, report, category or checker with the given id(s). You +# can either give multiple identifiers separated by comma (,) or put this +# option multiple times (only on the command line, not in the configuration +# file where it should appear only once).You can also use "--disable=all" to +# disable everything first and then reenable specific checks. For example, if +# you want to run only the similarities checker, you can use "--disable=all +# --enable=similarities". If you want to run only the classes checker, but have +# no Warning level messages displayed, use"--disable=all --enable=classes +# --disable=W" +disable=apply-builtin, + backtick, + bad-option-value, + basestring-builtin, + buffer-builtin, + c-extension-no-member, + cmp-builtin, + cmp-method, + coerce-builtin, + coerce-method, + delslice-method, + div-method, + duplicate-code, + eq-without-hash, + execfile-builtin, + file-builtin, + filter-builtin-not-iterating, + fixme, + getslice-method, + global-statement, + hex-method, + idiv-method, + implicit-str-concat-in-sequence, + import-error, + import-self, + import-star-module-level, + input-builtin, + intern-builtin, + invalid-str-codec, + locally-disabled, + long-builtin, + long-suffix, + map-builtin-not-iterating, + metaclass-assignment, + next-method-called, + next-method-defined, + no-absolute-import, + no-else-break, + no-else-continue, + no-else-raise, + no-else-return, + no-member, + no-self-use, + nonzero-method, + oct-method, + old-division, + old-ne-operator, + old-octal-literal, + old-raise-syntax, + parameter-unpacking, + print-statement, + raising-string, + range-builtin-not-iterating, + raw_input-builtin, + rdiv-method, + reduce-builtin, + relative-import, + reload-builtin, + round-builtin, + setslice-method, + signature-differs, + standarderror-builtin, + suppressed-message, + sys-max-int, + too-few-public-methods, + too-many-ancestors, + too-many-arguments, + too-many-boolean-expressions, + too-many-branches, + too-many-instance-attributes, + too-many-locals, + too-many-public-methods, + too-many-return-statements, + too-many-statements, + trailing-newlines, + unichr-builtin, + unicode-builtin, + unpacking-in-except, + useless-else-on-loop, + useless-suppression, + using-cmp-argument, + xrange-builtin, + zip-builtin-not-iterating, + + +[REPORTS] + +# Set the output format. Available formats are text, parseable, colorized, msvs +# (visual studio) and html. You can also give a reporter class, eg +# mypackage.mymodule.MyReporterClass. +output-format=text + +# Put messages in a separate file for each module / package specified on the +# command line instead of printing them on stdout. Reports (if any) will be +# written in a file name "pylint_global.[txt|html]". This option is deprecated +# and it will be removed in Pylint 2.0. +files-output=no + +# Tells whether to display a full report or only the messages +reports=no + +# Python expression which should return a note less than 10 (10 is the highest +# note). You have access to the variables errors warning, statement which +# respectively contain the number of errors / warnings messages and the total +# number of statements analyzed. This is used by the global evaluation report +# (RP0004). +evaluation=10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10) + +# Template used to display messages. This is a python new-style format string +# used to format the message information. See doc for all details +#msg-template= + + +[BASIC] + +# Good variable names which should always be accepted, separated by a comma +good-names=main,_ + +# Bad variable names which should always be refused, separated by a comma +bad-names= + +# Colon-delimited sets of names that determine each other's naming style when +# the name regexes allow several styles. +name-group= + +# Include a hint for the correct naming format with invalid-name +include-naming-hint=no + +# List of decorators that produce properties, such as abc.abstractproperty. Add +# to this list to register other decorators that produce valid properties. +property-classes=abc.abstractproperty,cached_property.cached_property,cached_property.threaded_cached_property,cached_property.cached_property_with_ttl,cached_property.threaded_cached_property_with_ttl + +# Regular expression matching correct function names +function-rgx=^(?:(?P<exempt>setUp|tearDown|setUpModule|tearDownModule)|(?P<camel_case>_?[A-Z][a-zA-Z0-9]*)|(?P<snake_case>_?[a-z][a-z0-9_]*))$ + +# Regular expression matching correct variable names +variable-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct constant names +const-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct attribute names +attr-rgx=^_{0,2}[a-z][a-z0-9_]*$ + +# Regular expression matching correct argument names +argument-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class attribute names +class-attribute-rgx=^(_?[A-Z][A-Z0-9_]*|__[a-z0-9_]+__|_?[a-z][a-z0-9_]*)$ + +# Regular expression matching correct inline iteration names +inlinevar-rgx=^[a-z][a-z0-9_]*$ + +# Regular expression matching correct class names +class-rgx=^_?[A-Z][a-zA-Z0-9]*$ + +# Regular expression matching correct module names +module-rgx=^(_?[a-z][a-z0-9_]*|__init__)$ + +# Regular expression matching correct method names +method-rgx=(?x)^(?:(?P<exempt>_[a-z0-9_]+__|runTest|setUp|tearDown|setUpTestCase|tearDownTestCase|setupSelf|tearDownClass|setUpClass|(test|assert)_*[A-Z0-9][a-zA-Z0-9_]*|next)|(?P<camel_case>_{0,2}[A-Z][a-zA-Z0-9_]*)|(?P<snake_case>_{0,2}[a-z][a-z0-9_]*))$ + +# Regular expression which should only match function or class names that do +# not require a docstring. +no-docstring-rgx=(__.*__|main|test.*|.*test|.*Test)$ + +# Minimum line length for functions/classes that require docstrings, shorter +# ones are exempt. +docstring-min-length=10 + + +[TYPECHECK] + +# List of decorators that produce context managers, such as +# contextlib.contextmanager. Add to this list to register other decorators that +# produce valid context managers. +contextmanager-decorators=contextlib.contextmanager,contextlib2.contextmanager + +# Tells whether missing members accessed in mixin class should be ignored. A +# mixin class is detected if its name ends with "mixin" (case insensitive). +ignore-mixin-members=yes + +# List of module names for which member attributes should not be checked +# (useful for modules/projects where namespaces are manipulated during runtime +# and thus existing member attributes cannot be deduced by static analysis. It +# supports qualified module names, as well as Unix pattern matching. +ignored-modules= + +# List of class names for which member attributes should not be checked (useful +# for classes with dynamically set attributes). This supports the use of +# qualified names. +ignored-classes=optparse.Values,thread._local,_thread._local + +# List of members which are set dynamically and missed by pylint inference +# system, and so shouldn't trigger E1101 when accessed. Python regular +# expressions are accepted. +generated-members= + + +[FORMAT] + +# Maximum number of characters on a single line. +max-line-length=80 + +# TODO(https://github.com/PyCQA/pylint/issues/3352): Direct pylint to exempt +# lines made too long by directives to pytype. + +# Regexp for a line that is allowed to be longer than the limit. +ignore-long-lines=(?x)( + ^\s*(\#\ )?<?https?://\S+>?$| + ^\s*(from\s+\S+\s+)?import\s+.+$) + +# Allow the body of an if to be on the same line as the test if there is no +# else. +single-line-if-stmt=yes + +# List of optional constructs for which whitespace checking is disabled. `dict- +# separator` is used to allow tabulation in dicts, etc.: {1 : 1,\n222: 2}. +# `trailing-comma` allows a space between comma and closing bracket: (a, ). +# `empty-line` allows space-only lines. +no-space-check= + +# Maximum number of lines in a module +max-module-lines=99999 + +# String used as indentation unit. The internal Google style guide mandates 2 +# spaces. Google's externaly-published style guide says 4, consistent with +# PEP 8. Here, we use 2 spaces, for conformity with many open-sourced Google +# projects (like TensorFlow). +indent-string=' ' + +# Number of spaces of indent required inside a hanging or continued line. +indent-after-paren=4 + +# Expected format of line ending, e.g. empty (any line ending), LF or CRLF. +expected-line-ending-format= + + +[MISCELLANEOUS] + +# List of note tags to take in consideration, separated by a comma. +notes=TODO + + +[VARIABLES] + +# Tells whether we should check for unused import in __init__ files. +init-import=no + +# A regular expression matching the name of dummy variables (i.e. expectedly +# not used). +dummy-variables-rgx=^\*{0,2}(_$|unused_|dummy_) + +# List of additional names supposed to be defined in builtins. Remember that +# you should avoid to define new builtins when possible. +additional-builtins= + +# List of strings which can identify a callback function by name. A callback +# name must start or end with one of those strings. +callbacks=cb_,_cb + +# List of qualified module names which can have objects that can redefine +# builtins. +redefining-builtins-modules=six,six.moves,past.builtins,future.builtins,functools + + +[LOGGING] + +# Logging modules to check that the string format arguments are in logging +# function parameter format +logging-modules=logging,absl.logging,tensorflow.google.logging + + +[SIMILARITIES] + +# Minimum lines number of a similarity. +min-similarity-lines=4 + +# Ignore comments when computing similarities. +ignore-comments=yes + +# Ignore docstrings when computing similarities. +ignore-docstrings=yes + +# Ignore imports when computing similarities. +ignore-imports=no + + +[SPELLING] + +# Spelling dictionary name. Available dictionaries: none. To make it working +# install python-enchant package. +spelling-dict= + +# List of comma separated words that should not be checked. +spelling-ignore-words= + +# A path to a file that contains private dictionary; one word per line. +spelling-private-dict-file= + +# Tells whether to store unknown words to indicated private dictionary in +# --spelling-private-dict-file option instead of raising a message. +spelling-store-unknown-words=no + + +[IMPORTS] + +# Deprecated modules which should not be used, separated by a comma +deprecated-modules=regsub, + TERMIOS, + Bastion, + rexec, + sets + +# Create a graph of every (i.e. internal and external) dependencies in the +# given file (report RP0402 must not be disabled) +import-graph= + +# Create a graph of external dependencies in the given file (report RP0402 must +# not be disabled) +ext-import-graph= + +# Create a graph of internal dependencies in the given file (report RP0402 must +# not be disabled) +int-import-graph= + +# Force import order to recognize a module as part of the standard +# compatibility libraries. +known-standard-library= + +# Force import order to recognize a module as part of a third party library. +known-third-party=enchant, absl + +# Analyse import fallback blocks. This can be used to support both Python 2 and +# 3 compatible code, which means that the block might have code that exists +# only in one or another interpreter, leading to false positives when analysed. +analyse-fallback-blocks=no + + +[CLASSES] + +# List of method names used to declare (i.e. assign) instance attributes. +defining-attr-methods=__init__, + __new__, + setUp + +# List of member names, which should be excluded from the protected access +# warning. +exclude-protected=_asdict, + _fields, + _replace, + _source, + _make + +# List of valid names for the first argument in a class method. +valid-classmethod-first-arg=cls, + class_ + +# List of valid names for the first argument in a metaclass class method. +valid-metaclass-classmethod-first-arg=mcs + + +[EXCEPTIONS] + +# Exceptions that will emit a warning when being caught. Defaults to +# "Exception" +overgeneral-exceptions=StandardError, + Exception, + BaseException diff --git a/test_targets/csuite-app-launch/Android.bp b/test_targets/csuite-app-launch/Android.bp new file mode 100644 index 0000000..256d9f4 --- /dev/null +++ b/test_targets/csuite-app-launch/Android.bp @@ -0,0 +1,22 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +csuite_test { + name: "csuite-app-launch", + test_config_template: "template.xml" +} diff --git a/test_targets/csuite-app-launch/template.xml b/test_targets/csuite-app-launch/template.xml new file mode 100644 index 0000000..52c4611 --- /dev/null +++ b/test_targets/csuite-app-launch/template.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<configuration description="Launches an app and check for crashes"> + <option name="package-name" value="{package}"/> + <target_preparer class="com.android.compatibility.targetprep.AppSetupPreparer"> + <option name="test-file-name" value="app://{package}"/> + </target_preparer> + <target_preparer class="com.android.compatibility.targetprep.CheckGmsPreparer"/> + <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer"> + <option name="run-command" value="input keyevent KEYCODE_WAKEUP"/> + <option name="run-command" value="input keyevent KEYCODE_MENU"/> + <option name="run-command" value="input keyevent KEYCODE_HOME"/> + </target_preparer> + <test class="com.android.compatibility.testtype.AppLaunchTest"/> +</configuration>
\ No newline at end of file diff --git a/test_targets/csuite-pre-installed-app-launch/Android.bp b/test_targets/csuite-pre-installed-app-launch/Android.bp new file mode 100644 index 0000000..539306a --- /dev/null +++ b/test_targets/csuite-pre-installed-app-launch/Android.bp @@ -0,0 +1,22 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +csuite_test { + name: "csuite-pre-installed-app-launch", + test_config_template: "template.xml" +} diff --git a/test_targets/csuite-pre-installed-app-launch/template.xml b/test_targets/csuite-pre-installed-app-launch/template.xml new file mode 100644 index 0000000..2d20306 --- /dev/null +++ b/test_targets/csuite-pre-installed-app-launch/template.xml @@ -0,0 +1,25 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2021 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<configuration description="Launches an app that exists on the device and check for crashes"> + <option name="package-name" value="{package}"/> + <target_preparer class="com.android.compatibility.targetprep.CheckGmsPreparer"/> + <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer"> + <option name="run-command" value="input keyevent KEYCODE_WAKEUP"/> + <option name="run-command" value="input keyevent KEYCODE_MENU"/> + <option name="run-command" value="input keyevent KEYCODE_HOME"/> + </target_preparer> + <test class="com.android.compatibility.testtype.AppLaunchTest"/> +</configuration>
\ No newline at end of file diff --git a/test_targets/csuite-system-app-launch/Android.bp b/test_targets/csuite-system-app-launch/Android.bp new file mode 100644 index 0000000..2514740 --- /dev/null +++ b/test_targets/csuite-system-app-launch/Android.bp @@ -0,0 +1,22 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +csuite_test { + name: "csuite-system-app-launch", + test_config_template: "template.xml" +} diff --git a/test_targets/csuite-system-app-launch/template.xml b/test_targets/csuite-system-app-launch/template.xml new file mode 100644 index 0000000..4d1181b --- /dev/null +++ b/test_targets/csuite-system-app-launch/template.xml @@ -0,0 +1,28 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<configuration description="Reinstalls a system app and check for launch crashes."> + <option name="package-name" value="{package}"/> + <target_preparer class="com.android.compatibility.targetprep.AppSetupPreparer"> + <option name="test-file-name" value="app://{package}"/> + </target_preparer> + <target_preparer class="com.android.compatibility.targetprep.CheckGmsPreparer"/> + <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer"> + <option name="run-command" value="input keyevent KEYCODE_WAKEUP"/> + <option name="run-command" value="input keyevent KEYCODE_MENU"/> + <option name="run-command" value="input keyevent KEYCODE_HOME"/> + </target_preparer> + <test class="com.android.compatibility.testtype.AppLaunchTest"/> +</configuration>
\ No newline at end of file diff --git a/test_targets/csuite-test-package-launch/Android.bp b/test_targets/csuite-test-package-launch/Android.bp new file mode 100644 index 0000000..6cdd899 --- /dev/null +++ b/test_targets/csuite-test-package-launch/Android.bp @@ -0,0 +1,22 @@ +// Copyright (C) 2021 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +csuite_test { + name: "csuite-test-package-launch", + test_config_template: "template.xml" +} diff --git a/test_targets/csuite-test-package-launch/template.xml b/test_targets/csuite-test-package-launch/template.xml new file mode 100644 index 0000000..9c97fd3 --- /dev/null +++ b/test_targets/csuite-test-package-launch/template.xml @@ -0,0 +1,29 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<configuration description="Installs a test package with -t arg and check for launch crashes"> + <option name="package-name" value="{package}"/> + <target_preparer class="com.android.compatibility.targetprep.AppSetupPreparer"> + <option name="test-file-name" value="app://{package}"/> + <option name="install-arg" value="-t"/> + </target_preparer> + <target_preparer class="com.android.compatibility.targetprep.CheckGmsPreparer"/> + <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer"> + <option name="run-command" value="input keyevent KEYCODE_WAKEUP"/> + <option name="run-command" value="input keyevent KEYCODE_MENU"/> + <option name="run-command" value="input keyevent KEYCODE_HOME"/> + </target_preparer> + <test class="com.android.compatibility.testtype.AppLaunchTest"/> +</configuration>
\ No newline at end of file diff --git a/tools/csuite-tradefed/Android.bp b/tools/csuite-tradefed/Android.bp index 82d959e..a441726 100644 --- a/tools/csuite-tradefed/Android.bp +++ b/tools/csuite-tradefed/Android.bp @@ -12,6 +12,10 @@ // See the License for the specific language governing permissions and // limitations under the License. +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + tradefed_binary_host { name: "csuite-tradefed", wrapper: "src/scripts/csuite-tradefed", @@ -33,5 +37,7 @@ java_test_host { "tradefed", "csuite-tradefed", ], - test_suites: ["general-tests"], + test_options: { + unit_test: true, + }, } diff --git a/tools/csuite-tradefed/AndroidTest.xml b/tools/csuite-tradefed/AndroidTest.xml deleted file mode 100644 index 850750e..0000000 --- a/tools/csuite-tradefed/AndroidTest.xml +++ /dev/null @@ -1,20 +0,0 @@ -<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2019 The Android Open Source Project - -Licensed under the Apache License, Version 2.0 (the "License"); -you may not use this file except in compliance with the License. -You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, software -distributed under the License is distributed on an "AS IS" BASIS, -WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -See the License for the specific language governing permissions and -limitations under the License. ---> -<configuration> - <test class="com.android.tradefed.testtype.HostTest" > - <option name="class" value="com.android.compatibility.tradefed.CSuiteTradefedTest" /> - </test> -</configuration> diff --git a/tools/csuite-tradefed/TEST_MAPPING b/tools/csuite-tradefed/TEST_MAPPING deleted file mode 100644 index 89c2072..0000000 --- a/tools/csuite-tradefed/TEST_MAPPING +++ /dev/null @@ -1,8 +0,0 @@ -{ - "postsubmit": [ - { - "name": "csuite-tradefed-tests", - "host": true - } - ] -} diff --git a/tools/csuite-tradefed/src/scripts/csuite-tradefed b/tools/csuite-tradefed/src/scripts/csuite-tradefed index 4277884..f3b887a 100644 --- a/tools/csuite-tradefed/src/scripts/csuite-tradefed +++ b/tools/csuite-tradefed/src/scripts/csuite-tradefed @@ -83,7 +83,6 @@ JAR_DIR=${CSUITE_ROOT}/android-csuite/tools TRADEFED_JAR="tradefed" JARS="tradefed - hosttestlib compatibility-host-util csuite-tradefed csuite-tradefed-tests" @@ -99,7 +98,7 @@ OPTIONAL_JARS=" google-tf-prod-tests" for JAR in $OPTIONAL_JARS; do - if [ -f "$JAR.jar" ]; then + if [ -f "${JAR_DIR}/${JAR}.jar" ]; then JAR_PATH=${JAR_PATH}:${JAR_DIR}/${JAR}.jar fi; done @@ -119,4 +118,4 @@ for j in ${CSUITE_ROOT}/android-csuite/testcases/*.jar; do JAR_PATH=${JAR_PATH}:$j done -java $RDBG_FLAG -cp ${JAR_PATH} -DMTS_ROOT=${CSUITE_ROOT} com.android.compatibility.common.tradefed.command.CompatibilityConsole "$@" +java $RDBG_FLAG -cp ${JAR_PATH} -DCSUITE_ROOT=${CSUITE_ROOT} com.android.compatibility.common.tradefed.command.CompatibilityConsole "$@" diff --git a/tools/csuite_test/Android.bp b/tools/csuite_test/Android.bp new file mode 100644 index 0000000..a1103dc --- /dev/null +++ b/tools/csuite_test/Android.bp @@ -0,0 +1,20 @@ +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +bootstrap_go_package { + name: "soong-csuite", + pkgPath: "android/soong/csuite", + deps: [ + "blueprint", + "soong-android", + "soong-java", + ], + srcs: [ + "csuite_test.go", + ], + testSrcs: [ + "csuite_test_test.go", + ], + pluginFor: ["soong_build"], +} diff --git a/tools/csuite_test/csuite_test.go b/tools/csuite_test/csuite_test.go new file mode 100644 index 0000000..74373f2 --- /dev/null +++ b/tools/csuite_test/csuite_test.go @@ -0,0 +1,132 @@ +// Copyright 2020 Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package csuite + +import ( + "android/soong/android" + "android/soong/java" + "strings" +) + +var ( + pctx = android.NewPackageContext("android/soong/csuite") +) + +func init() { + android.RegisterModuleType("csuite_test", CSuiteTestFactory) +} + +type csuiteTestProperties struct { + // Local path to a module template xml file. + // The content of the template will be used to generate test modules at runtime. + Test_config_template *string `android:"path"` + + // Local path to a test plan config xml to be included in the generated plan. + Test_plan_include *string `android:"path"` +} + +type CSuiteTest struct { + // Java TestHost. + java.TestHost + + // C-Suite test properties struct. + csuiteTestProperties csuiteTestProperties +} + +func (cSuiteTest *CSuiteTest) buildCopyConfigTemplateCommand(ctx android.ModuleContext, rule *android.RuleBuilder) string { + if cSuiteTest.csuiteTestProperties.Test_config_template == nil { + ctx.ModuleErrorf(`'test_config_template' is missing.`) + } + inputPath := android.PathForModuleSrc(ctx, *cSuiteTest.csuiteTestProperties.Test_config_template) + genPath := android.PathForModuleGen(ctx, planConfigDirName, ctx.ModuleName()+configTemplateFileExtension) + rule.Command().Textf("cp").Input(inputPath).Output(genPath) + cSuiteTest.AddExtraResource(genPath) + return genPath.Rel() +} + +func (cSuiteTest *CSuiteTest) buildCopyPlanIncludeCommand(ctx android.ModuleContext, rule *android.RuleBuilder) string { + if cSuiteTest.csuiteTestProperties.Test_plan_include == nil { + return emptyPlanIncludePath + } + inputPath := android.PathForModuleSrc(ctx, *cSuiteTest.csuiteTestProperties.Test_plan_include) + genPath := android.PathForModuleGen(ctx, planConfigDirName, "includes", ctx.ModuleName()+".xml") + rule.Command().Textf("cp").Input(inputPath).Output(genPath) + cSuiteTest.AddExtraResource(genPath) + return strings.Replace(genPath.Rel(), "config/", "", -1) +} + +func (cSuiteTest *CSuiteTest) buildWritePlanConfigRule(ctx android.ModuleContext, configTemplatePath string, planIncludePath string) { + planName := ctx.ModuleName() + content := strings.Replace(planTemplate, "{planName}", planName, -1) + content = strings.Replace(content, "{templatePath}", configTemplatePath, -1) + content = strings.Replace(content, "{planInclude}", planIncludePath, -1) + genPath := android.PathForModuleGen(ctx, planConfigDirName, planName+planFileExtension) + android.WriteFileRule(ctx, genPath, content) + cSuiteTest.AddExtraResource(genPath) +} + +func (cSuiteTest *CSuiteTest) GenerateAndroidBuildActions(ctx android.ModuleContext) { + rule := android.NewRuleBuilder(pctx, ctx) + + configTemplatePath := cSuiteTest.buildCopyConfigTemplateCommand(ctx, rule) + planIncludePath := cSuiteTest.buildCopyPlanIncludeCommand(ctx, rule) + cSuiteTest.buildWritePlanConfigRule(ctx, configTemplatePath, planIncludePath) + + rule.Build("CSuite", "generate C-Suite config files") + cSuiteTest.TestHost.GenerateAndroidBuildActions(ctx) +} + +func CSuiteTestFactory() android.Module { + module := &CSuiteTest{} + module.AddProperties(&module.csuiteTestProperties) + installable := true + autoGenConfig := false + java.InitTestHost(&module.TestHost, &installable, []string{"csuite"}, &autoGenConfig) + + java.InitJavaModuleMultiTargets(module, android.HostSupported) + + return module +} + +const ( + emptyPlanIncludePath = `empty` + planConfigDirName = `config` + configTemplateFileExtension = `.xml.template` + planFileExtension = `.xml` + planTemplate = `<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<configuration> + <test class="com.android.csuite.config.ModuleGenerator"> + <option name="template" value="{templatePath}" /> + </test> + <include name="csuite-base" /> + <include name="{planInclude}" /> + <option name="plan" value="{planName}" /> +</configuration> +` +) diff --git a/tools/csuite_test/csuite_test_test.go b/tools/csuite_test/csuite_test_test.go new file mode 100644 index 0000000..daf07b0 --- /dev/null +++ b/tools/csuite_test/csuite_test_test.go @@ -0,0 +1,286 @@ +// Copyright 2020 Google Inc. All rights reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package csuite + +import ( + "android/soong/android" + "io/ioutil" + "os" + "strings" + "testing" +) + +var buildDir string + +func TestBpContainsTestHostPropsThrowsError(t *testing.T) { + ctx, _ := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template", + data_native_bins: "bin" + } + `) + + _, errs := ctx.ParseBlueprintsFiles("Android.bp") + + android.FailIfNoMatchingErrors(t, `unrecognized property`, errs) +} + +func TestBpContainsManifestThrowsError(t *testing.T) { + ctx, _ := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template", + test_config: "AndroidTest.xml" + } + `) + + _, errs := ctx.ParseBlueprintsFiles("Android.bp") + + android.FailIfNoMatchingErrors(t, `unrecognized property`, errs) +} + +func TestBpMissingNameThrowsError(t *testing.T) { + ctx, _ := createContextAndConfig(t, ` + csuite_test { + test_config_template: "test_config.xml.template" + } + `) + + _, errs := ctx.ParseBlueprintsFiles("Android.bp") + + android.FailIfNoMatchingErrors(t, `'name' is missing`, errs) +} + +func TestBpMissingTemplatePathThrowsError(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + } + `) + + ctx.ParseBlueprintsFiles("Android.bp") + _, errs := ctx.PrepareBuildActions(config) + + android.FailIfNoMatchingErrors(t, `'test_config_template' is missing`, errs) +} + +func TestValidBpMissingPlanIncludeDoesNotThrowError(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template" + } + `) + + parseBpAndBuild(t, ctx, config) +} + +func TestValidBpMissingPlanIncludeGeneratesPlanXmlWithoutPlaceholders(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template" + } + `) + + parseBpAndBuild(t, ctx, config) + + module := ctx.ModuleForTests("plan_name", android.BuildOs.String()+"_common") + content := android.ContentFromFileRuleForTests(t, module.Output("config/plan_name.xml")) + if strings.Contains(content, "{") || strings.Contains(content, "}") { + t.Errorf("The generated plan name contains a placeholder: %s", content) + } +} + +func TestGeneratedTestPlanContainsPlanName(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template" + } + `) + + parseBpAndBuild(t, ctx, config) + + module := ctx.ModuleForTests("plan_name", android.BuildOs.String()+"_common") + content := android.ContentFromFileRuleForTests(t, module.Output("config/plan_name.xml")) + if !strings.Contains(content, "plan_name") { + t.Errorf("The plan name is missing from the generated plan: %s", content) + } +} + +func TestGeneratedTestPlanContainsTemplatePath(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template" + } + `) + + parseBpAndBuild(t, ctx, config) + + module := ctx.ModuleForTests("plan_name", android.BuildOs.String()+"_common") + content := android.ContentFromFileRuleForTests(t, module.Output("config/plan_name.xml")) + if !strings.Contains(content, "config/plan_name.xml.template") { + t.Errorf("The template path is missing from the generated plan: %s", content) + } +} + +func TestTemplateFileCopyRuleExists(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template" + } + `) + + parseBpAndBuild(t, ctx, config) + + params := ctx.ModuleForTests("plan_name", android.BuildOs.String()+"_common").Rule("CSuite") + assertFileCopyRuleExists(t, params, "test_config.xml.template", "config/plan_name.xml.template") +} + +func TestGeneratedTestPlanContainsPlanInclude(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template", + test_plan_include: "include.xml" + } + `) + + parseBpAndBuild(t, ctx, config) + + module := ctx.ModuleForTests("plan_name", android.BuildOs.String()+"_common") + content := android.ContentFromFileRuleForTests(t, module.Output("config/plan_name.xml")) + if !strings.Contains(content, `"includes/plan_name.xml"`) { + t.Errorf("The plan include path is missing from the generated plan: %s", content) + } +} + +func TestPlanIncludeFileCopyRuleExists(t *testing.T) { + ctx, config := createContextAndConfig(t, ` + csuite_test { + name: "plan_name", + test_config_template: "test_config.xml.template", + test_plan_include: "include.xml" + } + `) + + parseBpAndBuild(t, ctx, config) + + params := ctx.ModuleForTests("plan_name", android.BuildOs.String()+"_common").Rule("CSuite") + assertFileCopyRuleExists(t, params, "include.xml", "config/includes/plan_name.xml") +} + +func TestMain(m *testing.M) { + run := func() int { + setUp() + defer tearDown() + + return m.Run() + } + + os.Exit(run()) +} + +func parseBpAndBuild(t *testing.T, ctx *android.TestContext, config android.Config) { + _, parsingErrs := ctx.ParseBlueprintsFiles("Android.bp") + _, buildErrs := ctx.PrepareBuildActions(config) + + android.FailIfErrored(t, parsingErrs) + android.FailIfErrored(t, buildErrs) +} + +func assertFileCopyRuleExists(t *testing.T, params android.TestingBuildParams, src string, dst string) { + assertPathsContains(t, getAllInputPaths(params), src) + assertWritablePathsContainsRel(t, getAllOutputPaths(params), dst) + if !strings.HasPrefix(params.RuleParams.Command, "cp") { + t.Errorf("'cp' command is missing.") + } +} + +func assertPathsContains(t *testing.T, paths android.Paths, path string) { + for _, p := range paths { + if p.String() == path { + return + } + } + t.Errorf("Cannot find expected path %s", path) +} + +func assertWritablePathsContainsRel(t *testing.T, paths android.WritablePaths, relPath string) { + for _, path := range paths { + if path.Rel() == relPath { + return + } + } + t.Errorf("Cannot find expected relative path %s", relPath) +} + +func getAllOutputPaths(params android.TestingBuildParams) android.WritablePaths { + var paths []android.WritablePath + if params.Output != nil { + paths = append(paths, params.Output) + } + if params.ImplicitOutput != nil { + paths = append(paths, params.ImplicitOutput) + } + if params.SymlinkOutput != nil { + paths = append(paths, params.SymlinkOutput) + } + paths = append(paths, params.Outputs...) + paths = append(paths, params.ImplicitOutputs...) + paths = append(paths, params.SymlinkOutputs...) + + return paths +} + +func getAllInputPaths(params android.TestingBuildParams) android.Paths { + var paths []android.Path + if params.Input != nil { + paths = append(paths, params.Input) + } + if params.Implicit != nil { + paths = append(paths, params.Implicit) + } + paths = append(paths, params.Inputs...) + paths = append(paths, params.Implicits...) + + return paths +} + +func setUp() { + var err error + buildDir, err = ioutil.TempDir("", "soong_csuite_test") + if err != nil { + panic(err) + } +} + +func tearDown() { + os.RemoveAll(buildDir) +} + +func createContextAndConfig(t *testing.T, bp string) (*android.TestContext, android.Config) { + t.Helper() + config := android.TestArchConfig(buildDir, nil, bp, nil) + ctx := android.NewTestArchContext(config) + ctx.RegisterModuleType("csuite_test", CSuiteTestFactory) + ctx.Register() + + return ctx, config +} diff --git a/tools/csuite_test/go.mod b/tools/csuite_test/go.mod new file mode 100644 index 0000000..a373cd1 --- /dev/null +++ b/tools/csuite_test/go.mod @@ -0,0 +1,14 @@ +module android/soong/csuite + +require ( + android/soong v0.0.0 + github.com/google/blueprint v0.0.0 +) + +replace android/soong v0.0.0 => ../../../../../build/soong + +replace github.com/golang/protobuf v0.0.0 => ../../../../../external/golang-protobuf + +replace github.com/google/blueprint v0.0.0 => ../../../../../build/blueprint + +go 1.13
\ No newline at end of file diff --git a/tools/script/Android.bp b/tools/script/Android.bp new file mode 100644 index 0000000..c1eb990 --- /dev/null +++ b/tools/script/Android.bp @@ -0,0 +1,47 @@ +// Copyright (C) 2020 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package { + default_applicable_licenses: ["Android-Apache-2.0"], +} + +python_binary_host { + name: "csuite_generate_module", + main: "generate_module.py", + srcs: [ + "generate_module.py", + ], + defaults: [ + "csuite_python_defaults", + ], +} + +python_test_host { + name: "generate_module_test", + srcs: [ + "generate_module.py", + "generate_module_test.py", + ], + libs: [ + "csuite_test", + "pyfakefs", + ], + test_config_template: "csuite_test_template.xml", + test_options: { + unit_test: true, + }, + defaults: [ + "csuite_python_defaults", + ], +} diff --git a/tools/script/csuite_test_template.xml b/tools/script/csuite_test_template.xml new file mode 100644 index 0000000..837716a --- /dev/null +++ b/tools/script/csuite_test_template.xml @@ -0,0 +1,23 @@ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2020 The Android Open Source Project + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<configuration> + <test class="com.android.tradefed.testtype.python.PythonBinaryHostTest"> + <option name="par-file-name" value="{MODULE}"/> + <option name="inject-serial-option" value="true"/> + <option name="use-test-output-file" value="true"/> + <option name="test-timeout" value="10m"/> + </test> +</configuration> diff --git a/tools/script/generate_module.py b/tools/script/generate_module.py index 30ae7b4..5bda01f 100644 --- a/tools/script/generate_module.py +++ b/tools/script/generate_module.py @@ -1,6 +1,6 @@ -#!/usr/bin/env python +# Lint as: python3 # -# Copyright (C) 2020 The Android Open Source Project +# Copyright (C) 2019 The Android Open Source Project # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. @@ -13,246 +13,237 @@ # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. -# -# This script generates C-Suite configuration files for a list of apps. +"""This script generates C-Suite configuration files for a list of apps.""" import argparse +import contextlib import glob import os +import string import sys -from xml.dom import minidom -from xml.etree import cElementTree as ET -from xml.sax import saxutils -from typing import IO, List, Text +from typing import IO, Set, Text _ANDROID_BP_FILE_NAME = 'Android.bp' _ANDROID_XML_FILE_NAME = 'AndroidTest.xml' +_AUTO_GENERATE_NOTE = 'THIS FILE WAS AUTO-GENERATED. DO NOT EDIT MANUALLY!' + +DEFAULT_BUILD_MODULE_TEMPLATE = string.Template("""\ +// Copyright (C) 2019 The Android Open Source Project +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. -_TF_TEST_APP_INSTALL_SETUP =\ - 'com.android.tradefed.targetprep.TestAppInstallSetup' -_CSUITE_APP_SETUP_PREPARER =\ - 'com.android.compatibility.targetprep.AppSetupPreparer' -_CSUITE_LAUNCH_TEST_CLASS =\ - 'com.android.compatibility.testtype.AppLaunchTest' +// ${auto_generate_note} -_CONFIG_TYPE_TARGET_PREPARER = 'target_preparer' -_CONFIG_TYPE_TEST = 'test' +csuite_config { + name: "csuite_${package_name}", +} +""") +DEFAULT_TEST_MODULE_TEMPLATE = string.Template("""\ +<?xml version="1.0" encoding="utf-8"?> +<!-- Copyright (C) 2019 The Android Open Source Project -def generate_all_modules_from_config(package_list_file_path, root_dir): - """Generate multiple test and build modules. + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at - Args: + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. +--> +<!-- ${auto_generate_note}--> + +<configuration description="Tests the compatibility of apps"> + <option key="plan" name="config-descriptor:metadata" value="app-launch"/> + <option name="package-name" value="${package_name}"/> + <target_preparer class="com.android.compatibility.targetprep.AppSetupPreparer"> + <option name="test-file-name" value="csuite-launch-instrumentation.apk"/> + <option name="test-file-name" value="app://${package_name}"/> + </target_preparer> + <target_preparer class="com.android.compatibility.targetprep.CheckGmsPreparer"/> + <target_preparer class="com.android.tradefed.targetprep.RunCommandTargetPreparer"> + <option name="run-command" value="input keyevent KEYCODE_WAKEUP"/> + <option name="run-command" value="input keyevent KEYCODE_MENU"/> + <option name="run-command" value="input keyevent KEYCODE_HOME"/> + </target_preparer> + <test class="com.android.compatibility.testtype.AppLaunchTest"/> +</configuration> +""") + + +def generate_all_modules_from_config(package_list_file_path, + root_dir, + build_module_template_file_path=None, + test_module_template_file_path=None): + """Generate multiple test and build modules. + + Args: package_list_file_path: path of a file containing package names. root_dir: root directory that modules will be generated in. - """ - remove_existing_package_files(root_dir) - - with open(package_list_file_path) as fp: - for line in parse_package_list(fp): - _generate_module_files(line.strip(), root_dir) + build_module_template_file_path: path of a file containing build module + template. + test_module_template_file_path: path of a file containing test module + template. + """ + build_module_template = DEFAULT_BUILD_MODULE_TEMPLATE + test_module_template = DEFAULT_TEST_MODULE_TEMPLATE + if build_module_template_file_path: + with open(build_module_template_file_path, 'r') as f: + build_module_template = string.Template(f.read()) + if test_module_template_file_path: + with open(test_module_template_file_path, 'r') as f: + test_module_template = string.Template(f.read()) + + remove_existing_package_files(root_dir) + + with open(package_list_file_path) as fp: + for line in parse_package_list(fp): + _generate_module_files(line.strip(), root_dir, build_module_template, + test_module_template) def remove_existing_package_files(root_dir): - for filename in glob.iglob(root_dir + '**/AndroidTest.xml'): - if _is_auto_generated(filename): - os.remove(filename) + for filename in glob.iglob(root_dir + '/**/AndroidTest.xml'): + if _is_auto_generated(filename): + os.remove(filename) - for filename in glob.iglob(root_dir + '**/Android.bp'): - if _is_auto_generated(filename): - os.remove(filename) + for filename in glob.iglob(root_dir + '/**/Android.bp'): + if _is_auto_generated(filename): + os.remove(filename) - _remove_empty_dirs(root_dir) + _remove_empty_dirs(root_dir) def _is_auto_generated(filename): - with open(filename, 'r') as f: - return 'auto-generated' in f.read() + with open(filename, 'r') as f: + return _AUTO_GENERATE_NOTE in f.read() def _remove_empty_dirs(path): - for filename in os.listdir(path): - file_path = os.path.join(path, filename) - if os.path.isdir(file_path) and not os.listdir(file_path): - os.rmdir(file_path) + for filename in os.listdir(path): + file_path = os.path.join(path, filename) + if os.path.isdir(file_path) and not os.listdir(file_path): + os.rmdir(file_path) -def parse_package_list(package_list_file: IO[bytes]) -> List[bytes]: - return { - line.strip() for line in package_list_file.readlines() if line.strip()} +def parse_package_list(package_list_file: IO[bytes]) -> Set[bytes]: + packages = {line.strip() for line in package_list_file.readlines()} + for package in packages: + if package and not package.startswith('#'): + yield package -def _generate_module_files(package_name, root_dir): - """Generate test and build modules for a single package. +def _generate_module_files(package_name, root_dir, build_module_template, + test_module_template): + """Generate test and build modules for a single package. - Args: + Args: package_name: package name of test and build modules. root_dir: root directory that modules will be generated in. - """ - package_dir = _create_package_dir(root_dir, package_name) + build_module_template: template for build module. + test_module_template: template for test module. + """ + package_dir = _create_package_dir(root_dir, package_name) - build_module_path = os.path.join(package_dir, _ANDROID_BP_FILE_NAME) - test_module_path = os.path.join(package_dir, _ANDROID_XML_FILE_NAME) + build_module_path = os.path.join(package_dir, _ANDROID_BP_FILE_NAME) + test_module_path = os.path.join(package_dir, _ANDROID_XML_FILE_NAME) - with open(build_module_path, 'w') as f: - write_build_module(package_name, f) + with open(build_module_path, 'w') as f: + write_module(build_module_template, package_name, f) - with open(test_module_path, 'w') as f: - write_test_module(package_name, f) + with open(test_module_path, 'w') as f: + write_module(test_module_template, package_name, f) def _create_package_dir(root_dir, package_name): - package_dir_path = os.path.join(root_dir, package_name) - os.mkdir(package_dir_path) - - return package_dir_path - - -def write_build_module(package_name: Text, out_file: IO[bytes]) -> Text: - build_module = _BUILD_MODULE_HEADER \ - + _BUILD_MODULE_TEMPLATE.format(package_name=package_name) - out_file.write(build_module) - - -def write_test_module(package_name: Text, out_file: IO[bytes]) -> Text: - configuration = ET.Element('configuration', { - 'description': 'Tests the compatibility of apps' - }) - ET.SubElement( - configuration, 'option', { - 'name': 'config-descriptor:metadata', - 'key': 'plan', - 'value': 'csuite-launch' - } - ) - ET.SubElement( - configuration, 'option', { - 'name': 'package-name', - 'value': package_name - } - ) - test_file_name_option = { - 'name': 'test-file-name', - 'value': 'csuite-launch-instrumentation.apk' - } - _add_element_with_option( - configuration, - _CONFIG_TYPE_TARGET_PREPARER, - _TF_TEST_APP_INSTALL_SETUP, - options=[test_file_name_option] - ) - _add_element_with_option( - configuration, - _CONFIG_TYPE_TARGET_PREPARER, - _CSUITE_APP_SETUP_PREPARER - ) - _add_element_with_option( - configuration, - _CONFIG_TYPE_TEST, - _CSUITE_LAUNCH_TEST_CLASS - ) - - test_module = _TEST_MODULE_HEADER + _prettify(configuration) - out_file.write(test_module) - - -def _add_element_with_option(elem, sub_elem, class_name, options=None): - if options is None: - options = [] - - new_elem = ET.SubElement( - elem, sub_elem, { - 'class': class_name, - } - ) - for option in options: - ET.SubElement( - new_elem, 'option', option - ) - - -def _prettify(elem: ET.Element) -> Text: - declaration = minidom.Document().toxml() - parsed = minidom.parseString(ET.tostring(elem, 'utf-8')) - - return saxutils.unescape( - parsed.toprettyxml(indent=' ')[len(declaration) + 1:]) - -_BUILD_MODULE_HEADER = """// Copyright (C) 2020 The Android Open Source Project -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. - -// This file was auto-generated by test/app_compat/csuite/tools/script/generate_module.py. -// Do not edit manually. - -""" + package_dir_path = os.path.join(root_dir, package_name) + os.mkdir(package_dir_path) -_BUILD_MODULE_TEMPLATE = """csuite_config {{ - name: "csuite_{package_name}", -}} -""" + return package_dir_path -_TEST_MODULE_HEADER = """<?xml version="1.0" encoding="utf-8"?> -<!-- Copyright (C) 2020 The Android Open Source Project - Licensed under the Apache License, Version 2.0 (the "License"); - you may not use this file except in compliance with the License. - You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 - - Unless required by applicable law or agreed to in writing, software - distributed under the License is distributed on an "AS IS" BASIS, - WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - See the License for the specific language governing permissions and - limitations under the License. ---> -<!-- This file was auto-generated by test/app_compat/csuite/tools/script/generate_module.py. - Do not edit manually. ---> - -""" +def write_module(template: string.Template, package_name: Text, + out_file: IO[bytes]) -> Text: + """Writes the build or test module for the provided package into a file.""" + test_module = template.substitute( + package_name=package_name, auto_generate_note=_AUTO_GENERATE_NOTE) + out_file.write(test_module) def _file_path(path): - if os.path.isfile(path): - return path - raise argparse.ArgumentTypeError('%s is not a valid path' % path) + if os.path.isfile(path): + return path + raise argparse.ArgumentTypeError('%s is not a valid path' % path) def _dir_path(path): - if os.path.isdir(path): - return path - raise argparse.ArgumentTypeError('%s is not a valid path' % path) - - -def parse_args(args): - parser = argparse.ArgumentParser() - parser.add_argument('--package_list', - type=_file_path, - required=True, - help='path of the file containing package names') - parser.add_argument('--root_dir', - type=_dir_path, - required=True, - help='path of the root directory that' + - 'modules will be generated in') + if os.path.isdir(path): + return path + raise argparse.ArgumentTypeError('%s is not a valid path' % path) + + +@contextlib.contextmanager +def _redirect_sys_output(out, err): + current_out, current_err = sys.stdout, sys.stderr + try: + sys.stdout, sys.stderr = out, err + yield + finally: + sys.stdout, sys.stderr = current_out, current_err + + +def parse_args(args, out=sys.stdout, err=sys.stderr): + """Parses the provided sequence of arguments.""" + parser = argparse.ArgumentParser() + parser.add_argument( + '--package-list', + type=_file_path, + required=True, + help='path of the file containing package names') + parser.add_argument( + '--root-dir', + type=_dir_path, + required=True, + help='path of the root directory that' + 'modules will be generated in') + parser.add_argument( + '--test-module-template', + type=_file_path, + required=False, + help='path of the file containing test module configuration template') + parser.add_argument( + '--build-module-template', + type=_file_path, + required=False, + help='path of the file containing build module configuration template') + + # We redirect stdout and stderr to improve testability since ArgumentParser + # always writes to those files. More specifically, the TradeFed python test + # runner will choke parsing output that is not in the expected format. + with _redirect_sys_output(out, err): return parser.parse_args(args) def main(): - parser = parse_args(sys.argv[1:]) - generate_all_modules_from_config(parser.package_list, parser.root_dir) + parser = parse_args(sys.argv[1:]) + generate_all_modules_from_config(parser.package_list, parser.root_dir, + parser.build_module_template, + parser.test_module_template) + if __name__ == '__main__': - main() + main() diff --git a/tools/script/generate_module_test.py b/tools/script/generate_module_test.py new file mode 100644 index 0000000..09370a0 --- /dev/null +++ b/tools/script/generate_module_test.py @@ -0,0 +1,245 @@ +# Lint as: python3 +# +# Copyright (C) 2020 The Android Open Source Project +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +"""Tests for the generate_module package.""" + +import io +import os +from xml.etree import cElementTree as ET +import csuite_test +import generate_module +from pyfakefs import fake_filesystem_unittest + +_AUTO_GENERATE_NOTE = 'THIS FILE WAS AUTO-GENERATED. DO NOT EDIT MANUALLY!' + + +class WriteTestModuleTest(csuite_test.TestCase): + + def test_output_contains_license(self): + out = io.StringIO() + + generate_module.write_module(generate_module.DEFAULT_BUILD_MODULE_TEMPLATE, + 'a.package.name', out) + + self.assertIn('Copyright', out.getvalue()) + self.assertIn('Android Open Source Project', out.getvalue()) + + def test_output_is_valid_xml(self): + out = io.StringIO() + + generate_module.write_module(generate_module.DEFAULT_TEST_MODULE_TEMPLATE, + 'a.package.name', out) + + self.assert_valid_xml(out.getvalue()) + + def test_output_contains_package_name(self): + package_name = 'a.package.name' + out = io.StringIO() + + generate_module.write_module(generate_module.DEFAULT_TEST_MODULE_TEMPLATE, + 'a.package.name', out) + + self.assertIn(package_name, out.getvalue()) + + def assert_valid_xml(self, xml_str: bytes) -> None: + try: + ET.parse(io.BytesIO(xml_str.encode('utf8'))) + except ET.ParseError as e: + self.fail('Input \'%s\' is not a valid XML document: %s' % (xml_str, e)) + + +class WriteBuildModuleTest(csuite_test.TestCase): + + def test_output_contains_license(self): + out = io.StringIO() + + generate_module.write_module(generate_module.DEFAULT_BUILD_MODULE_TEMPLATE, + 'a.package.name', out) + + self.assertIn('Copyright', out.getvalue()) + self.assertIn('Android Open Source Project', out.getvalue()) + + def test_output_is_valid_build_file(self): + package_name = 'a.package.name' + out = io.StringIO() + + generate_module.write_module(generate_module.DEFAULT_BUILD_MODULE_TEMPLATE, + 'a.package.name', out) + + out_str = out.getvalue() + self.assert_braces_balanced(out_str) + self.assertIn('csuite_config', out_str) + self.assertIn(package_name, out_str) + + def assert_braces_balanced(self, generated_str: bytes) -> None: + """Checks whether all braces in the provided string are balanced.""" + count = 0 + + for c in generated_str: + if c == '{': + count += 1 + elif c == '}': + count -= 1 + + if count < 0: + break + + self.assertEqual(count, 0, + 'Braces in \'%s\' are not balanced' % generated_str) + + +class ParsePackageListTest(csuite_test.TestCase): + + def test_accepts_empty_lines(self): + lines = io.StringIO('\n\n\npackage_name\n\n') + + package_list = generate_module.parse_package_list(lines) + + self.assertListEqual(['package_name'], list(package_list)) + + def test_strips_trailing_whitespace(self): + lines = io.StringIO(' package_name ') + + package_list = generate_module.parse_package_list(lines) + + self.assertListEqual(['package_name'], list(package_list)) + + def test_duplicate_package_name(self): + lines = io.StringIO('\n\npackage_name\n\npackage_name\n') + + package_list = generate_module.parse_package_list(lines) + + self.assertListEqual(['package_name'], list(package_list)) + + def test_ignore_comment_lines(self): + lines = io.StringIO('\n# Comments.\npackage_name\n') + + package_list = generate_module.parse_package_list(lines) + + self.assertListEqual(['package_name'], list(package_list)) + + +class ParseArgsTest(fake_filesystem_unittest.TestCase): + + def setUp(self): + super(ParseArgsTest, self).setUp() + self.setUpPyfakefs() + + def test_configuration_file_not_exist(self): + package_list_file_path = '/test/package_list.txt' + root_dir = '/test/modules' + os.makedirs(root_dir) + + with self.assertRaises(SystemExit): + generate_module.parse_args( + ['--package-list', package_list_file_path, '--root-dir', root_dir], + out=io.StringIO(), + err=io.StringIO()) + + def test_module_dir_not_exist(self): + package_list_file_path = '/test/package_list.txt' + package_name1 = 'package_name_1' + package_name2 = 'package_name_2' + self.fs.create_file( + package_list_file_path, contents=(package_name1 + '\n' + package_name2)) + root_dir = '/test/modules' + + with self.assertRaises(SystemExit): + generate_module.parse_args( + ['--package-list', package_list_file_path, '--root-dir', root_dir], + out=io.StringIO(), + err=io.StringIO()) + + def test_test_module_template_file_not_exist(self): + package_list_file_path = '/test/package_list.txt' + package_name1 = 'package_name_1' + package_name2 = 'package_name_2' + self.fs.create_file( + package_list_file_path, contents=(package_name1 + '\n' + package_name2)) + root_dir = '/test/modules' + os.makedirs(root_dir) + template_file_path = '/test/template.txt' + + with self.assertRaises(SystemExit): + generate_module.parse_args([ + '--package-list', package_list_file_path, '--root-dir', root_dir, + '--test', template_file_path + ], + out=io.StringIO(), + err=io.StringIO()) + + def test_build_module_template_file_not_exist(self): + package_list_file_path = '/test/package_list.txt' + package_name1 = 'package_name_1' + package_name2 = 'package_name_2' + self.fs.create_file( + package_list_file_path, contents=(package_name1 + '\n' + package_name2)) + root_dir = '/test/modules' + os.makedirs(root_dir) + template_file_path = '/test/template.txt' + + with self.assertRaises(SystemExit): + generate_module.parse_args([ + '--package-list', package_list_file_path, '--root-dir', root_dir, + '--template', template_file_path + ], + out=io.StringIO(), + err=io.StringIO()) + + +class GenerateAllModulesFromConfigTest(fake_filesystem_unittest.TestCase): + + def setUp(self): + super(GenerateAllModulesFromConfigTest, self).setUp() + self.setUpPyfakefs() + + def test_creates_package_files(self): + package_list_file_path = '/test/package_list.txt' + package_name1 = 'package_name_1' + package_name2 = 'package_name_2' + self.fs.create_file( + package_list_file_path, contents=(package_name1 + '\n' + package_name2)) + root_dir = '/test/modules' + self.fs.create_dir(root_dir) + + generate_module.generate_all_modules_from_config(package_list_file_path, + root_dir) + + self.assertTrue( + os.path.exists(os.path.join(root_dir, package_name1, 'Android.bp'))) + self.assertTrue( + os.path.exists( + os.path.join(root_dir, package_name1, 'AndroidTest.xml'))) + self.assertTrue( + os.path.exists(os.path.join(root_dir, package_name2, 'Android.bp'))) + self.assertTrue( + os.path.exists( + os.path.join(root_dir, package_name2, 'AndroidTest.xml'))) + + def test_removes_all_existing_package_files(self): + root_dir = '/test/' + package_dir = '/test/existing_package/' + self.fs.create_file( + 'test/existing_package/AndroidTest.xml', contents=_AUTO_GENERATE_NOTE) + self.fs.create_file( + 'test/existing_package/Android.bp', contents=_AUTO_GENERATE_NOTE) + + generate_module.remove_existing_package_files(root_dir) + + self.assertFalse(os.path.exists(package_dir)) + + +if __name__ == '__main__': + csuite_test.main() diff --git a/tools/script/generate_module_unittest.py b/tools/script/generate_module_unittest.py deleted file mode 100644 index 4d24162..0000000 --- a/tools/script/generate_module_unittest.py +++ /dev/null @@ -1,203 +0,0 @@ -#!/usr/bin/env python -# -# Copyright (C) 2020 The Android Open Source Project -# -# Licensed under the Apache License, Version 2.0 (the "License"); -# you may not use this file except in compliance with the License. -# You may obtain a copy of the License at -# -# http://www.apache.org/licenses/LICENSE-2.0 -# -# Unless required by applicable law or agreed to in writing, software -# distributed under the License is distributed on an "AS IS" BASIS, -# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -# See the License for the specific language governing permissions and -# limitations under the License. - -import io -import os -import unittest - -from lxml import etree -from pyfakefs import fake_filesystem_unittest - -import generate_module - - -class WriteTestModuleTest(unittest.TestCase): - - def test_xml_is_valid(self): - package_name = 'package_name' - out = io.StringIO() - - generate_module.write_test_module(package_name, out) - - test_module_generated = out.getvalue() - self.assertTrue(self._contains_license(test_module_generated)) - self.assertTrue(self._is_validate_xml(test_module_generated)) - - def _contains_license(self, generated_str: bytes) -> bool: - return 'Copyright' in generated_str and \ - 'Android Open Source Project' in generated_str - - def _is_validate_xml(self, xml_str: bytes) -> bool: - xmlschema_doc = etree.parse( - io.BytesIO('''<?xml version="1.0" encoding="UTF-8" ?> - <xs:schema xmlns:xs="http://www.w3.org/2001/XMLSchema"> - <xs:element name="configuration"> - <xs:complexType> - <xs:sequence> - <xs:element name="option" minOccurs="0" maxOccurs="unbounded"/> - <xs:element name="target_preparer" minOccurs="0" maxOccurs="unbounded"/> - <xs:element name="test" minOccurs="0" maxOccurs="unbounded"/> - </xs:sequence> - <xs:attribute name="description"/> - </xs:complexType> - </xs:element> - </xs:schema> - '''.encode('utf8'))) - xmlschema = etree.XMLSchema(xmlschema_doc) - - xml_doc = etree.parse(io.BytesIO(xml_str.encode('utf8'))) - result = xmlschema.validate(xml_doc) - - return result - - -class WriteBuildModuleTest(unittest.TestCase): - - def test_build_file_is_valid(self): - package_name = 'package_name' - out = io.StringIO() - - generate_module.write_build_module(package_name, out) - - build_module_generated = out.getvalue() - self.assertTrue(self._contains_license(build_module_generated)) - self.assertTrue(self._are_parentheses_balanced(build_module_generated)) - self.assertIn('csuite_config', build_module_generated) - self.assertIn(package_name, build_module_generated) - - def _contains_license(self, generated_str: bytes) -> bool: - return 'Copyright' in generated_str and \ - 'Android Open Source Project' in generated_str - - def _are_parentheses_balanced(self, generated_str: bytes) -> bool: - parenthese_count = 0 - - for elem in generated_str: - if elem == '{': - parenthese_count += 1 - elif elem == '}': - parenthese_count -= 1 - - if parenthese_count < 0: - return False - - return parenthese_count == 0 - - -class ParsePackageListTest(unittest.TestCase): - - def test_accepts_empty_lines(self): - input = io.StringIO('\n\n\npackage_name\n\n') - - package_list = generate_module.parse_package_list(input) - - self.assertEqual(len(package_list), 1) - self.assertIn('package_name', package_list) - self.assertTrue(all(package_list)) - - def test_strips_trailing_whitespace(self): - input = io.StringIO(' package_name ') - - package_list = generate_module.parse_package_list(input) - - self.assertEqual(len(package_list), 1) - self.assertIn('package_name', package_list) - self.assertTrue(all(package_list)) - - def test_duplicate_package_name(self): - input = io.StringIO('\n\npackage_name\n\npackage_name\n') - - package_list = generate_module.parse_package_list(input) - - self.assertEqual(len(package_list), 1) - self.assertIn('package_name', package_list) - self.assertTrue(all(package_list)) - - -class ParseArgsTest(fake_filesystem_unittest.TestCase): - - def setUp(self): - super(ParseArgsTest, self).setUp() - self.setUpPyfakefs() - - def test_configuration_file_not_exist(self): - package_list_file_path = '/test/package_list.txt' - root_dir = '/test/modules' - os.makedirs(root_dir) - - with self.assertRaises(SystemExit): - generate_module.parse_args( - ['--package_list', package_list_file_path, - '--root_dir', root_dir]) - - def test_module_dir_not_exist(self): - package_list_file_path = '/test/package_list.txt' - package_name1 = 'package_name_1' - package_name2 = 'package_name_2' - self.fs.create_file(package_list_file_path, - contents=(package_name1+'\n'+package_name2)) - root_dir = '/test/modules' - - with self.assertRaises(SystemExit): - generate_module.parse_args( - ['--package_list', package_list_file_path, - '--root_dir', root_dir]) - - -class GenerateAllModulesFromConfigTest(fake_filesystem_unittest.TestCase): - - def setUp(self): - super(GenerateAllModulesFromConfigTest, self).setUp() - self.setUpPyfakefs() - - def test_creates_package_files(self): - package_list_file_path = '/test/package_list.txt' - package_name1 = 'package_name_1' - package_name2 = 'package_name_2' - self.fs.create_file(package_list_file_path, - contents=(package_name1+'\n'+package_name2)) - root_dir = '/test/modules' - self.fs.create_dir(root_dir) - - generate_module.generate_all_modules_from_config( - package_list_file_path, root_dir) - - self.assertTrue(os.path.exists( - os.path.join(root_dir, package_name1, 'Android.bp'))) - self.assertTrue(os.path.exists( - os.path.join(root_dir, package_name1, 'AndroidTest.xml'))) - self.assertTrue(os.path.exists( - os.path.join(root_dir, package_name2, 'Android.bp'))) - self.assertTrue(os.path.exists( - os.path.join(root_dir, package_name2, 'AndroidTest.xml'))) - - def test_removes_all_existing_package_files(self): - root_dir = '/test/' - package_dir = '/test/existing_package/' - existing_package_file1 = 'test/existing_package/AndroidTest.xml' - existing_package_file2 = 'test/existing_package/Android.bp' - self.fs.create_file(existing_package_file1, contents='auto-generated') - self.fs.create_file(existing_package_file2, contents='auto-generated') - - generate_module.remove_existing_package_files(root_dir) - - self.assertFalse(os.path.exists(existing_package_file1)) - self.assertFalse(os.path.exists(existing_package_file2)) - self.assertFalse(os.path.exists(package_dir)) - - -if __name__ == '__main__': - unittest.main() |