| import enum |
| import filecmp |
| import os |
| import platform |
| import shutil |
| import stat |
| import subprocess |
| import tempfile |
| import unittest |
| import zipfile |
| |
| UPDATER_PATH: str = "tools/adt/idea/studio/" |
| |
| |
| @enum.unique |
| class Platform(str, enum.Enum): |
| """Enum representing the platforms for which we produce .zip files.""" |
| |
| LINUX = "linux" |
| WIN = "win" |
| MAC = "mac" |
| MAC_ARM = "mac_arm" |
| |
| def get_path_to_zip(self) -> str: |
| return "tools/adt/idea/studio/android-studio.%s.zip" % self.value |
| |
| |
| def get_current_platform() -> Platform: |
| major_win_ver = platform.win32_ver()[0] |
| if major_win_ver: |
| return Platform.WIN |
| |
| major_mac_ver = platform.mac_ver()[0] |
| if major_mac_ver: |
| is_arm = platform.processor() == "arm" |
| return Platform.MAC_ARM if is_arm else Platform.MAC |
| |
| return Platform.LINUX |
| |
| |
| class UpdaterTests(unittest.TestCase): |
| """Performs basic tests on the updater/patcher.""" |
| |
| def setUp(self): |
| super().setUp() |
| self.updater_script_path: str = os.path.join(UPDATER_PATH, "updater") |
| |
| def fail_dircmp(self, dircmp: filecmp.dircmp, msg: str): |
| """Fails the test using dircmp to form the full message. |
| |
| Args: |
| dircmp: the dircmp causing the test failure. |
| msg: additional information about the failure. |
| |
| Raises: |
| AssertionError: this is always raised. |
| """ |
| self.fail( |
| "Difference found comparing \"%s\" (left) and \"%s\" (right): %s" % |
| (dircmp.left, dircmp.right, msg)) |
| |
| def fail_on_dircmp_diffs(self, dircmp: filecmp.dircmp): |
| """Examines dircmp for differences and fails the test if any are found. |
| |
| If the contents of two files are identical but one is executable and the |
| other isn't, dircmp will still consider them to be the same. The only user |
| scenario this would impact is running files from a command-line. |
| |
| Args: |
| dircmp: the dircmp to examine for differences. |
| |
| Raises: |
| AssertionError: the directories differ. |
| """ |
| if dircmp.left_only: |
| self.fail_dircmp(dircmp, |
| "files found only in left dir: %s" % dircmp.left_only) |
| if dircmp.right_only: |
| self.fail_dircmp(dircmp, |
| "files found only in right dir: %s" % dircmp.right_only) |
| if dircmp.common_funny: |
| # Note: differing symlinks fall into this category of "common_funny". |
| self.fail_dircmp( |
| dircmp, "files found which differ in types or os.stat() results: %s" % |
| dircmp.common_funny) |
| if dircmp.funny_files: |
| self.fail_dircmp(dircmp, |
| "files could not be compared: %s" % dircmp.funny_files) |
| if dircmp.diff_files: |
| self.fail_dircmp(dircmp, |
| "files whose contents differ: %s" % dircmp.diff_files) |
| |
| # Recurse through subdirectories. |
| for sub_dircmp in dircmp.subdirs.values(): |
| self.fail_on_dircmp_diffs(sub_dircmp) |
| |
| def validate_patcher(self, old_zip: str, new_zip: str): |
| """Ensure that we can patch one platform to another. |
| |
| The patcher should be able to take any arbitrary inputs "old" and "new" and |
| produce a patch, P, which represents the diff from "old" → "new". This |
| method generates P, then ensures that P(old) == new. |
| |
| Args: |
| old_zip: a relative or absolute path to the "old" zip file (see |
| description above for "old" vs. "new" terminology). |
| new_zip: a relative or absolute path to the "new" zip file |
| |
| Raises: |
| AssertionError: some part of the process failed. |
| """ |
| print("Validating that the patcher works on \"%s\" -> \"%s\"" % |
| (old_zip, new_zip)) |
| |
| with tempfile.TemporaryDirectory() as tempdir: |
| old_version_description = "Old version" |
| new_version_description = "New version" |
| |
| old_folder_path = os.path.join(tempdir, "old") |
| new_folder_path = os.path.join(tempdir, "new") |
| patch_folder_path = os.path.join(tempdir, "patch") |
| |
| for folder_path in [old_folder_path, new_folder_path, patch_folder_path]: |
| os.makedirs(folder_path) |
| |
| print("Extracting .zip files") |
| with zipfile.ZipFile(old_zip, "r") as zip_ref: |
| zip_ref.extractall(old_folder_path) |
| with zipfile.ZipFile(new_zip, "r") as zip_ref: |
| zip_ref.extractall(new_folder_path) |
| |
| # Before continuing, ensure that the input directories differ, that way we |
| # know the patcher is actually removing differences. |
| failed_as_expected = False |
| dircmp = filecmp.dircmp(old_folder_path, new_folder_path) |
| try: |
| self.fail_on_dircmp_diffs(dircmp) |
| except AssertionError: |
| failed_as_expected = True |
| if not failed_as_expected: |
| self.fail( |
| "The extracted .zip files contain no differences to begin with.") |
| |
| patch_file_path = os.path.join(tempdir, "patch.jar") |
| |
| print("Running updater") |
| args = [ |
| self.updater_script_path, |
| "--wrapper_script_flag=--jvm_flag=-Xmx8G", # 233245811 |
| "create", |
| old_version_description, |
| new_version_description, |
| old_folder_path, |
| new_folder_path, |
| patch_file_path, |
| |
| # Treat .zip and .jar files as binary files. Without this, it's |
| # possible that the resulting patch may use a different compression |
| # strategy from the original files. When that happens, the .zip or |
| # .jar files themselves will appear different despite containing |
| # equivalent contents. |
| "--zip_as_binary", |
| |
| # The "strict" flag makes it so that the created patch contains extra |
| # information to fully validate an installation. |
| # |
| # This isn't strictly necessary for the test to pass, but it matches |
| # how Android Studio produces patches for production uses. |
| "--strict", |
| ] |
| process = subprocess.Popen( |
| args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| stdout, stderr = process.communicate() |
| print("stdout: %s" % stdout) |
| print("stderr: %s" % stderr) |
| return_code = process.returncode |
| if return_code != 0: |
| self.fail("Running the updater (args=%s) failed. Return code: %s" % |
| (str(args), return_code)) |
| |
| # We'll use the bundled Java for running the patcher, so make a copy of the JBR first |
| if get_current_platform() in [Platform.MAC, Platform.MAC_ARM]: |
| jbr_path = "Contents/jbr" |
| java_path = "Contents/Home/bin/java" |
| elif get_current_platform() == Platform.WIN: |
| jbr_path = "jbr" |
| java_path = "bin\\java.exe" |
| else: |
| jbr_path = "jbr" |
| java_path = "bin/java" |
| |
| jbr_dest = os.path.join(tempdir, "jbr") |
| jbr_src = os.path.join(old_folder_path, os.listdir(old_folder_path)[0], jbr_path) |
| shutil.copytree(jbr_src, jbr_dest) |
| |
| jbr_java = os.path.join(jbr_dest, java_path) |
| # ZipFile extraction didn't preserve permissions, set executable bit back on. |
| os.chmod(jbr_java, stat.S_IEXEC) |
| |
| # The patcher runs in-place, meaning the old folder will have its contents |
| # directly modified rather than producing a copy. |
| print("Running the patcher") |
| args = [ |
| jbr_java, |
| "-jar", |
| patch_file_path, |
| old_folder_path, |
| ] |
| process = subprocess.Popen( |
| args, stdout=subprocess.PIPE, stderr=subprocess.PIPE) |
| stdout, stderr = process.communicate() |
| print("stdout: %s" % stdout) |
| print("stderr: %s" % stderr) |
| return_code = process.returncode |
| if return_code != 0: |
| self.fail("Running the patcher (args=%s) failed. Return code: %s" % |
| (str(args), return_code)) |
| |
| print("Comparing the results") |
| dircmp = filecmp.dircmp(old_folder_path, new_folder_path) |
| self.fail_on_dircmp_diffs(dircmp) |
| |
| def test_patch_platforms(self): |
| current_platform = get_current_platform() |
| |
| if current_platform == Platform.WIN: |
| self.validate_patcher(Platform.WIN.get_path_to_zip(), |
| Platform.MAC.get_path_to_zip()) |
| else: |
| self.validate_patcher(current_platform.get_path_to_zip(), |
| Platform.WIN.get_path_to_zip()) |
| |
| |
| if __name__ == "__main__": |
| unittest.main() |