From e728c7e372f6aced4ac98227e67ff07c89fd110b Mon Sep 17 00:00:00 2001 From: Dan Albert Date: Thu, 30 Nov 2023 21:34:42 +0000 Subject: End-to-end tests for external_updater. My usual strategy of "just try a few projects and see if it works" hasn't been very reliable. This adds some tests that create a fake repo tree and some fake git remotes, clone them, and then run updater.sh on them. This doesn't work quite as well as I'd hoped: * We can't put this in CI, because we can't assume git or repo are installed or that we have network access (even though all the git repos we use on purpose in the test are local, running `repo init` will repo itself into .repo/repo). * We can't do this with `python_test_host` because the python in our tree isn't configured for the SSL certifactes of repo's git repo to validate. Still, this might be better than nothing, even if we can only run it locally. Bug: None Test: pytest tests/endtoend Change-Id: I2bd6ff8ecfb977d7c076a46ae3f86af23dfb4d01 --- Android.bp | 7 ++ tests/__init__.py | 0 tests/endtoend/__init__.py | 0 tests/endtoend/conftest.py | 85 ++++++++++++++++++ tests/endtoend/test_check.py | 86 +++++++++++++++++++ tests/endtoend/treebuilder/__init__.py | 4 + tests/endtoend/treebuilder/fakeproject.py | 94 ++++++++++++++++++++ tests/endtoend/treebuilder/fakerepo.py | 108 +++++++++++++++++++++++ tests/endtoend/treebuilder/gitrepo.py | 114 +++++++++++++++++++++++++ tests/endtoend/treebuilder/test_fakeproject.py | 61 +++++++++++++ tests/endtoend/treebuilder/test_fakerepo.py | 85 ++++++++++++++++++ tests/endtoend/treebuilder/test_gitrepo.py | 67 +++++++++++++++ tests/endtoend/treebuilder/test_treebuilder.py | 38 +++++++++ tests/endtoend/treebuilder/treebuilder.py | 34 ++++++++ 14 files changed, 783 insertions(+) create mode 100644 tests/__init__.py create mode 100644 tests/endtoend/__init__.py create mode 100644 tests/endtoend/conftest.py create mode 100644 tests/endtoend/test_check.py create mode 100644 tests/endtoend/treebuilder/__init__.py create mode 100644 tests/endtoend/treebuilder/fakeproject.py create mode 100644 tests/endtoend/treebuilder/fakerepo.py create mode 100644 tests/endtoend/treebuilder/gitrepo.py create mode 100644 tests/endtoend/treebuilder/test_fakeproject.py create mode 100644 tests/endtoend/treebuilder/test_fakerepo.py create mode 100644 tests/endtoend/treebuilder/test_gitrepo.py create mode 100644 tests/endtoend/treebuilder/test_treebuilder.py create mode 100644 tests/endtoend/treebuilder/treebuilder.py diff --git a/Android.bp b/Android.bp index 7528a45..2774ed4 100644 --- a/Android.bp +++ b/Android.bp @@ -100,3 +100,10 @@ python_test_host { unit_test: true, }, } + +// The tests in tests/endtoend are not built as a Soong module because we can't +// run those tests via python_test_host. It's an end-to-end test so it needs to +// run `repo`, but `repo init` will try to clone the real repo source (the thing +// in PATH is just a launcher), which our Python can't do (the SSL module's +// certificate validation is not configured for that). Run those tests with +// `pytest tests/endtoend`. diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/endtoend/__init__.py b/tests/endtoend/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/endtoend/conftest.py b/tests/endtoend/conftest.py new file mode 100644 index 0000000..68e1f4e --- /dev/null +++ b/tests/endtoend/conftest.py @@ -0,0 +1,85 @@ +# +# Copyright (C) 2023 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. +# +"""Common tools for end-to-end tests.""" +import subprocess +from pathlib import Path + +import pytest + +from .treebuilder import TreeBuilder + +THIS_DIR = Path(__file__).parent.resolve() +EXTERNAL_UPDATER_DIR = THIS_DIR.parent.parent +ANDROID_DIR = EXTERNAL_UPDATER_DIR.parent.parent + + +def pytest_addoption(parser: pytest.Parser) -> None: + """Add custom options to pytest.""" + parser.addoption( + "--build-updater", + action="store_true", + default=True, + help=( + "Build external_updater before running tests. This is the default behavior." + ), + ) + parser.addoption( + "--no-build-updater", + action="store_false", + dest="build_updater", + help=( + "Do not build external_updater before running tests. Only use this option " + "if you've manually built external_updater. It will make test startup " + "faster." + ), + ) + + +@pytest.fixture(name="should_build_updater", scope="session") +def should_build_updater_fixture(request: pytest.FixtureRequest) -> bool: + """True if external_updater should be built before running tests.""" + return request.config.getoption("--build-updater") + + +# Session scope means that this fixture will only run the first time it's used. We don't +# want to re-run soong for every test because it's horrendously slow to do so. +@pytest.fixture(scope="session") +def updater_cmd(should_build_updater: bool) -> list[str]: + """The command to run for external_updater. + + The result is the prefix of the command that should be used with subprocess.run or + similar. + """ + # Running updater.sh should be a more accurate test, but doing so isn't really + # feasible with a no-op `m external_updater` taking ~10 seconds. Doing that would + # add 10 seconds to every test. Build the updater once for the first thing that + # requires it (that's the "session" scope above) and run the PAR directly. + if should_build_updater: + subprocess.run( + [ + ANDROID_DIR / "build/soong/soong_ui.bash", + "--make-mode", + "external_updater", + ], + check=True, + ) + return [str("external_updater")] + + +@pytest.fixture(name="tree_builder") +def tree_builder_fixture(tmp_path: Path) -> TreeBuilder: + """Creates a TreeBuilder for making test repo trees.""" + return TreeBuilder(tmp_path) diff --git a/tests/endtoend/test_check.py b/tests/endtoend/test_check.py new file mode 100644 index 0000000..74a00ae --- /dev/null +++ b/tests/endtoend/test_check.py @@ -0,0 +1,86 @@ +# +# Copyright (C) 2023 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. +# +"""End-to-end tests for external_updater.""" +import subprocess +from pathlib import Path + +from .treebuilder import TreeBuilder + + +class TestCheck: + """Tests for `external_updater check`.""" + + def check(self, updater_cmd: list[str], paths: list[Path]) -> str: + """Runs `external_updater check` with the given arguments. + + Returns: + The output of the command. + """ + return subprocess.run( + updater_cmd + ["check"] + [str(p) for p in paths], + check=True, + capture_output=True, + text=True, + ).stdout + + def test_git_up_to_date( + self, tree_builder: TreeBuilder, updater_cmd: list[str] + ) -> None: + """Tests that up-to-date projects are identified.""" + tree = tree_builder.repo_tree("tree") + a = tree.project("platform/external/foo", "external/foo") + a.upstream.commit( + "Add README.md.", + update_files={ + "README.md": "Hello, world!\n", + }, + ) + tree.create_manifest_repo() + a.initial_import() + tree.init_and_sync() + output = self.check(updater_cmd, [a.local.path]) + current_version = a.upstream.head() + assert output == ( + f"Checking {a.local.path}. Current version: {current_version}. Latest " + f"version: {current_version} Up to date.\n" + ) + + def test_git_out_of_date( + self, tree_builder: TreeBuilder, updater_cmd: list[str] + ) -> None: + """Tests that out-of-date projects are identified.""" + tree = tree_builder.repo_tree("tree") + a = tree.project("platform/external/foo", "external/foo") + a.upstream.commit( + "Add README.md.", + update_files={ + "README.md": "Hello, world!\n", + }, + ) + tree.create_manifest_repo() + a.initial_import() + current_version = a.upstream.head() + tree.init_and_sync() + a.upstream.commit( + "Update the project.", + update_files={"README.md": "This project is deprecated.\n"}, + ) + output = self.check(updater_cmd, [a.local.path]) + latest_version = a.upstream.head() + assert output == ( + f"Checking {a.local.path}. Current version: {current_version}. Latest " + f"version: {latest_version} Out of date!\n" + ) diff --git a/tests/endtoend/treebuilder/__init__.py b/tests/endtoend/treebuilder/__init__.py new file mode 100644 index 0000000..19dce12 --- /dev/null +++ b/tests/endtoend/treebuilder/__init__.py @@ -0,0 +1,4 @@ +"""Tools for creating temporary repo trees in tests.""" +from .fakeproject import FakeProject +from .fakerepo import FakeRepo +from .treebuilder import TreeBuilder diff --git a/tests/endtoend/treebuilder/fakeproject.py b/tests/endtoend/treebuilder/fakeproject.py new file mode 100644 index 0000000..e0a72a9 --- /dev/null +++ b/tests/endtoend/treebuilder/fakeproject.py @@ -0,0 +1,94 @@ +# +# Copyright (C) 2023 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. +# +"""Git repository fakes for use in tests.""" +import textwrap +from pathlib import Path + +from .gitrepo import GitRepo + + +class FakeProject: # pylint: disable=too-few-public-methods + """A collection of git repositories for use in tests. + + This shouldn't be used directly. Use the tree_builder fixture. + + A project contains the three git repos that are used in the Android upstream + mirroring process: + + 1. The true upstream repository. Where the third-party commits their work. + 2. The Android repository. Where Gerrit submits work. + 3. The local repository. This is where work happens before being uploaded to Gerrit. + """ + + def __init__( + self, tree_path: Path, upstream_path: Path, android_mirror_path: Path + ) -> None: + self.local = GitRepo(tree_path) + self.upstream = GitRepo(upstream_path) + self.android_mirror = GitRepo(android_mirror_path) + self._initialize_repo(self.upstream) + + def _initialize_repo(self, repo: GitRepo) -> None: + """Create a git repo and initial commit in the upstream repository.""" + repo.init(branch_name="main") + repo.commit("Initial commit.", allow_empty=True) + + def initial_import(self) -> None: + """Perform the initial import of the upstream repo into the mirror repo. + + These are an approximation of the steps that would be taken for the initial + import as part of go/android3p: + + * A new git repo is created with a single empty commit. That commit is **not** + present in the upstream repo. Android imports begin with unrelated histories. + * The upstream repository is merged into the local tree. + * METADATA, NOTICE, MODULE_LICENSE_*, and OWNERS files are added to the local + tree. We only bother with METADATA for now since that's all the tests need. + """ + self.android_mirror.init() + self.android_mirror.commit("Initial commit.", allow_empty=True) + + upstream_sha = self.upstream.head() + self.android_mirror.fetch(self.upstream) + self.android_mirror.merge( + "FETCH_HEAD", allow_fast_forward=False, allow_unrelated_histories=True + ) + + self.android_mirror.commit( + "Add metadata files.", + update_files={ + "OWNERS": "nobody", + "METADATA": textwrap.dedent( + f"""\ + name: "test" + description: "It's a test." + third_party {{ + license_type: UNENCUMBERED + last_upgrade_date {{ + year: 2023 + month: 12 + day: 1 + }} + identifier {{ + type: "GIT" + value: "{self.upstream.path.as_uri()}" + version: "{upstream_sha}" + }} + }} + """ + ), + }, + ) diff --git a/tests/endtoend/treebuilder/fakerepo.py b/tests/endtoend/treebuilder/fakerepo.py new file mode 100644 index 0000000..6517afd --- /dev/null +++ b/tests/endtoend/treebuilder/fakerepo.py @@ -0,0 +1,108 @@ +# +# Copyright (C) 2023 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. +# +"""repo tree fakes for use in tests.""" +import contextlib +import subprocess +from pathlib import Path +from xml.etree import ElementTree + +from .fakeproject import FakeProject + + +class FakeRepo: + """A repo tree for use in tests. + + This shouldn't be used directly. Use the tree_builder fixture. + """ + + def __init__(self, temp_dir: Path) -> None: + self.root = temp_dir / "tree" + self.mirror_dir = temp_dir / "mirrors" + self.upstream_dir = temp_dir / "upstreams" + self.manifest_repo = temp_dir / "manifest" + self.projects: list[FakeProject] = [] + self._used_git_subpaths: set[str] = set() + self._used_tree_subpaths: set[str] = set() + + def project(self, git_subpath: str, tree_subpath: str) -> FakeProject: + """Creates a new project in the repo.""" + if git_subpath in self._used_git_subpaths: + raise KeyError(f"A project with git path {git_subpath} already exists") + if tree_subpath in self._used_tree_subpaths: + raise KeyError(f"A project with tree path {tree_subpath} already exists") + project = FakeProject( + self.root / tree_subpath, + self.upstream_dir / tree_subpath, + self.mirror_dir / git_subpath, + ) + self.projects.append(project) + self._used_git_subpaths.add(git_subpath) + self._used_tree_subpaths.add(tree_subpath) + return project + + def init_and_sync(self) -> None: + """Runs repo init and repo sync to clone the repo tree.""" + self.root.mkdir(parents=True) + with contextlib.chdir(self.root): + subprocess.run( + ["repo", "init", "-c", "-u", str(self.manifest_repo), "-b", "main"], + check=True, + ) + subprocess.run(["repo", "sync", "-c"], check=True) + + def create_manifest_repo(self) -> None: + """Creates the git repo for the manifest, commits the manifest XML.""" + self.manifest_repo.mkdir(parents=True) + with contextlib.chdir(self.manifest_repo): + subprocess.run(["git", "init"], check=True) + Path("default.xml").write_bytes( + ElementTree.tostring(self._create_manifest_xml(), encoding="utf-8") + ) + subprocess.run(["git", "add", "default.xml"], check=True) + subprocess.run(["git", "commit", "-m", "Initial commit."], check=True) + + def _create_manifest_xml(self) -> ElementTree.Element: + # Example manifest: + # + # + # + # + # + # + # ... + # + # + # The revision and remote attributes of project are optional. + root = ElementTree.Element("manifest") + ElementTree.SubElement( + root, + "remote", + {"name": "aosp", "fetch": self.mirror_dir.resolve().as_uri()}, + ) + ElementTree.SubElement(root, "default", {"revision": "main", "remote": "aosp"}) + for project in self.projects: + ElementTree.SubElement( + root, + "project", + { + "path": str(project.local.path.relative_to(self.root)), + "name": str( + project.android_mirror.path.relative_to(self.mirror_dir) + ), + }, + ) + return root diff --git a/tests/endtoend/treebuilder/gitrepo.py b/tests/endtoend/treebuilder/gitrepo.py new file mode 100644 index 0000000..46bba86 --- /dev/null +++ b/tests/endtoend/treebuilder/gitrepo.py @@ -0,0 +1,114 @@ +# +# Copyright (C) 2023 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. +# +"""APIs for interacting with git repositories.""" +# TODO: This should be partially merged with the git_utils APIs. +# The bulk of this should be lifted out of the tests and used by the rest of +# external_updater, but we'll want to keep a few of the APIs just in the tests because +# they're not particularly sensible elsewhere (specifically the shorthand for commit +# with the update_files and delete_files arguments). It's probably easiest to do that by +# reworking the git_utils APIs into a class like this and then deriving this one from +# that. +from __future__ import annotations + +import subprocess +from pathlib import Path + + +class GitRepo: + """A git repository for use in tests.""" + + def __init__(self, path: Path) -> None: + self.path = path + + def run(self, command: list[str]) -> str: + """Runs the given git command in the repository, returning the output.""" + return subprocess.run( + ["git", "-C", str(self.path)] + command, + check=True, + capture_output=True, + text=True, + ).stdout + + def init(self, branch_name: str | None = None) -> None: + """Initializes a new git repository.""" + self.path.mkdir(parents=True) + cmd = ["init"] + if branch_name is not None: + cmd.extend(["-b", branch_name]) + self.run(cmd) + + def head(self) -> str: + """Returns the SHA of the current HEAD.""" + return self.run(["rev-parse", "HEAD"]).strip() + + def fetch(self, ref_or_repo: str | GitRepo) -> None: + """Fetches the given ref or repo.""" + if isinstance(ref_or_repo, GitRepo): + ref_or_repo = str(ref_or_repo.path) + self.run(["fetch", ref_or_repo]) + + def commit( + self, + message: str, + allow_empty: bool = False, + update_files: dict[str, str] | None = None, + delete_files: set[str] | None = None, + ) -> None: + """Create a commit in the repository.""" + if update_files is None: + update_files = {} + if delete_files is None: + delete_files = set() + + for delete_file in delete_files: + self.run(["rm", delete_file]) + + for update_file, contents in update_files.items(): + (self.path / update_file).write_text(contents, encoding="utf-8") + self.run(["add", update_file]) + + commit_cmd = ["commit", "-m", message] + if allow_empty: + commit_cmd.append("--allow-empty") + self.run(commit_cmd) + + def merge( + self, + ref: str, + allow_fast_forward: bool = True, + allow_unrelated_histories: bool = False, + ) -> None: + """Merges the upstream ref into the repo.""" + cmd = ["merge"] + if not allow_fast_forward: + cmd.append("--no-ff") + if allow_unrelated_histories: + cmd.append("--allow-unrelated-histories") + self.run(cmd + [ref]) + + def commit_message_at_revision(self, revision: str) -> str: + """Returns the commit message of the given revision.""" + # %B is the raw commit body + # %- eats the separator newline + # Note that commit messages created with `git commit` will always end with a + # trailing newline. + return self.run(["log", "--format=%B%-", "-n1", revision]) + + def file_contents_at_revision(self, revision: str, path: str) -> str: + """Returns the commit message of the given revision.""" + # %B is the raw commit body + # %- eats the separator newline + return self.run(["show", "--format=%B%-", f"{revision}:{path}"]) diff --git a/tests/endtoend/treebuilder/test_fakeproject.py b/tests/endtoend/treebuilder/test_fakeproject.py new file mode 100644 index 0000000..09aadb2 --- /dev/null +++ b/tests/endtoend/treebuilder/test_fakeproject.py @@ -0,0 +1,61 @@ +# +# Copyright (C) 2023 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 for fakeproject.""" +from pathlib import Path + +from .fakeproject import FakeProject + + +class TestFakeProject: + """Tests for fakeproject.FakeProject.""" + + def test_constructor_initializes_upstream_repo(self, tmp_path: Path) -> None: + """Tests that the constructor initializes the "upstream" git repo.""" + project = FakeProject( + tmp_path / "local", tmp_path / "upstream", tmp_path / "mirror" + ) + assert ( + project.upstream.commit_message_at_revision("HEAD") == "Initial commit.\n" + ) + + def test_initial_import(self, tmp_path: Path) -> None: + """Tests that initial_import merges and creates metadata files.""" + project = FakeProject( + tmp_path / "local", tmp_path / "upstream", tmp_path / "mirror" + ) + project.upstream.commit( + "Add README.md.", update_files={"README.md": "Hello, world!"} + ) + + upstream_sha = project.upstream.head() + project.initial_import() + + # The import is done in the mirror repository. The cloned repository in the repo + # tree should not be created until init_and_sync() is called. + assert not project.local.path.exists() + + assert ( + project.android_mirror.commit_message_at_revision("HEAD^") + == f"Merge {project.upstream.path}\n" + ) + assert ( + project.android_mirror.commit_message_at_revision("HEAD") + == "Add metadata files.\n" + ) + metadata = project.android_mirror.file_contents_at_revision("HEAD", "METADATA") + assert 'type: "GIT"' in metadata + assert f'value: "{project.upstream.path.as_uri()}"' in metadata + assert f'version: "{upstream_sha}"' in metadata diff --git a/tests/endtoend/treebuilder/test_fakerepo.py b/tests/endtoend/treebuilder/test_fakerepo.py new file mode 100644 index 0000000..92fb22b --- /dev/null +++ b/tests/endtoend/treebuilder/test_fakerepo.py @@ -0,0 +1,85 @@ +# +# Copyright (C) 2023 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 for fakerepo.""" +from pathlib import Path +from xml.etree import ElementTree + +import pytest + +from .fakerepo import FakeRepo + + +class TestFakeRepo: + """Tests for fakerepo.FakeRepo.""" + + def test_project_created(self, tmp_path: Path) -> None: + """Tests that FakeRepo.project creates a new FakeProject.""" + repo = FakeRepo(tmp_path) + project = repo.project("platform/foo", "foo") + assert project.local.path == repo.root / "foo" + assert project.android_mirror.path == repo.mirror_dir / "platform/foo" + assert project.upstream.path == repo.upstream_dir / "foo" + + def test_project_error_if_path_reused(self, tmp_path: Path) -> None: + """Tests that KeyError is raised if a project path is reused.""" + repo = FakeRepo(tmp_path) + repo.project("platform/foo", "foo") + repo.project("platform/bar", "bar") + with pytest.raises(KeyError): + repo.project("platform/baz", "foo") + with pytest.raises(KeyError): + repo.project("platform/foo", "baz") + + def test_create_manifest_repo_xml_structure(self, tmp_path: Path) -> None: + """Tests that the correct manifest XML is created.""" + repo = FakeRepo(tmp_path) + repo.project("platform/foo", "foo") + repo.project("platform/external/bar", "external/bar") + repo.create_manifest_repo() + + manifest_path = repo.manifest_repo / "default.xml" + assert manifest_path.exists() + root = ElementTree.parse(manifest_path) + remotes = root.findall("./remote") + assert len(remotes) == 1 + remote = remotes[0] + assert remote.attrib["name"] == "aosp" + assert remote.attrib["fetch"] == repo.mirror_dir.as_uri() + + defaults = root.findall("./default") + assert len(defaults) == 1 + default = defaults[0] + assert default.attrib["remote"] == "aosp" + assert default.attrib["revision"] == "main" + + projects = root.findall("./project") + assert len(projects) == 2 + + assert projects[0].attrib["name"] == "platform/foo" + assert projects[0].attrib["path"] == "foo" + + assert projects[1].attrib["name"] == "platform/external/bar" + assert projects[1].attrib["path"] == "external/bar" + + def test_init_and_sync(self, tmp_path: Path) -> None: + """Tests that init_and_sync initializes and syncs the tree.""" + repo = FakeRepo(tmp_path) + project = repo.project("platform/foo", "foo") + repo.create_manifest_repo() + project.initial_import() + repo.init_and_sync() + + assert project.local.head() == project.android_mirror.head() diff --git a/tests/endtoend/treebuilder/test_gitrepo.py b/tests/endtoend/treebuilder/test_gitrepo.py new file mode 100644 index 0000000..7d2daba --- /dev/null +++ b/tests/endtoend/treebuilder/test_gitrepo.py @@ -0,0 +1,67 @@ +# +# Copyright (C) 2023 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 for gitrepo.""" +import subprocess +from pathlib import Path + +from .gitrepo import GitRepo + + +class TestGitRepo: + """Tests for gitrepo.GitRepo.""" + + def test_commit_adds_files(self, tmp_path: Path) -> None: + """Tests that new files in commit are added to the repo.""" + repo = GitRepo(tmp_path / "repo") + repo.init() + repo.commit("Add README.md.", update_files={"README.md": "Hello, world!"}) + assert repo.commit_message_at_revision("HEAD") == "Add README.md.\n" + assert repo.file_contents_at_revision("HEAD", "README.md") == "Hello, world!" + + def test_commit_updates_files(self, tmp_path: Path) -> None: + """Tests that updated files in commit are modified.""" + repo = GitRepo(tmp_path / "repo") + repo.init() + repo.commit("Add README.md.", update_files={"README.md": "Hello, world!"}) + repo.commit("Update README.md.", update_files={"README.md": "Goodbye, world!"}) + assert repo.commit_message_at_revision("HEAD^") == "Add README.md.\n" + assert repo.file_contents_at_revision("HEAD^", "README.md") == "Hello, world!" + assert repo.commit_message_at_revision("HEAD") == "Update README.md.\n" + assert repo.file_contents_at_revision("HEAD", "README.md") == "Goodbye, world!" + + def test_commit_deletes_files(self, tmp_path: Path) -> None: + """Tests that files deleted by commit are removed from the repo.""" + repo = GitRepo(tmp_path / "repo") + repo.init() + repo.commit("Add README.md.", update_files={"README.md": "Hello, world!"}) + repo.commit("Remove README.md.", delete_files={"README.md"}) + assert repo.commit_message_at_revision("HEAD^") == "Add README.md.\n" + assert repo.file_contents_at_revision("HEAD^", "README.md") == "Hello, world!" + assert repo.commit_message_at_revision("HEAD") == "Remove README.md.\n" + assert ( + subprocess.run( + [ + "git", + "-C", + str(repo.path), + "ls-files", + "--error-unmatch", + "README.md", + ], + check=False, + ).returncode + != 0 + ) diff --git a/tests/endtoend/treebuilder/test_treebuilder.py b/tests/endtoend/treebuilder/test_treebuilder.py new file mode 100644 index 0000000..1a5f04d --- /dev/null +++ b/tests/endtoend/treebuilder/test_treebuilder.py @@ -0,0 +1,38 @@ +# +# Copyright (C) 2023 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 for treebuilder.""" +from pathlib import Path + +import pytest + +from .treebuilder import TreeBuilder + + +class TestTreeBuilder: + """Tests for treebuilder.TreeBuilder""" + + def test_repo_tree(self, tmp_path: Path) -> None: + """Tests that a TreeBuilder is created.""" + builder = TreeBuilder(tmp_path) + assert builder.temp_dir == tmp_path + + def test_repo_tree_error_if_name_reused(self, tmp_path: Path) -> None: + """Tests that KeyError is raised if a tree name is reused.""" + builder = TreeBuilder(tmp_path) + builder.repo_tree("foo") + builder.repo_tree("bar") + with pytest.raises(KeyError): + builder.repo_tree("foo") diff --git a/tests/endtoend/treebuilder/treebuilder.py b/tests/endtoend/treebuilder/treebuilder.py new file mode 100644 index 0000000..2218d4e --- /dev/null +++ b/tests/endtoend/treebuilder/treebuilder.py @@ -0,0 +1,34 @@ +# +# Copyright (C) 2023 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. +# +"""Builder for creating repo trees for use in testing.""" +from pathlib import Path + +from .fakerepo import FakeRepo + + +class TreeBuilder: # pylint: disable=too-few-public-methods + """Creates test repo trees in a temporary directory.""" + + def __init__(self, temp_dir: Path) -> None: + self.temp_dir = temp_dir + self._trees: set[str] = set() + + def repo_tree(self, name: str) -> FakeRepo: + """Creates a new repo tree with the given name.""" + if name in self._trees: + raise KeyError(f"A repo tree named {name} already exists") + self._trees.add(name) + return FakeRepo(self.temp_dir / name) -- cgit v1.2.3