diff --git a/emu/android_release_zip.py b/emu/android_release_zip.py index a486061..91683c8 100644 --- a/emu/android_release_zip.py +++ b/emu/android_release_zip.py @@ -114,6 +114,15 @@ def is_emulator(self) -> bool: """True if this zip file contains the android emulator.""" return "Android Emulator" in self.description() + def is_preview_emulator(self) -> bool: + """True if this zip contains the Emulator Preview rather than the regular emulator. + + The preview requires features on the system images that are right now + only available in the API 37+ system images. Its Pkg.Desc is + "Android Emulator (Preview)". + """ + return self.is_emulator() and "(Preview)" in (self.description() or "") + def copy(self, destination: str) -> str: """Copy the zipfile to the given destination. diff --git a/emu/containers/emulator_container.py b/emu/containers/emulator_container.py index 7227e23..3580cae 100644 --- a/emu/containers/emulator_container.py +++ b/emu/containers/emulator_container.py @@ -88,7 +88,14 @@ def write(self, dest): ) writer.write_template( - "launch-emulator.sh", {"extra": self.extra, "version": emu.__version__} + "launch-emulator.sh", + { + "extra": self.extra, + "version": emu.__version__, + # The Emulator Preview runs on a much newer qemu that rejects + # some of the passthrough arguments the regular emulator takes. + "preview_emulator": self.emulator_zip.is_preview_emulator(), + }, ) writer.write_template("default.pa", {}) diff --git a/emu/emu_docker.py b/emu/emu_docker.py index ab7929d..38aed07 100644 --- a/emu/emu_docker.py +++ b/emu/emu_docker.py @@ -15,7 +15,6 @@ """Minimal dependency script to create a Dockerfile for a particular combination of emulator and system image.""" import argparse -import itertools import logging import os import re @@ -25,6 +24,7 @@ import click import colorlog import emu.emu_downloads_menu as emu_downloads_menu +from emu.android_release_zip import AndroidReleaseZip from emu.cloud_build import cloud_build from emu.containers.emulator_container import EmulatorContainer from emu.containers.system_image_container import SystemImageContainer @@ -63,6 +63,34 @@ def metrics_config(args): return cfg +def _api_major(sys_docker): + """The integer api level of the system image in the given container.""" + if sys_docker.system_image_info is not None: + return sys_docker.system_image_info.api_major + match = re.match(r"\d+", sys_docker.system_image_zip.api() or "") + return int(match.group()) if match else 0 + + +def _check_emulator_supports_image(emulator, sys_docker): + """Refuse an emulator/system-image pairing that cannot work. + + The Emulator Preview requires features on the system images that are right + now only available in the API 37+ system images, so it is rejected below + MIN_API_PREVIEW_EMULATOR. Checked from the emulator zip itself so it also + covers locally supplied files, not just menu selections. + """ + if not AndroidReleaseZip(emulator).is_preview_emulator(): + return + api_major = _api_major(sys_docker) + if api_major < emu_downloads_menu.MIN_API_PREVIEW_EMULATOR: + raise Exception( + f"The Emulator Preview requires an API " + f"{emu_downloads_menu.MIN_API_PREVIEW_EMULATOR}+ system image, " + f"but {os.path.basename(str(emulator))} was paired with API {api_major}. " + f"Use the regular emulator for this image." + ) + + def create_docker_image(args): """Create a directory containing all the necessary ingredients to construct a docker image. @@ -73,18 +101,32 @@ def create_docker_image(args): if not os.path.exists(imgzip[0]): imgzip = emu_downloads_menu.find_image(imgzip[0]) - emuzip = [args.emuzip] - if emuzip[0] in ["stable", "canary", "all"]: - emuzip = [x.download() for x in emu_downloads_menu.find_emulator(emuzip[0])] - elif re.match(r"\d+", emuzip[0]): - # We must be looking for a build id - logging.info("Treating %s as a build id", emuzip[0]) - emuzip = [emu_downloads_menu.download_build(emuzip[0])] + # A channel resolves to a different emulator depending on the system image + # (API 37+ images get the Emulator Preview), so it is looked up per image. + emuzip = {} + + def emulators_for(sys_docker): + if args.emuzip in ["stable", "beta", "dev", "canary", "all"]: + api_major = _api_major(sys_docker) + preview = api_major >= emu_downloads_menu.MIN_API_PREVIEW_EMULATOR + if preview not in emuzip: + emuzip[preview] = [ + x.download() + for x in emu_downloads_menu.find_emulator(args.emuzip, api_major) + ] + return emuzip[preview] + if None not in emuzip: + emuzip[None] = [args.emuzip] + if re.match(r"\d+", args.emuzip): + # We must be looking for a build id + logging.info("Treating %s as a build id", args.emuzip) + emuzip[None] = [emu_downloads_menu.download_build(args.emuzip)] + return emuzip[None] devices = [] logging.info("Using repo %s", args.repo) - for img, emulator in itertools.product(imgzip, emuzip): - logging.info("Processing %s, %s", img, emulator) + for img in imgzip: + logging.info("Processing %s", img) sys_docker = SystemImageContainer(img, args.repo) if not sys_docker.available() and not sys_docker.can_pull(): sys_docker.build(Path(args.dest) / "sys_img") @@ -102,17 +144,21 @@ def create_docker_image(args): if args.sys: continue - emu_docker = EmulatorContainer( - emulator, sys_docker, args.repo, cfg.collect_metrics(), args.extra, args.name - ) - emu_docker.build(Path(args.dest) / "emulator") + for emulator in emulators_for(sys_docker): + logging.info("Processing %s, %s", img, emulator) + _check_emulator_supports_image(emulator, sys_docker) - if args.start: - emu_docker.launch({"5555/tcp": 5555, "8554/tcp": 8554}) - if args.push: - emu_docker.push() + emu_docker = EmulatorContainer( + emulator, sys_docker, args.repo, cfg.collect_metrics(), args.extra, args.name + ) + emu_docker.build(Path(args.dest) / "emulator") + + if args.start: + emu_docker.launch({"5555/tcp": 5555, "8554/tcp": 8554}) + if args.push: + emu_docker.push() - devices.append(emu_docker) + devices.append(emu_docker) return devices @@ -120,7 +166,7 @@ def create_docker_image(args): def create_docker_image_interactive(args): """Interactively create a docker image by selecting the desired combination from a menu.""" img = emu_downloads_menu.select_image(args.arm) or sys.exit(1) - emulator = emu_downloads_menu.select_emulator() or sys.exit(1) + emulator = emu_downloads_menu.select_emulator(img) or sys.exit(1) cfg = DockerConfig() metrics = False @@ -197,6 +243,8 @@ def main(): create_parser.add_argument( "emuzip", help="Zipfile containing the a publicly released emulator, or (canary|stable|[0-9]+) to use the latest canary, stable, or build id of the emulator to use. " + "For API 37+ system images, canary and stable select the latest Emulator Preview; pass a zipfile to use another emulator. " + "The Emulator Preview requires features on the system images that are right now only available in the API 37+ system images. " "Keep in mind that using a build id can result in downloading an untested pre-release emulator build from the android ci server.", ) create_parser.add_argument( @@ -326,6 +374,8 @@ def main(): dist_parser.add_argument( "emuzip", help="Zipfile containing the a publicly released emulator, or (canary|stable|[0-9]+) to use the latest canary, stable, or build id of the emulator to use. " + "For API 37+ system images, canary and stable select the latest Emulator Preview; pass a zipfile to use another emulator. " + "The Emulator Preview requires features on the system images that are right now only available in the API 37+ system images. " "Keep in mind that using a build id can result in downloading an untested pre-release emulator build from the android ci server.", ) dist_parser.add_argument( diff --git a/emu/emu_downloads_menu.py b/emu/emu_downloads_menu.py index b7caf57..c72c46a 100644 --- a/emu/emu_downloads_menu.py +++ b/emu/emu_downloads_menu.py @@ -39,6 +39,12 @@ EMU_REPOS = ["%s/android/repository/repository2-1.xml" % ANDROID_REPOSITORY] +# The regular emulator is published under the "emulator" path; the Emulator +# Preview under "emulators;latest" and "emulators;". +EMU_PATH = "emulator" +EMU_PREVIEW_PATH_PREFIX = "emulators;" +EMU_PREVIEW_LATEST_PATH = "emulators;latest" + CHANNEL_MAPPING = { "channel-0": "stable", "channel-1": "beta", @@ -77,6 +83,12 @@ MIN_API_I386 = 19 # K MIN_API_X64 = 26 # O +# The Emulator Preview ("emulators;latest" and the pinned "emulators;" +# packages) requires features on the system images that are right now only +# available in the API 37+ system images. It is what those images get by +# default, and it is not offered below that. +MIN_API_PREVIEW_EMULATOR = 37 + class License(object): """Represents a license.""" @@ -243,13 +255,29 @@ def __init__(self, pkg, licenses): rev_major = rev.find("major").text rev_minor = rev.find("minor").text rev_micro = rev.find("micro").text + # The Emulator Preview packages carry a build id and share the + # same 0.0.1 major/minor/micro, so without it every preview release + # would present itself as plain "0.0.1". + rev_preview = rev.find("preview") archives = pkg.find("archives") channel = pkg.find("channelRef") self.channel = CHANNEL_MAPPING[channel.attrib["ref"]] + # "emulator" is the regular emulator; "emulators;latest" and + # "emulators;" are the Emulator Preview. + self.path = pkg.attrib.get("path", "") + self.is_preview = self.path.startswith(EMU_PREVIEW_PATH_PREFIX) + + display_name = pkg.find("display-name") + self.display_name = ( + display_name.text if display_name is not None else "Android Emulator" + ) + self.version = "%s.%s.%s" % (rev_major, rev_minor, rev_micro) + if rev_preview is not None and rev_preview.text: + self.version = "%s-%s" % (self.version, rev_preview.text) self.urls = {} for archive in archives: @@ -258,6 +286,10 @@ def __init__(self, pkg, licenses): self.urls[hostos] = "%s/android/repository/%s" % (ANDROID_REPOSITORY, url) def download_name(self): + # The two emulators are versioned independently, and an existing file + # is not downloaded again, so they must not share a name. + if self.is_preview: + return "emulator-preview-{}.zip".format(self.version) return "emulator-{}.zip".format(self.version) def download(self, hostos="linux", dest=None): @@ -268,6 +300,8 @@ def download(self, hostos="linux", dest=None): return super(EmuInfo, self).download(self.urls[hostos], dest) def __str__(self): + if self.is_preview: + return "preview {}".format(self.version) return "{} {}".format(self.channel, self.version) @@ -334,15 +368,37 @@ def find_image(regexpr): return matches -def find_emulator(channel): - """Finds the released emulator binaries for the given channel. +def find_emulator(channel, api_major=None): + """Finds the released emulator binaries to use for the given channel. As with sdkmanager, a channel includes the more stable channels below it: "canary" gives the newest emulator published on any channel, "stable" the newest stable one. "all" gives every published emulator. + If the api level of the system image is given and it is + MIN_API_PREVIEW_EMULATOR or higher, the latest Emulator Preview is returned + whatever the channel, as that is the emulator those images should run on. + Returns a list of EmuInfo objects.""" emu_infos = [x for x in get_emus_info() if "linux" in x.urls] + if api_major is not None and api_major >= MIN_API_PREVIEW_EMULATOR: + latest = [x for x in emu_infos if x.path == EMU_PREVIEW_LATEST_PATH] + if latest: + logging.info( + "Using the Emulator Preview %s for API %s (%s+ system images use it instead of %s)", + latest[0].version, + api_major, + MIN_API_PREVIEW_EMULATOR, + channel, + ) + return latest[:1] + logging.warning( + "No Emulator Preview found for API %s, using the %s emulator", + api_major, + channel, + ) + + emu_infos = [x for x in emu_infos if not x.is_preview] if channel != "all": channels = list(CHANNEL_MAPPING.values()) included = channels[: channels.index(channel) + 1] if channel in channels else [] @@ -373,7 +429,8 @@ def get_emus_info(): [ p for p in ET.fromstring(x).findall("remotePackage") - if "emulator" == p.attrib["path"] + if p.attrib["path"] == EMU_PATH + or p.attrib["path"].startswith(EMU_PREVIEW_PATH_PREFIX) ] for x in xml ] @@ -398,12 +455,45 @@ def select_image(arm): return img_infos[selection] if selection < len(img_infos) else None -def select_emulator(): +def emulator_supports_image(emu_info, sys_img_info): + """True if this emulator can be paired with the given system image. + + The Emulator Preview requires features on the system images that are right + now only available in the API 37+ system images, so it is not offered below + MIN_API_PREVIEW_EMULATOR. + """ + if not emu_info.is_preview or sys_img_info is None: + return True + return sys_img_info.api_major >= MIN_API_PREVIEW_EMULATOR + + +def select_emulator(sys_img_info=None): """Displayes an interactive menu to select a released emulator binary. - Returns a ImuInfo object with the choice or None if the user aborts.""" - emu_infos = [x for x in get_emus_info() if "linux" in x.urls] - display = [f"EMU {emu_info.channel} {emu_info.version}" for emu_info in emu_infos] + If a system image is given, emulators that cannot run it are left out of + the menu, so an unsupported pairing cannot be picked. When the Emulator + Preview is offered it is listed first, latest on top. + + Returns a EmuInfo object with the choice or None if the user aborts.""" + emu_infos = [ + x + for x in get_emus_info() + if "linux" in x.urls and emulator_supports_image(x, sys_img_info) + ] + if sys_img_info is not None: + emu_infos.sort( + key=lambda x: (not x.is_preview, x.path != EMU_PREVIEW_LATEST_PATH) + ) + display = [ + f"EMU {'preview' if emu_info.is_preview else emu_info.channel} " + f"{emu_info.version} ({emu_info.display_name})" + for emu_info in emu_infos + ] + if sys_img_info is not None and not any(x.is_preview for x in emu_infos): + print( + f"Note: the Emulator Preview is not offered for API {sys_img_info.api}; " + f"it requires API {MIN_API_PREVIEW_EMULATOR} or higher." + ) selection = SelectionMenu.get_selection( display, title="Select the emulator you wish to use:" ) @@ -429,12 +519,9 @@ def list_all_downloads(arm): ) for emu_info in emu_infos: + channel = "preview" if emu_info.is_preview else emu_info.channel for hostos, url in list(emu_info.urls.items()): - print( - "EMU {} {} {} {}".format( - emu_info.channel, emu_info.version, hostos, url - ) - ) + print("EMU {} {} {} {}".format(channel, emu_info.version, hostos, url)) def download_build(build_id, dest=None): diff --git a/emu/templates/launch-emulator.sh b/emu/templates/launch-emulator.sh index 1106d17..0ab4d36 100644 --- a/emu/templates/launch-emulator.sh +++ b/emu/templates/launch-emulator.sh @@ -192,7 +192,14 @@ if [ ! -z "${TURN}" ]; then fi # Add qemu specific parameters +{% if preview_emulator %} +# The Emulator Preview builds its own qemu command line, which already carries +# an -append with the guest kernel arguments. Passing another one through +# -qemu makes its (much newer) qemu fail with "requires an argument", so the +# panic=1 kernel argument is not set for preview builds. +{% else %} LAUNCH_CMD+=("-qemu" "-append" "panic=1") +{% endif %} if [ ! -z "${ANDROID_AVD_HOME}" ]; then export ANDROID_AVD_HOME=/android-home diff --git a/tests/test_emulator_preview.py b/tests/test_emulator_preview.py new file mode 100644 index 0000000..76746bd --- /dev/null +++ b/tests/test_emulator_preview.py @@ -0,0 +1,496 @@ +# Copyright 2026 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. +"""Unit tests for Emulator Preview support, focused on: + +- EmuInfo parsing of the "emulators;latest" / "emulators;" packages. +- find_emulator picking the latest preview for API 37+ system images. +- Menu filtering and ordering in select_emulator. +- Preview detection from the zip, and the emulator/system-image pairing check. +- The launcher template leaving out "-qemu -append" for preview builds. +""" +import os +import types +import unittest.mock as mock +import zipfile + +import pytest +import requests + +import emu.emu_docker as emu_docker +import emu.emu_downloads_menu as menu +from emu.android_release_zip import AndroidReleaseZip +from emu.template_writer import TemplateWriter + + +# --------------------------------------------------------------------------- # +# Helpers +# --------------------------------------------------------------------------- # + + +def _emu_pkg(path, channel, version, url, preview=None, display_name=None, hostos="linux"): + """Build a snippet shaped like the real SDK XML.""" + major, minor, micro = version.split(".") + prev = f"{preview}" if preview else "" + name = f"{display_name}" if display_name else "" + return f""" + + {major}{minor}{micro}{prev} + {name} + + + + {url}{hostos} + +""" + + +_REPO_XML = """ + + Terms + {packages} + + 3600 + + + + platform-tools.ziplinux + + + +""" + +_PREVIEW = "Android Emulator (Preview)" + +# The version numbers below are made up: only their order, and the path and +# channel they are published on matter. +_PACKAGES = [ + # A pinned build comes first, as in the real repository: "latest" has to be + # found by its path, not by its position. + _emu_pkg("emulators;200", "channel-2", "0.2.0", "emu-preview-200.zip", display_name=_PREVIEW), + _emu_pkg("emulator", "channel-2", "2.0.0", "emu-dev.zip", display_name="Android Emulator"), + _emu_pkg("emulators;300", "channel-2", "0.3.0", "emu-preview-300.zip", display_name=_PREVIEW), + _emu_pkg("emulators;latest", "channel-2", "0.3.0", "emu-preview-300.zip", display_name=_PREVIEW + " (latest)"), + _emu_pkg("emulator", "channel-0", "1.0.0", "emu-stable.zip", display_name="Android Emulator"), + _emu_pkg("emulators;400", "channel-2", "0.4.0", "emu-preview-mac.zip", hostos="macosx"), +] + + +def _mock_repo(monkeypatch, packages): + xml = _REPO_XML.format(packages="".join(packages)) + + def mock_get(url): + return mock.MagicMock(content=xml, status_code=200) + + monkeypatch.setattr(requests, "get", mock_get) + + +@pytest.fixture +def emu_repo(monkeypatch): + _mock_repo(monkeypatch, _PACKAGES) + + +def _sys_img(api): + """A stand-in for SysImgInfo with just what the emulator menu looks at.""" + return types.SimpleNamespace(api=str(api), api_major=int(float(api))) + + +def _emu_zip(temp_dir, description, name="emulator.zip"): + path = temp_dir / name + with zipfile.ZipFile(path, "w") as zf: + zf.writestr( + "emulator/source.properties", + f"Pkg.Desc={description}\nPkg.Revision=1.2.3\n", + ) + return path + + +# --------------------------------------------------------------------------- # +# EmuInfo / get_emus_info +# --------------------------------------------------------------------------- # + + +def test_get_emus_info_includes_preview_packages(emu_repo): + paths = [x.path for x in menu.get_emus_info()] + assert paths == [ + "emulators;200", + "emulator", + "emulators;300", + "emulators;latest", + "emulator", + "emulators;400", + ] + + +def test_regular_emulator_is_not_preview(emu_repo): + stable = menu.find_emulator("stable")[0] + assert stable.is_preview is False + assert stable.version == "1.0.0" + assert str(stable) == "stable 1.0.0" + assert stable.download_name() == "emulator-1.0.0.zip" + + +def test_preview_is_recognized_by_path(emu_repo): + preview = [x for x in menu.get_emus_info() if x.is_preview] + assert [x.version for x in preview] == ["0.2.0", "0.3.0", "0.3.0", "0.4.0"] + assert str(preview[0]) == "preview 0.2.0" + + +def test_preview_download_does_not_share_a_name_with_regular_emulator(emu_repo): + # Both emulators can carry the same version, and existing files are reused. + preview = [x for x in menu.get_emus_info() if x.is_preview][0] + assert preview.download_name() == "emulator-preview-0.2.0.zip" + + +def test_preview_build_id_is_added_to_version(monkeypatch): + _mock_repo( + monkeypatch, + [_emu_pkg("emulators;latest", "channel-2", "0.0.1", "emu.zip", preview="200")], + ) + assert menu.get_emus_info()[0].version == "0.0.1-200" + + +def test_display_name_defaults_when_missing(emu_repo): + by_path = {x.path: x for x in menu.get_emus_info()} + assert by_path["emulators;latest"].display_name == "Android Emulator (Preview) (latest)" + assert by_path["emulators;400"].display_name == "Android Emulator" + + +# --------------------------------------------------------------------------- # +# find_emulator +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize("channel", ["stable", "canary", "all"]) +@pytest.mark.parametrize("api_major", [37, 38]) +def test_recent_image_gets_latest_preview_whatever_the_channel(emu_repo, channel, api_major): + found = menu.find_emulator(channel, api_major) + assert [x.path for x in found] == ["emulators;latest"] + + +@pytest.mark.parametrize("api_major", [None, 28, 36]) +@pytest.mark.parametrize( + "channel, expected", + [ + ("stable", ["stable 1.0.0"]), + ("canary", ["dev 2.0.0"]), + ("all", ["dev 2.0.0", "stable 1.0.0"]), + ], +) +def test_older_image_gets_regular_emulator(emu_repo, channel, expected, api_major): + assert [str(x) for x in menu.find_emulator(channel, api_major)] == expected + + +def test_dev_channel_does_not_pull_in_preview(monkeypatch): + # The preview is published on the dev channel, but it is not what "dev" means. + _mock_repo(monkeypatch, [p for p in _PACKAGES if "emu-dev.zip" not in p]) + assert [str(x) for x in menu.find_emulator("dev", 36)] == ["stable 1.0.0"] + + +def test_preview_is_not_a_channel(emu_repo): + with pytest.raises(menu.EmulatorNotFoundException): + menu.find_emulator("preview") + + +def test_recent_image_falls_back_to_channel_without_preview(monkeypatch): + _mock_repo(monkeypatch, [p for p in _PACKAGES if "emulators;" not in p]) + assert [str(x) for x in menu.find_emulator("stable", 37)] == ["stable 1.0.0"] + + +# --------------------------------------------------------------------------- # +# emulator_supports_image / select_emulator / list_all_downloads +# --------------------------------------------------------------------------- # + + +def _latest_preview(): + return menu.find_emulator("stable", 37)[0] + + +@pytest.mark.parametrize( + "api, supported", + [("28", False), ("36", False), ("36.1", False), ("37", True), ("37.0", True), ("38", True)], +) +def test_preview_supports_only_recent_images(emu_repo, api, supported): + assert menu.emulator_supports_image(_latest_preview(), _sys_img(api)) is supported + + +def test_regular_emulator_supports_any_image(emu_repo): + stable = menu.find_emulator("stable")[0] + assert menu.emulator_supports_image(stable, _sys_img("28")) is True + assert menu.emulator_supports_image(stable, _sys_img("37")) is True + + +def test_preview_supported_when_no_image_given(emu_repo): + assert menu.emulator_supports_image(_latest_preview(), None) is True + + +@pytest.fixture +def selection_menu(monkeypatch): + """Captures what the emulator menu displays, and picks the first entry.""" + shown = [] + + def get_selection(display, title): + shown.extend(display) + return 0 + + monkeypatch.setattr(menu.SelectionMenu, "get_selection", get_selection) + return shown + + +def test_select_emulator_hides_preview_for_older_image(emu_repo, selection_menu, capsys): + picked = menu.select_emulator(_sys_img("36")) + + assert selection_menu == [ + "EMU dev 2.0.0 (Android Emulator)", + "EMU stable 1.0.0 (Android Emulator)", + ] + assert str(picked) == "dev 2.0.0" + assert "Emulator Preview is not offered for API 36" in capsys.readouterr().out + + +def test_select_emulator_lists_preview_first_for_recent_image(emu_repo, selection_menu, capsys): + picked = menu.select_emulator(_sys_img("37")) + + # The regular emulator still runs API 37, and a pinned preview build can be + # picked on purpose, so both stay in the menu below the latest preview. + assert selection_menu == [ + "EMU preview 0.3.0 (Android Emulator (Preview) (latest))", + "EMU preview 0.2.0 (Android Emulator (Preview))", + "EMU preview 0.3.0 (Android Emulator (Preview))", + "EMU dev 2.0.0 (Android Emulator)", + "EMU stable 1.0.0 (Android Emulator)", + ] + assert picked.path == "emulators;latest" + assert "not offered" not in capsys.readouterr().out + + +def test_select_emulator_without_image_keeps_repository_order(emu_repo, selection_menu, capsys): + menu.select_emulator() + + assert selection_menu[0] == "EMU preview 0.2.0 (Android Emulator (Preview))" + assert len(selection_menu) == 5 + assert "not offered" not in capsys.readouterr().out + + +def test_select_emulator_returns_none_on_exit(emu_repo, monkeypatch): + # SelectionMenu reports the "Exit" entry as one past the last item. + monkeypatch.setattr( + menu.SelectionMenu, "get_selection", lambda display, title: len(display) + ) + assert menu.select_emulator() is None + + +def test_list_all_downloads_labels_preview(emu_repo, monkeypatch, capsys): + monkeypatch.setattr(menu, "get_images_info", lambda arm: []) + + menu.list_all_downloads(False) + + lines = capsys.readouterr().out.splitlines() + assert f"EMU stable 1.0.0 linux {menu.ANDROID_REPOSITORY}/android/repository/emu-stable.zip" in lines + assert f"EMU preview 0.2.0 linux {menu.ANDROID_REPOSITORY}/android/repository/emu-preview-200.zip" in lines + # The regular emulator is the only one left under its channel name. + assert [line for line in lines if line.startswith("EMU dev")] == [ + f"EMU dev 2.0.0 linux {menu.ANDROID_REPOSITORY}/android/repository/emu-dev.zip" + ] + + +# --------------------------------------------------------------------------- # +# AndroidReleaseZip.is_preview_emulator +# --------------------------------------------------------------------------- # + + +@pytest.mark.parametrize( + "description, is_preview", + [ + ("Android Emulator", False), + ("Android Emulator (Preview)", True), + ("Android SDK Platform-Tools (Preview)", False), + ], +) +def test_is_preview_emulator(temp_dir, description, is_preview): + zip_file = AndroidReleaseZip(_emu_zip(temp_dir, description)) + assert zip_file.is_preview_emulator() is is_preview + + +# --------------------------------------------------------------------------- # +# emu_docker._check_emulator_supports_image +# --------------------------------------------------------------------------- # + + +def _sys_docker_from_zip(api): + """A stand-in for a SystemImageContainer built from a local zip.""" + return types.SimpleNamespace( + system_image_info=None, + system_image_zip=types.SimpleNamespace(api=lambda: api), + ) + + +def _sys_docker_from_info(api): + """A stand-in for a SystemImageContainer whose zip is not downloaded yet.""" + return types.SimpleNamespace(system_image_info=_sys_img(api), system_image_zip=None) + + +@pytest.mark.parametrize("sys_docker", [_sys_docker_from_zip, _sys_docker_from_info]) +@pytest.mark.parametrize("api", ["28", "36", "36.1"]) +def test_check_rejects_preview_with_older_image(temp_dir, sys_docker, api): + emulator = _emu_zip(temp_dir, "Android Emulator (Preview)", "emulator-preview.zip") + + with pytest.raises(Exception) as err: + emu_docker._check_emulator_supports_image(emulator, sys_docker(api)) + + assert "requires an API 37+ system image" in str(err.value) + assert "emulator-preview.zip" in str(err.value) + + +def test_check_rejects_preview_when_zip_has_no_api_level(temp_dir): + emulator = _emu_zip(temp_dir, "Android Emulator (Preview)") + with pytest.raises(Exception): + emu_docker._check_emulator_supports_image(emulator, _sys_docker_from_zip("")) + + +@pytest.mark.parametrize("sys_docker", [_sys_docker_from_zip, _sys_docker_from_info]) +@pytest.mark.parametrize("api", ["37", "37.0", "38"]) +def test_check_accepts_preview_with_recent_image(temp_dir, sys_docker, api): + emulator = _emu_zip(temp_dir, "Android Emulator (Preview)") + emu_docker._check_emulator_supports_image(emulator, sys_docker(api)) + + +def test_check_accepts_regular_emulator_with_any_image(temp_dir): + emulator = _emu_zip(temp_dir, "Android Emulator") + emu_docker._check_emulator_supports_image(emulator, _sys_docker_from_zip("28")) + emu_docker._check_emulator_supports_image(emulator, _sys_docker_from_zip("37")) + + +# --------------------------------------------------------------------------- # +# launch-emulator.sh template +# --------------------------------------------------------------------------- # + +_QEMU_APPEND = 'LAUNCH_CMD+=("-qemu" "-append" "panic=1")' + + +def _launch_script(temp_dir, preview_emulator): + writer = TemplateWriter(temp_dir) + writer.write_template( + "launch-emulator.sh", + {"extra": "", "version": "1.0", "preview_emulator": preview_emulator}, + ) + return (temp_dir / "launch-emulator.sh").read_text() + + +def test_launcher_sets_panic_for_regular_emulator(temp_dir): + assert _QEMU_APPEND in _launch_script(temp_dir, False) + + +def test_launcher_leaves_out_qemu_append_for_preview(temp_dir): + assert _QEMU_APPEND not in _launch_script(temp_dir, True) + + +# --------------------------------------------------------------------------- # +# emu_docker.create_docker_image +# --------------------------------------------------------------------------- # + + +@pytest.fixture +def create_image(monkeypatch, temp_dir): + """Runs create_docker_image with docker and the downloads stubbed out. + + Returns a function that takes the emulator argument and the api levels of + the matching system images, and gives back the (api, emulator) pairs built. + """ + built = [] + + class FakeSystemImageContainer: + def __init__(self, img, repo): + self.system_image_info = img + self.system_image_zip = None + + def available(self): + return True + + def can_pull(self): + return True + + class FakeEmulatorContainer: + def __init__(self, emulator, sys_docker, *args): + built.append((sys_docker.system_image_info.api, os.path.basename(str(emulator)))) + + def build(self, dest): + pass + + def find_emulator(channel, api_major=None): + if api_major >= menu.MIN_API_PREVIEW_EMULATOR: + descriptions = {"emulator-preview.zip": "Android Emulator (Preview)"} + else: + descriptions = {f"emulator-{channel}.zip": "Android Emulator"} + return [ + mock.MagicMock(download=lambda n=name, d=desc: _emu_zip(temp_dir, d, n)) + for name, desc in descriptions.items() + ] + + monkeypatch.setattr(emu_docker, "SystemImageContainer", FakeSystemImageContainer) + monkeypatch.setattr(emu_docker, "EmulatorContainer", FakeEmulatorContainer) + monkeypatch.setattr(emu_docker, "metrics_config", lambda args: mock.MagicMock()) + monkeypatch.setattr(menu, "find_emulator", mock.MagicMock(side_effect=find_emulator)) + + def run(emuzip, apis, sys=False): + monkeypatch.setattr(menu, "find_image", lambda regexpr: [_sys_img(a) for a in apis]) + args = types.SimpleNamespace( + imgzip="some-image-regexp", + emuzip=str(emuzip), + repo="repo", + dest=str(temp_dir), + push=False, + sys=sys, + start=False, + extra="", + name=None, + ) + emu_docker.create_docker_image(args) + return built + + return run + + +def test_create_picks_emulator_per_system_image(create_image): + assert create_image("stable", ["36", "37", "28", "38"]) == [ + ("36", "emulator-stable.zip"), + ("37", "emulator-preview.zip"), + ("28", "emulator-stable.zip"), + ("38", "emulator-preview.zip"), + ] + # One lookup for the older images, one for the recent ones. + assert menu.find_emulator.call_count == 2 + + +def test_create_uses_given_zip_for_every_image(create_image, temp_dir): + emulator = _emu_zip(temp_dir, "Android Emulator", "my-emulator.zip") + + assert create_image(emulator, ["36", "37"]) == [ + ("36", "my-emulator.zip"), + ("37", "my-emulator.zip"), + ] + menu.find_emulator.assert_not_called() + + +def test_create_refuses_preview_zip_with_older_image(create_image, temp_dir): + emulator = _emu_zip(temp_dir, "Android Emulator (Preview)", "my-preview.zip") + + with pytest.raises(Exception) as err: + create_image(emulator, ["37", "36"]) + + assert "my-preview.zip was paired with API 36" in str(err.value) + + +def test_create_sys_only_does_not_look_up_an_emulator(create_image): + assert create_image("stable", ["37"], sys=True) == [] + menu.find_emulator.assert_not_called()