Add a module generator preparer

Add a new preparer that can generate module configs at test runtime.

Bug: 175317761
Test: atest :presubmit
Test: make csuite && csuite-tradefed run simple-app-launch-test --package <package>
Change-Id: I3201c77edd3426eecafe1a92931dfe6ddd37cf2c
diff --git a/harness/Android.bp b/harness/Android.bp
index cea4d03..66f123b 100644
--- a/harness/Android.bp
+++ b/harness/Android.bp
@@ -23,6 +23,9 @@
     libs: [
         "tradefed",
     ],
+    static_libs: [
+        "compatibility-tradefed",
+    ]
 }
 
 java_test_host {
@@ -35,7 +38,9 @@
         "tradefed",
     ],
     static_libs: [
+        "compatibility-tradefed",
         "guava-testlib",
+        "jimfs",
         "mockito-host",
         "objenesis",
         "testng",
diff --git a/harness/src/main/java/com/android/csuite/core/GenerateModulePreparer.java b/harness/src/main/java/com/android/csuite/core/GenerateModulePreparer.java
new file mode 100644
index 0000000..33d1b85
--- /dev/null
+++ b/harness/src/main/java/com/android/csuite/core/GenerateModulePreparer.java
@@ -0,0 +1,166 @@
+/*
+ * 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 com.android.compatibility.common.tradefed.build.CompatibilityBuildHelper;
+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.targetprep.ITargetPreparer;
+import com.android.tradefed.targetprep.TargetSetupError;
+
+import com.google.common.annotations.VisibleForTesting;
+import com.google.common.io.Resources;
+
+import java.io.IOException;
+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.ArrayList;
+import java.util.HashSet;
+import java.util.List;
+import java.util.Set;
+
+/**
+ * A preparer for generating TradeFed test suite modules config files.
+ *
+ * <p>This preparer generates module config files into TradeFed's test directory at runtime using a
+ * template. The entire test directory is delete before every run. As a result, there can only be
+ * one instance executing at a given time.
+ */
+public final class GenerateModulePreparer implements ITargetPreparer {
+
+    @VisibleForTesting static final String MODULE_FILE_EXTENSION = ".config";
+    @VisibleForTesting static final String OPTION_TEMPLATE = "template";
+    @VisibleForTesting static final String OPTION_PACKAGE = "package";
+    private static final String TEMPLATE_PACKAGE_PATTERN = "\\{package\\}";
+
+    @Option(
+            name = OPTION_TEMPLATE,
+            description = "Module config template resource path.",
+            importance = Importance.ALWAYS)
+    private String mTemplate;
+
+    @Option(name = OPTION_PACKAGE, description = "App package names.")
+    private final Set<String> mPackages = new HashSet<>();
+
+    private final TestDirectoryProvider mTestDirectoryProvider;
+    private final ResourceLoader mResourceLoader;
+    private final FileSystem mFileSystem;
+    private final List<Path> mGeneratedModules = new ArrayList<>();
+
+    public GenerateModulePreparer() {
+        this(FileSystems.getDefault());
+    }
+
+    private GenerateModulePreparer(FileSystem fileSystem) {
+        this(
+                fileSystem,
+                new CompatibilityTestDirectoryProvider(fileSystem),
+                new ClassResourceLoader());
+    }
+
+    @VisibleForTesting
+    GenerateModulePreparer(
+            FileSystem fileSystem,
+            TestDirectoryProvider testDirectoryProvider,
+            ResourceLoader resourceLoader) {
+        mFileSystem = fileSystem;
+        mTestDirectoryProvider = testDirectoryProvider;
+        mResourceLoader = resourceLoader;
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void setUp(TestInformation testInfo)
+            throws TargetSetupError, DeviceNotAvailableException {
+        try {
+            Path testsDir = mTestDirectoryProvider.get(testInfo);
+            String templateContent = mResourceLoader.load(mTemplate);
+
+            for (String packageName : mPackages) {
+                validatePackageName(packageName);
+                Path modulePath = testsDir.resolve(packageName + MODULE_FILE_EXTENSION);
+                Files.write(
+                        modulePath,
+                        templateContent
+                                .replaceAll(TEMPLATE_PACKAGE_PATTERN, packageName)
+                                .getBytes());
+                mGeneratedModules.add(modulePath);
+            }
+        } catch (IOException e) {
+            throw new TargetSetupError("Failed to generate modules", e);
+        }
+    }
+
+    /** {@inheritDoc} */
+    @Override
+    public void tearDown(TestInformation testInfo, Throwable e) throws DeviceNotAvailableException {
+        mGeneratedModules.forEach(
+                modulePath -> {
+                    try {
+                        Files.delete(modulePath);
+                    } catch (IOException ioException) {
+                        CLog.e("Failed to delete a generated module: " + modulePath, ioException);
+                    }
+                });
+    }
+
+    private static void validatePackageName(String packageName) throws TargetSetupError {
+        if (packageName.isEmpty() || packageName.matches(".*" + TEMPLATE_PACKAGE_PATTERN + ".*")) {
+            throw new TargetSetupError(
+                    "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(TestInformation testInfo) throws IOException;
+    }
+
+    private static final class CompatibilityTestDirectoryProvider implements TestDirectoryProvider {
+        private final FileSystem mFileSystem;
+
+        private CompatibilityTestDirectoryProvider(FileSystem fileSystem) {
+            mFileSystem = fileSystem;
+        }
+
+        @Override
+        public Path get(TestInformation testInfo) throws IOException {
+            return mFileSystem.getPath(
+                    new CompatibilityBuildHelper(testInfo.getBuildInfo()).getTestsDir().getPath());
+        }
+    }
+}
diff --git a/harness/src/test/java/com/android/csuite/CSuiteUnitTests.java b/harness/src/test/java/com/android/csuite/CSuiteUnitTests.java
index e8aec05..01df8a6 100644
--- a/harness/src/test/java/com/android/csuite/CSuiteUnitTests.java
+++ b/harness/src/test/java/com/android/csuite/CSuiteUnitTests.java
@@ -26,6 +26,7 @@
     com.android.compatibility.targetprep.SystemAppRemovalPreparerTest.class,
     com.android.compatibility.testtype.AppLaunchTestTest.class,
     com.android.csuite.config.AppRemoteFileResolverTest.class,
+    com.android.csuite.core.GenerateModulePreparerTest.class,
     com.android.csuite.testing.CorrespondencesTest.class,
     com.android.csuite.testing.MoreAssertsTest.class,
 })
diff --git a/harness/src/test/java/com/android/csuite/core/GenerateModulePreparerTest.java b/harness/src/test/java/com/android/csuite/core/GenerateModulePreparerTest.java
new file mode 100644
index 0000000..eeaa2e2
--- /dev/null
+++ b/harness/src/test/java/com/android/csuite/core/GenerateModulePreparerTest.java
@@ -0,0 +1,278 @@
+/*
+ * 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 static org.testng.Assert.assertThrows;
+
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.targetprep.TargetSetupError;
+
+import com.google.common.collect.ArrayListMultimap;
+import com.google.common.collect.ImmutableList;
+import com.google.common.collect.ListMultimap;
+import com.google.common.jimfs.Configuration;
+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 java.io.IOException;
+import java.nio.file.FileSystem;
+import java.nio.file.Files;
+import java.nio.file.Path;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.Map;
+
+@RunWith(JUnit4.class)
+public final class GenerateModulePreparerTest {
+    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(Configuration.unix());
+
+    @Test
+    public void tearDown_packageOptionIsSet_deletesGeneratedModules() throws Exception {
+        TestInformation testInfo = createTestInfo();
+        Path testsDir = createTestsDir();
+        GenerateModulePreparer preparer =
+                createPreparerBuilder()
+                        .setTestsDir(testsDir)
+                        .addPackage(TEST_PACKAGE_NAME1)
+                        .addPackage(TEST_PACKAGE_NAME1) // Simulate duplicate package option
+                        .addPackage(TEST_PACKAGE_NAME2)
+                        .build();
+        preparer.setUp(testInfo);
+
+        preparer.tearDown(testInfo, NO_EXCEPTION);
+
+        assertThatListDirectory(testsDir).isEmpty();
+    }
+
+    @Test
+    public void tearDown_packageOptionIsNotSet_doesNotThrowError() throws Exception {
+        TestInformation testInfo = createTestInfo();
+        GenerateModulePreparer preparer =
+                createPreparerBuilder().setTestsDir(createTestsDir()).build();
+        preparer.setUp(testInfo);
+
+        preparer.tearDown(testInfo, NO_EXCEPTION);
+    }
+
+    @Test
+    public void setUp_packageNameIsEmptyString_throwsError() throws Exception {
+        GenerateModulePreparer preparer = createPreparerBuilder().addPackage("").build();
+
+        assertThrows(TargetSetupError.class, () -> preparer.setUp(createTestInfo()));
+    }
+
+    @Test
+    public void setUp_packageNameContainsPlaceholder_throwsError() throws Exception {
+        GenerateModulePreparer preparer =
+                createPreparerBuilder().addPackage("a" + PACKAGE_PLACEHOLDER + "b").build();
+
+        assertThrows(TargetSetupError.class, () -> preparer.setUp(createTestInfo()));
+    }
+
+    @Test
+    public void setUp_packageOptionContainsDuplicates_ignoreDuplicates() throws Exception {
+        Path testsDir = createTestsDir();
+        GenerateModulePreparer preparer =
+                createPreparerBuilder()
+                        .setTestsDir(testsDir)
+                        .addPackage(TEST_PACKAGE_NAME1)
+                        .addPackage(TEST_PACKAGE_NAME1) // Simulate duplicate package option
+                        .addPackage(TEST_PACKAGE_NAME2)
+                        .build();
+
+        preparer.setUp(createTestInfo());
+
+        assertThatListDirectory(testsDir)
+                .containsExactly(
+                        getModuleConfigFile(testsDir, TEST_PACKAGE_NAME1),
+                        getModuleConfigFile(testsDir, TEST_PACKAGE_NAME2));
+    }
+
+    @Test
+    public void setUp_packageOptionNotSet_doesNotGenerate() throws Exception {
+        Path testsDir = createTestsDir();
+        GenerateModulePreparer preparer = createPreparerBuilder().setTestsDir(testsDir).build();
+
+        preparer.setUp(createTestInfo());
+
+        assertThatListDirectory(testsDir).isEmpty();
+    }
+
+    @Test
+    public void setUp_templateContainsPlaceholders_replacesPlaceholdersInOutput() throws Exception {
+        Path testsDir = createTestsDir();
+        String content = "hello placeholder%s%s world";
+        GenerateModulePreparer preparer =
+                createPreparerBuilder()
+                        .setTestsDir(testsDir)
+                        .addPackage(TEST_PACKAGE_NAME1)
+                        .addPackage(TEST_PACKAGE_NAME2)
+                        .setTemplateContent(
+                                String.format(content, PACKAGE_PLACEHOLDER, PACKAGE_PLACEHOLDER))
+                        .build();
+
+        preparer.setUp(createTestInfo());
+
+        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 setUp_templateDoesNotContainPlaceholder_outputsTemplateContent() throws Exception {
+        Path testsDir = createTestsDir();
+        String content = "no placeholder";
+        GenerateModulePreparer preparer =
+                createPreparerBuilder()
+                        .setTestsDir(testsDir)
+                        .addPackage(TEST_PACKAGE_NAME1)
+                        .addPackage(TEST_PACKAGE_NAME2)
+                        .setTemplateContent(content)
+                        .build();
+
+        preparer.setUp(createTestInfo());
+
+        assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME1).isEqualTo(content);
+        assertThatModuleConfigFileContent(testsDir, TEST_PACKAGE_NAME2).isEqualTo(content);
+    }
+
+    @Test
+    public void setUp_templateContentIsEmpty_outputsTemplateContent() throws Exception {
+        Path testsDir = createTestsDir();
+        String content = "";
+        GenerateModulePreparer preparer =
+                createPreparerBuilder()
+                        .setTestsDir(testsDir)
+                        .addPackage(TEST_PACKAGE_NAME1)
+                        .addPackage(TEST_PACKAGE_NAME2)
+                        .setTemplateContent(content)
+                        .build();
+
+        preparer.setUp(createTestInfo());
+
+        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() {
+        return TestInformation.newBuilder().build();
+    }
+
+    private PreparerBuilder createPreparerBuilder() throws IOException {
+        return new PreparerBuilder()
+                .setFileSystem(mFileSystem)
+                .setTemplateContent(MODULE_TEMPLATE_CONTENT)
+                .setOption(GenerateModulePreparer.OPTION_TEMPLATE, "empty_path");
+    }
+
+    private static final class PreparerBuilder {
+        private final ListMultimap<String, String> mOptions = ArrayListMultimap.create();
+        private final List<String> mPackages = new ArrayList<>();
+        private Path mTestsDir;
+        private String mTemplateContent;
+        private FileSystem mFileSystem;
+
+        PreparerBuilder addPackage(String packageName) {
+            mPackages.add(packageName);
+            return this;
+        }
+
+        PreparerBuilder setFileSystem(FileSystem fileSystem) {
+            mFileSystem = fileSystem;
+            return this;
+        }
+
+        PreparerBuilder setTemplateContent(String templateContent) {
+            mTemplateContent = templateContent;
+            return this;
+        }
+
+        PreparerBuilder setTestsDir(Path testsDir) {
+            mTestsDir = testsDir;
+            return this;
+        }
+
+        PreparerBuilder setOption(String key, String value) {
+            mOptions.put(key, value);
+            return this;
+        }
+
+        GenerateModulePreparer build() throws Exception {
+            GenerateModulePreparer preparer =
+                    new GenerateModulePreparer(
+                            mFileSystem, testInfo -> mTestsDir, resourcePath -> mTemplateContent);
+
+            OptionSetter optionSetter = new OptionSetter(preparer);
+            for (Map.Entry<String, String> entry : mOptions.entries()) {
+                optionSetter.setOptionValue(entry.getKey(), entry.getValue());
+            }
+
+            for (String packageName : mPackages) {
+                optionSetter.setOptionValue(GenerateModulePreparer.OPTION_PACKAGE, packageName);
+            }
+
+            return preparer;
+        }
+    }
+
+    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_preparer class=\"some.preparer.class\">\n"
+                    + "        <option name=\"test-file-name\" value=\"app://{package}\"/>\n"
+                    + "    </target_preparer>\n"
+                    + "    <test class=\"some.test.class\"/>\n"
+                    + "</configuration>";
+}