aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-12-15 00:44:53 +0000
committerAndroid Build Coastguard Worker <android-build-coastguard-worker@google.com>2023-12-15 00:44:53 +0000
commit966c01016bbf451e29cd9c135773dbf8316451b0 (patch)
tree25d96943bba27c6e95d1dad56c91fe194ee583f4
parentff21b28b584e3ff7eb9325fa9e4cb385f6932269 (diff)
parent522e58fe3f52001eab32107bb7bd4733c984fcb4 (diff)
downloadexternal_updater-android14-qpr2-s2-release.tar.gz
Change-Id: Ia60973fed5f9d9013124c442235a1da8739f3ff9
-rw-r--r--Android.bp7
-rw-r--r--tests/__init__.py0
-rw-r--r--tests/endtoend/__init__.py0
-rw-r--r--tests/endtoend/conftest.py85
-rw-r--r--tests/endtoend/test_check.py86
-rw-r--r--tests/endtoend/treebuilder/__init__.py4
-rw-r--r--tests/endtoend/treebuilder/fakeproject.py94
-rw-r--r--tests/endtoend/treebuilder/fakerepo.py108
-rw-r--r--tests/endtoend/treebuilder/gitrepo.py114
-rw-r--r--tests/endtoend/treebuilder/test_fakeproject.py61
-rw-r--r--tests/endtoend/treebuilder/test_fakerepo.py85
-rw-r--r--tests/endtoend/treebuilder/test_gitrepo.py67
-rw-r--r--tests/endtoend/treebuilder/test_treebuilder.py38
-rw-r--r--tests/endtoend/treebuilder/treebuilder.py34
14 files changed, 783 insertions, 0 deletions
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
--- /dev/null
+++ b/tests/__init__.py
diff --git a/tests/endtoend/__init__.py b/tests/endtoend/__init__.py
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/tests/endtoend/__init__.py
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:
+ #
+ # <manifest>
+ # <remote name="aosp" fetch="$URL" />
+ # <default revision="main" remote="aosp" />
+ #
+ # <project path="external/project" name="platform/external/project"
+ # revision="master" remote="goog" />
+ # ...
+ # </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)