Merge "Adevice update integration test; keep symlinks" into main
diff --git a/adevice/integration_tests/build_adevice_integration_tests.sh b/adevice/integration_tests/build_adevice_integration_tests.sh
new file mode 100755
index 0000000..d706f97
--- /dev/null
+++ b/adevice/integration_tests/build_adevice_integration_tests.sh
@@ -0,0 +1,101 @@
+#!/usr/bin/env bash
+
+# Copyright (C) 2024 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 script is dedicated to build the atest integration test in build server.
+# To run the test locally, it's recommended to invoke the test via
+# `atest atest_integration_tests` or `python atest_integration_tests.py`.
+# For usage examples please run `python atest_integration_tests.py --help`.
+
+set -eo pipefail
+set -x
+
+# Legacy support for the deprecated argument --artifacts_dir and directory name
+for ((i=1; i<=$#; i++)); do
+  arg="${@:$i:1}"
+  case "$arg" in
+    --artifacts_dir)
+      export SNAPSHOT_STORAGE_TAR_PATH="${@:$i+1:1}"/adevice_integration_tests.tar
+      i=$((i+1))
+      ;;
+    *)
+      filtered_args+=("$arg")
+      ;;
+  esac
+done
+
+if [ -n "${DIST_DIR}" ] ; then
+  export SNAPSHOT_STORAGE_TAR_PATH=${DIST_DIR}/adevice_integration_tests.tar
+fi
+
+function get_build_var()
+{
+  (${PWD}/build/soong/soong_ui.bash --dumpvar-mode --abs $1)
+}
+
+if [ ! -n "${ANDROID_BUILD_TOP}" ] ; then
+  export ANDROID_BUILD_TOP=${PWD}
+fi
+
+# Uncomment the following if verifying locally without running envsetup
+# if [ ! -n "${TARGET_PRODUCT}" ] || [ ! -n "${TARGET_BUILD_VARIANT}" ] ; then
+#   export \
+#     TARGET_PRODUCT=aosp_x86_64 \
+#     TARGET_BUILD_VARIANT=userdebug \
+#     TARGET_RELEASE="trunk_staging"
+# fi
+
+# ANDROID_BUILD_TOP is deprecated, so don't use it throughout the script.
+# But if someone sets it, we'll respect it.
+cd ${ANDROID_BUILD_TOP:-.}
+
+if [ ! -n "${ANDROID_PRODUCT_OUT}" ] ; then
+  export ANDROID_PRODUCT_OUT=$(get_build_var PRODUCT_OUT)
+fi
+
+if [ ! -n "${OUT}" ] ; then
+  export OUT=$ANDROID_PRODUCT_OUT
+fi
+
+if [ ! -n "${ANDROID_HOST_OUT}" ] ; then
+  export ANDROID_HOST_OUT=$(get_build_var HOST_OUT)
+fi
+
+if [ ! -n "${ANDROID_TARGET_OUT_TESTCASES}" ] ; then
+  export ANDROID_TARGET_OUT_TESTCASES=$(get_build_var TARGET_OUT_TESTCASES)
+fi
+
+if [ ! -n "${HOST_OUT_TESTCASES}" ] ; then
+  export HOST_OUT_TESTCASES=$(get_build_var HOST_OUT_TESTCASES)
+  export ANDROID_HOST_OUT_TESTCASES=$HOST_OUT_TESTCASES
+fi
+
+if [ ! -n "${ANDROID_JAVA_HOME}" ] ; then
+  export ANDROID_JAVA_HOME=$(get_build_var ANDROID_JAVA_HOME)
+  export JAVA_HOME=$(get_build_var JAVA_HOME)
+fi
+
+export REMOTE_AVD=true
+
+# Use the versioned Python binaries in prebuilts/ for a reproducible
+# build with minimal reliance on host tools. Add build/bazel/bin to PATH since
+# atest needs 'b'
+export PATH=${PWD}/prebuilts/build-tools/path/linux-x86:${PWD}/build/bazel/bin:${PWD}/out/host/linux-x86/bin/:${PATH}
+
+# Use the versioned Java binaries in prebuilds/ for a reproducible
+# build with minimal reliance on host tools.
+export PATH=${ANDROID_JAVA_HOME}/bin:${PATH}
+
+python3 tools/asuite/atest/integration_tests/adevice_integration_tests.py "${filtered_args[@]}" --build --tar_snapshot
diff --git a/atest/integration_tests/Android.bp b/atest/integration_tests/Android.bp
index 2b90e87..08bf8ce 100644
--- a/atest/integration_tests/Android.bp
+++ b/atest/integration_tests/Android.bp
@@ -103,6 +103,21 @@
 }
 
 python_test_host {
+    name: "adevice_integration_tests",
+    srcs: [
+        "adevice_integration_tests.py",
+        "adevice_command_success_tests.py",
+    ],
+    test_config_template: ":atest_integration_test_config_template",
+    test_options: {
+        unit_test: false,
+    },
+    defaults: [
+        "atest_integration_test_defaults",
+    ],
+}
+
+python_test_host {
     name: "atest_command_success_tests",
     srcs: [
         "atest_command_success_tests.py",
@@ -117,6 +132,20 @@
 }
 
 python_test_host {
+    name: "adevice_command_success_tests",
+    srcs: [
+        "adevice_command_success_tests.py",
+    ],
+    test_config_template: "split_build_test_script_config.xml",
+    test_options: {
+        unit_test: false,
+    },
+    defaults: [
+        "atest_integration_test_defaults",
+    ],
+}
+
+python_test_host {
     name: "atest_command_verification_tests",
     srcs: [
         "atest_command_verification_tests.py",
diff --git a/atest/integration_tests/adevice_command_success_tests.py b/atest/integration_tests/adevice_command_success_tests.py
new file mode 100644
index 0000000..f7dd9ca
--- /dev/null
+++ b/atest/integration_tests/adevice_command_success_tests.py
@@ -0,0 +1,77 @@
+#!/usr/bin/env python3
+#
+# Copyright 2024, 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 to check if adevice commands were executed with success exit codes."""
+
+import atest_integration_test
+
+
+class AdeviceCommandSuccessTests(atest_integration_test.AtestTestCase):
+  """Test whether the adevice commands run with success exit codes."""
+
+  def setUp(self):
+    super().setUp()
+    self._default_snapshot_include_paths += [
+        '$OUT_DIR/combined-*.ninja',
+        '$OUT_DIR/build-*.ninja',
+        '$OUT_DIR/soong/*.ninja',
+        '$OUT_DIR/target/',
+    ]
+
+    self._default_snapshot_env_keys += ['TARGET_PRODUCT', 'ANDROID_BUILD_TOP']
+    self._default_snapshot_exclude_paths = []
+
+  def test_status(self):
+    """Test if status command runs successfully across periodic repo syncs."""
+    self._verify_adevice_command_success('adevice status')
+
+  def test_update(self):
+    """Test if update command runs successfully across periodic repo syncs."""
+    self._verify_adevice_command_success(
+        'adevice update --max-allowed-changes=6000'
+    )
+
+  def _verify_adevice_command_success(self, test_cmd: str):
+    """Verifies whether an adevice command run completed with exit code 0."""
+    script = self.create_atest_script()
+
+    def build_step(
+        step_in: atest_integration_test.StepInput,
+    ) -> atest_integration_test.StepOutput:
+      self._run_shell_command(
+          'build/soong/soong_ui.bash --make-mode'.split(),
+          env=step_in.get_env(),
+          cwd=step_in.get_repo_root(),
+          print_output=True,
+      ).check_returncode()
+      return self.create_step_output()
+
+    def test_step(step_in: atest_integration_test.StepInput) -> None:
+      self._run_shell_command(
+          test_cmd.split(),
+          env=step_in.get_env(),
+          cwd=step_in.get_repo_root(),
+          print_output=True,
+      ).check_returncode()
+      print(step_in.get_env())
+
+    script.add_build_step(build_step)
+    script.add_test_step(test_step)
+    script.run()
+
+
+if __name__ == '__main__':
+  atest_integration_test.main()
diff --git a/atest/integration_tests/adevice_integration_tests.py b/atest/integration_tests/adevice_integration_tests.py
new file mode 100644
index 0000000..ad5a69b
--- /dev/null
+++ b/atest/integration_tests/adevice_integration_tests.py
@@ -0,0 +1,25 @@
+#!/usr/bin/env python3
+#
+# Copyright 2024, 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.
+
+"""A collection of all integration test cases for adevice."""
+
+# pylint: disable=wildcard-import
+from adevice_command_success_tests import *
+import atest_integration_test
+
+
+if __name__ == '__main__':
+  atest_integration_test.main()
diff --git a/atest/integration_tests/snapshot.py b/atest/integration_tests/snapshot.py
index 55cedac..53d164f 100644
--- a/atest/integration_tests/snapshot.py
+++ b/atest/integration_tests/snapshot.py
@@ -230,6 +230,7 @@
       permissions: int,
       symlink_target: str,
       is_directory: bool,
+      is_target_in_workspace: bool = False,
   ):
     self.path = path
     self.timestamp = timestamp
@@ -237,6 +238,7 @@
     self.permissions = permissions
     self.symlink_target = symlink_target
     self.is_directory = is_directory
+    self.is_target_in_workspace = is_target_in_workspace
 
 
 class _BlobStore:
@@ -429,16 +431,19 @@
       )
 
     def process_link(path: pathlib.Path) -> None:
-      symlink_target = path.resolve()
+      relative_path = path.relative_to(root_path).as_posix()
+      symlink_target = path.readlink()
+      is_target_in_workspace = False
       if symlink_target.is_relative_to(root_path):
         symlink_target = symlink_target.relative_to(root_path)
-      relative_path = path.relative_to(root_path).as_posix()
+        is_target_in_workspace = True
       file_infos[relative_path] = _FileInfo(
           relative_path,
           timestamp=None,
           content_hash=None,
           permissions=None,
           symlink_target=symlink_target.as_posix(),
+          is_target_in_workspace=is_target_in_workspace,
           is_directory=False,
       )
 
@@ -569,11 +574,10 @@
       if self._is_excluded(file_path.as_posix(), exclude_paths):
         continue
       if file_info.symlink_target:
-        target = (
-            file_info.symlink_target
-            if os.path.isabs(file_info.symlink_target)
-            else pathlib.Path(root_path).joinpath(file_info.symlink_target)
-        )
+        file_path.parent.mkdir(parents=True, exist_ok=True)
+        target = file_info.symlink_target
+        if bool(file_info.is_target_in_workspace):
+          target = pathlib.Path(root_path).joinpath(target)
         file_path.parent.mkdir(parents=True, exist_ok=True)
         file_path.symlink_to(target)
         continue
diff --git a/atest/integration_tests/snapshot_unittest.py b/atest/integration_tests/snapshot_unittest.py
index 75e0479..1c9a4db 100644
--- a/atest/integration_tests/snapshot_unittest.py
+++ b/atest/integration_tests/snapshot_unittest.py
@@ -199,15 +199,15 @@
     snapshot_name = 'a_snapshot_name'
     restore_dir = self.temp_dir / 'restore'
     link_file_name = 'link'
-    target_file_name = 'non-existent-path'
+    target_file_name = pathlib.Path('non-existent-path')
     workspace.joinpath(link_file_name).symlink_to(target_file_name)
     snapshot.take_snapshot(snapshot_name, workspace, ['*'])
 
     snapshot.restore_snapshot(snapshot_name, restore_dir)
 
     self.assertEqual(
-        restore_dir.joinpath(link_file_name).resolve(),
-        restore_dir / target_file_name,
+        restore_dir.joinpath(link_file_name).readlink(),
+        target_file_name,
     )
 
   def test_restore_snapshot_preserve_dangling_absolute_links(self):