Merge "Add RunHostScriptTargetPreparer"
diff --git a/test_framework/com/android/tradefed/targetprep/RunHostScriptTargetPreparer.java b/test_framework/com/android/tradefed/targetprep/RunHostScriptTargetPreparer.java
new file mode 100644
index 0000000..346c86e
--- /dev/null
+++ b/test_framework/com/android/tradefed/targetprep/RunHostScriptTargetPreparer.java
@@ -0,0 +1,178 @@
+/*
+ * 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.tradefed.targetprep;
+
+import com.android.tradefed.config.GlobalConfiguration;
+import com.android.tradefed.config.Option;
+import com.android.tradefed.config.OptionClass;
+import com.android.tradefed.device.DeviceNotAvailableException;
+import com.android.tradefed.device.IDeviceManager;
+import com.android.tradefed.device.ITestDevice;
+import com.android.tradefed.invoker.ExecutionFiles.FilesKey;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.log.LogUtil.CLog;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.IRunUtil;
+import com.android.tradefed.util.RunUtil;
+
+import com.google.common.annotations.VisibleForTesting;
+
+import java.io.File;
+import java.io.FileNotFoundException;
+import java.time.Duration;
+import java.util.ArrayList;
+import java.util.List;
+import java.util.stream.Collectors;
+
+import javax.annotation.Nullable;
+
+/**
+ * Target preparer which executes a script before running a test. The script can reference the
+ * device's serial number using the SERIAL environment variable.
+ */
+@OptionClass(alias = "run-host-script")
+public class RunHostScriptTargetPreparer extends BaseTargetPreparer {
+
+    @Option(name = "script-file", description = "Path to the script to execute.")
+    private String mScriptPath = null;
+
+    @Option(name = "work-dir", description = "Working directory to use when executing script.")
+    private File mWorkDir = null;
+
+    @Option(name = "script-timeout", description = "Script execution timeout.")
+    private Duration mTimeout = Duration.ofMinutes(1L);
+
+    private IRunUtil mRunUtil;
+
+    @Override
+    public void setUp(TestInformation testInfo)
+            throws TargetSetupError, BuildError, DeviceNotAvailableException {
+        if (mScriptPath == null) {
+            CLog.w("No script to execute.");
+            return;
+        }
+        ITestDevice device = testInfo.getDevice();
+
+        // Find script and verify it exists and is executable
+        File scriptFile = findScriptFile(testInfo);
+        if (scriptFile == null || !scriptFile.isFile()) {
+            throw new TargetSetupError(
+                    String.format("File %s not found.", mScriptPath), device.getDeviceDescriptor());
+        }
+        if (!scriptFile.canExecute()) {
+            scriptFile.setExecutable(true);
+        }
+
+        // Set working directory and environment variables
+        getRunUtil().setWorkingDir(mWorkDir);
+        getRunUtil().setEnvVariable("SERIAL", device.getSerialNumber());
+        setPathVariable(testInfo);
+
+        // Execute script and handle result
+        CommandResult result =
+                getRunUtil().runTimedCmd(mTimeout.toMillis(), scriptFile.getAbsolutePath());
+        switch (result.getStatus()) {
+            case SUCCESS:
+                CLog.i("Script executed successfully, stdout = [%s].", result.getStdout());
+                break;
+            case FAILED:
+                throw new TargetSetupError(
+                        String.format(
+                                "Script execution failed, stdout = [%s], stderr = [%s].",
+                                result.getStdout(), result.getStderr()),
+                        device.getDeviceDescriptor());
+            case TIMED_OUT:
+                throw new TargetSetupError(
+                        "Script execution timed out.", device.getDeviceDescriptor());
+            case EXCEPTION:
+                throw new TargetSetupError(
+                        String.format(
+                                "Exception during script execution, stdout = [%s], stderr = [%s].",
+                                result.getStdout(), result.getStderr()),
+                        device.getDeviceDescriptor());
+        }
+    }
+
+    /** @return {@link IRunUtil} instance to use */
+    @VisibleForTesting
+    IRunUtil getRunUtil() {
+        if (mRunUtil == null) {
+            mRunUtil = new RunUtil();
+        }
+        return mRunUtil;
+    }
+
+    /** @return {@link IDeviceManager} instance used to fetch the configured adb/fastboot paths */
+    @VisibleForTesting
+    IDeviceManager getDeviceManager() {
+        return GlobalConfiguration.getDeviceManagerInstance();
+    }
+
+    /** Find the script file to execute. */
+    @Nullable
+    private File findScriptFile(TestInformation testInfo) {
+        File scriptFile = new File(mScriptPath);
+        if (scriptFile.isAbsolute()) {
+            return scriptFile;
+        }
+        // Try to find the script in the working directory if it was set
+        if (mWorkDir != null) {
+            scriptFile = new File(mWorkDir, mScriptPath);
+            if (scriptFile.isFile()) {
+                return scriptFile;
+            }
+        }
+        // Otherwise, search for it in the test information
+        try {
+            return testInfo.getDependencyFile(mScriptPath, false);
+        } catch (FileNotFoundException e) {
+            return null;
+        }
+    }
+
+    /** Update $PATH if necessary to use consistent adb and fastboot binaries. */
+    private void setPathVariable(TestInformation testInfo) {
+        List<String> paths = new ArrayList<>();
+        // Use the test's adb binary or the globally defined one
+        File adbBinary = testInfo.executionFiles().get(FilesKey.ADB_BINARY);
+        if (adbBinary == null) {
+            String adbPath = getDeviceManager().getAdbPath();
+            if (!adbPath.equals("adb")) { // ignore default binary
+                adbBinary = new File(adbPath);
+            }
+        }
+        if (adbBinary != null && adbBinary.isFile()) {
+            paths.add(adbBinary.getParentFile().getAbsolutePath());
+        }
+        // Use the globally defined fastboot binary
+        String fastbootPath = getDeviceManager().getFastbootPath();
+        if (!fastbootPath.equals("fastboot")) { // ignore default binary
+            File fastbootBinary = new File(fastbootPath);
+            if (fastbootBinary.isFile()) {
+                paths.add(fastbootBinary.getParentFile().getAbsolutePath());
+            }
+        }
+        // Prepend additional paths to the PATH variable
+        if (!paths.isEmpty()) {
+            String separator = System.getProperty("path.separator");
+            paths.add(System.getenv("PATH"));
+            String path = paths.stream().distinct().collect(Collectors.joining(separator));
+            CLog.d("Using updated $PATH: %s", path);
+            getRunUtil().setEnvVariable("PATH", path);
+        }
+    }
+}
diff --git a/tests/src/com/android/tradefed/UnitTests.java b/tests/src/com/android/tradefed/UnitTests.java
index 45594a4..101caa8 100644
--- a/tests/src/com/android/tradefed/UnitTests.java
+++ b/tests/src/com/android/tradefed/UnitTests.java
@@ -235,6 +235,7 @@
 import com.android.tradefed.targetprep.RootTargetPreparerTest;
 import com.android.tradefed.targetprep.RunCommandTargetPreparerTest;
 import com.android.tradefed.targetprep.RunHostCommandTargetPreparerTest;
+import com.android.tradefed.targetprep.RunHostScriptTargetPreparerTest;
 import com.android.tradefed.targetprep.StopServicesSetupTest;
 import com.android.tradefed.targetprep.SwitchUserTargetPreparerTest;
 import com.android.tradefed.targetprep.SystemUpdaterDeviceFlasherTest;
@@ -674,6 +675,7 @@
     RootTargetPreparerTest.class,
     RunCommandTargetPreparerTest.class,
     RunHostCommandTargetPreparerTest.class,
+    RunHostScriptTargetPreparerTest.class,
     StopServicesSetupTest.class,
     SystemUpdaterDeviceFlasherTest.class,
     TargetSetupErrorTest.class,
diff --git a/tests/src/com/android/tradefed/targetprep/RunHostScriptTargetPreparerTest.java b/tests/src/com/android/tradefed/targetprep/RunHostScriptTargetPreparerTest.java
new file mode 100644
index 0000000..ee43e22
--- /dev/null
+++ b/tests/src/com/android/tradefed/targetprep/RunHostScriptTargetPreparerTest.java
@@ -0,0 +1,172 @@
+/*
+ * 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.tradefed.targetprep;
+
+import static org.junit.Assert.assertTrue;
+import static org.mockito.ArgumentMatchers.any;
+import static org.mockito.ArgumentMatchers.anyBoolean;
+import static org.mockito.ArgumentMatchers.anyLong;
+import static org.mockito.ArgumentMatchers.eq;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
+
+import com.android.tradefed.config.ConfigurationException;
+import com.android.tradefed.config.OptionSetter;
+import com.android.tradefed.device.IDeviceManager;
+import com.android.tradefed.invoker.ExecutionFiles.FilesKey;
+import com.android.tradefed.invoker.TestInformation;
+import com.android.tradefed.util.CommandResult;
+import com.android.tradefed.util.CommandStatus;
+import com.android.tradefed.util.FileUtil;
+import com.android.tradefed.util.IRunUtil;
+
+import org.junit.After;
+import org.junit.Before;
+import org.junit.Rule;
+import org.junit.Test;
+import org.junit.runner.RunWith;
+import org.junit.runners.JUnit4;
+import org.mockito.Answers;
+import org.mockito.Mock;
+import org.mockito.junit.MockitoJUnit;
+import org.mockito.junit.MockitoRule;
+
+import java.io.File;
+import java.io.IOException;
+import java.nio.file.Files;
+import java.nio.file.Path;
+
+/** Unit test for {@link RunHostScriptTargetPreparer}. */
+@RunWith(JUnit4.class)
+public final class RunHostScriptTargetPreparerTest {
+
+    private static final String DEVICE_SERIAL = "DEVICE_SERIAL";
+
+    @Rule public final MockitoRule mockito = MockitoJUnit.rule();
+
+    @Mock(answer = Answers.RETURNS_DEEP_STUBS)
+    private TestInformation mTestInfo;
+
+    @Mock private IRunUtil mRunUtil;
+    @Mock private IDeviceManager mDeviceManager;
+    private RunHostScriptTargetPreparer mPreparer;
+    private OptionSetter mOptionSetter;
+    private File mWorkDir;
+    private File mScriptFile;
+
+    @Before
+    public void setUp() throws IOException, ConfigurationException {
+        // Initialize preparer and test information
+        mPreparer =
+                new RunHostScriptTargetPreparer() {
+                    @Override
+                    IRunUtil getRunUtil() {
+                        return mRunUtil;
+                    }
+
+                    @Override
+                    IDeviceManager getDeviceManager() {
+                        return mDeviceManager;
+                    }
+                };
+        mOptionSetter = new OptionSetter(mPreparer);
+        when(mTestInfo.getDevice().getSerialNumber()).thenReturn(DEVICE_SERIAL);
+        when(mTestInfo.executionFiles().get(any(FilesKey.class))).thenReturn(null);
+
+        // Create temporary working directory and script file
+        mWorkDir = FileUtil.createTempDir(this.getClass().getSimpleName());
+        mScriptFile = File.createTempFile("script", ".sh", mWorkDir);
+
+        // Default to default adb/fastboot paths
+        when(mDeviceManager.getAdbPath()).thenReturn("adb");
+        when(mDeviceManager.getFastbootPath()).thenReturn("fastboot");
+
+        // Default to successful execution
+        CommandResult result = new CommandResult(CommandStatus.SUCCESS);
+        when(mRunUtil.runTimedCmd(anyLong(), any())).thenReturn(result);
+    }
+
+    @After
+    public void tearDown() {
+        FileUtil.recursiveDelete(mWorkDir);
+    }
+
+    @Test
+    public void testSetUp() throws Exception {
+        mOptionSetter.setOptionValue("script-file", mScriptFile.getAbsolutePath());
+        mOptionSetter.setOptionValue("script-timeout", "10");
+        // Verify environment, timeout, and script path
+        mPreparer.setUp(mTestInfo);
+        verify(mRunUtil).setEnvVariable("SERIAL", DEVICE_SERIAL);
+        verify(mRunUtil, never()).setEnvVariable(eq("PATH"), any()); // uses default PATH
+        verify(mRunUtil).runTimedCmd(10L, mScriptFile.getAbsolutePath());
+        // Verify that script is executable
+        assertTrue(mScriptFile.canExecute());
+    }
+
+    @Test
+    public void testSetUp_workingDir() throws Exception {
+        mOptionSetter.setOptionValue("work-dir", mWorkDir.getAbsolutePath());
+        mOptionSetter.setOptionValue("script-file", mScriptFile.getName()); // relative
+        // Verify that the working directory is set and script's path is resolved
+        mPreparer.setUp(mTestInfo);
+        verify(mRunUtil).setWorkingDir(mWorkDir);
+        verify(mRunUtil).runTimedCmd(anyLong(), eq(mScriptFile.getAbsolutePath()));
+    }
+
+    @Test
+    public void testSetUp_findFile() throws Exception {
+        mOptionSetter.setOptionValue("script-file", mScriptFile.getName()); // relative
+        when(mTestInfo.getDependencyFile(any(), anyBoolean())).thenReturn(mScriptFile);
+        // Verify that the script is found in the test information
+        mPreparer.setUp(mTestInfo);
+        verify(mRunUtil).runTimedCmd(anyLong(), eq(mScriptFile.getAbsolutePath()));
+    }
+
+    @Test(expected = TargetSetupError.class)
+    public void testSetUp_fileNotFound() throws Exception {
+        mOptionSetter.setOptionValue("script-file", "unknown.sh");
+        mPreparer.setUp(mTestInfo);
+    }
+
+    @Test(expected = TargetSetupError.class)
+    public void testSetUp_executionError() throws Exception {
+        mOptionSetter.setOptionValue("script-file", mScriptFile.getAbsolutePath());
+        CommandResult result = new CommandResult(CommandStatus.FAILED);
+        when(mRunUtil.runTimedCmd(anyLong(), any())).thenReturn(result);
+        mPreparer.setUp(mTestInfo);
+    }
+
+    @Test
+    public void testSetUp_pathVariable() throws Exception {
+        mOptionSetter.setOptionValue("script-file", mScriptFile.getAbsolutePath());
+        // Create and set dummy adb binary
+        Path adbDir = Files.createTempDirectory(mWorkDir.toPath(), "adb");
+        File adbBinary = File.createTempFile("adb", ".sh", adbDir.toFile());
+        when(mTestInfo.executionFiles().get(eq(FilesKey.ADB_BINARY))).thenReturn(adbBinary);
+        // Create and set dummy fastboot binary
+        Path fastbootDir = Files.createTempDirectory(mWorkDir.toPath(), "fastboot");
+        File fastbootBinary = File.createTempFile("fastboot", ".sh", fastbootDir.toFile());
+        when(mDeviceManager.getFastbootPath()).thenReturn(fastbootBinary.getAbsolutePath());
+        // Verify that binary paths were prepended to the path variable
+        String separator = System.getProperty("path.separator");
+        String expectedPath = adbDir + separator + fastbootDir + separator + System.getenv("PATH");
+        mPreparer.setUp(mTestInfo);
+        verify(mRunUtil).setEnvVariable("PATH", expectedPath);
+    }
+}