|
5 | 5 |
|
6 | 6 | import pathlib |
7 | 7 | import re |
| 8 | +import shutil |
8 | 9 | import subprocess |
9 | 10 |
|
10 | 11 | import pytest |
11 | 12 |
|
12 | 13 | from frequenz.repo import config |
13 | 14 |
|
| 15 | +UPDATE_GOLDEN: bool = False |
| 16 | +"""Set to True to update the golden files. |
| 17 | +
|
| 18 | +After setting to True you need to run the tests once to update the golden files. |
| 19 | +
|
| 20 | +Make sure to review the changes before committing them and setting this back to False. |
| 21 | +""" |
| 22 | + |
| 23 | + |
| 24 | +@pytest.mark.integration |
| 25 | +@pytest.mark.parametrize("repo_type", [*config.RepositoryType]) |
| 26 | +def test_golden( |
| 27 | + tmp_path: pathlib.Path, |
| 28 | + repo_type: config.RepositoryType, |
| 29 | + request: pytest.FixtureRequest, |
| 30 | +) -> None: |
| 31 | + """Test generation of a new repo comparing it to a golden tree.""" |
| 32 | + cwd = pathlib.Path().cwd() |
| 33 | + golden_path = ( |
| 34 | + cwd |
| 35 | + / "tests_golden" |
| 36 | + / request.path.relative_to(cwd / "tests").with_suffix("") |
| 37 | + / repo_type.value |
| 38 | + ) |
| 39 | + |
| 40 | + generated_repo_path, run_result = _generate_repo( |
| 41 | + repo_type, tmp_path, capture_output=True |
| 42 | + ) |
| 43 | + stdout, stderr = _filter_generation_output(run_result) |
| 44 | + _assert_golden_file(golden_path, "cookiecutter-stdout", stdout) |
| 45 | + _assert_golden_file(golden_path, "cookiecutter-stderr", stderr) |
| 46 | + _assert_golden_tree( |
| 47 | + generated_repo_path=generated_repo_path, |
| 48 | + golden_tree=golden_path / generated_repo_path.name, |
| 49 | + ) |
| 50 | + |
14 | 51 |
|
15 | 52 | @pytest.mark.integration |
16 | 53 | @pytest.mark.parametrize("repo_type", [*config.RepositoryType]) |
@@ -66,6 +103,144 @@ def _run( |
66 | 103 | return subprocess.run(cmd, cwd=cwd, check=check, capture_output=capture_output) |
67 | 104 |
|
68 | 105 |
|
| 106 | +def _read_golden_file(golden_path: pathlib.Path, name: str) -> str: |
| 107 | + """Read a golden file. |
| 108 | +
|
| 109 | + File names will be appended with ".txt". |
| 110 | +
|
| 111 | + Args: |
| 112 | + golden_path: The path to the directory containing the golden files. |
| 113 | + name: The name of the golden file. |
| 114 | +
|
| 115 | + Returns: |
| 116 | + The contents of the file. |
| 117 | + """ |
| 118 | + with open( |
| 119 | + golden_path / f"{name}.txt", "r", encoding="utf8", errors="replace" |
| 120 | + ) as golden_file: |
| 121 | + return golden_file.read() |
| 122 | + |
| 123 | + |
| 124 | +def _write_golden_file(golden_path: pathlib.Path, name: str, contents: str) -> int: |
| 125 | + """Write a golden file. |
| 126 | +
|
| 127 | + File names will be appended with ".txt". |
| 128 | +
|
| 129 | + Args: |
| 130 | + golden_path: The path to the directory containing the golden files. |
| 131 | + name: The name of the golden file. |
| 132 | +
|
| 133 | + Returns: |
| 134 | + The number of bytes written. |
| 135 | + """ |
| 136 | + golden_path.mkdir(parents=True, exist_ok=True) |
| 137 | + with open( |
| 138 | + golden_path / f"{name}.txt", "w", encoding="utf8", errors="replace" |
| 139 | + ) as golden_file: |
| 140 | + return golden_file.write(contents) |
| 141 | + |
| 142 | + |
| 143 | +def _assert_golden_file( |
| 144 | + golden_path: pathlib.Path, |
| 145 | + name: str, |
| 146 | + new_result: str | bytes | subprocess.CompletedProcess[bytes], |
| 147 | +) -> None: |
| 148 | + if isinstance(new_result, subprocess.CompletedProcess): |
| 149 | + _assert_golden_file(golden_path, f"{name}-stdout", new_result.stdout) |
| 150 | + _assert_golden_file(golden_path, f"{name}-stderr", new_result.stderr) |
| 151 | + return |
| 152 | + |
| 153 | + if isinstance(new_result, bytes): |
| 154 | + new_result = new_result.decode("utf-8", "replace") |
| 155 | + if UPDATE_GOLDEN: |
| 156 | + _write_golden_file(golden_path, name, new_result) |
| 157 | + else: |
| 158 | + assert new_result == _read_golden_file(golden_path, name) |
| 159 | + |
| 160 | + |
| 161 | +def _read_golden_tree( |
| 162 | + *, generated_repo_path: pathlib.Path, golden_tree: pathlib.Path |
| 163 | +) -> subprocess.CompletedProcess[bytes]: |
| 164 | + """Read a golden tree. |
| 165 | +
|
| 166 | + The `diff` command is used to compare the generated tree with the golden tree. |
| 167 | +
|
| 168 | + Args: |
| 169 | + generated_repo_path: The path to the generated repository tree. |
| 170 | + golden_tree: The path to the golden tree. |
| 171 | +
|
| 172 | + Returns: |
| 173 | + The result of the `diff` command. |
| 174 | + """ |
| 175 | + return _run( |
| 176 | + generated_repo_path, |
| 177 | + "diff", |
| 178 | + "-ru", |
| 179 | + str(generated_repo_path), |
| 180 | + str(golden_tree), |
| 181 | + check=False, |
| 182 | + capture_output=True, |
| 183 | + ) |
| 184 | + |
| 185 | + |
| 186 | +def _write_golden_tree( |
| 187 | + *, generated_repo_path: pathlib.Path, golden_tree: pathlib.Path |
| 188 | +) -> None: |
| 189 | + """Write a golden tree. |
| 190 | +
|
| 191 | + Replace all files in the golden tree with the files from the generated tree. |
| 192 | +
|
| 193 | + Args: |
| 194 | + generated_repo_path: The path to the generated repository tree. |
| 195 | + golden_tree: The path to the golden tree. |
| 196 | + """ |
| 197 | + if golden_tree.exists(): |
| 198 | + shutil.rmtree(golden_tree) |
| 199 | + shutil.copytree(generated_repo_path, golden_tree, dirs_exist_ok=True) |
| 200 | + |
| 201 | + |
| 202 | +def _assert_golden_tree( |
| 203 | + *, generated_repo_path: pathlib.Path, golden_tree: pathlib.Path |
| 204 | +) -> None: |
| 205 | + if UPDATE_GOLDEN: |
| 206 | + _write_golden_tree( |
| 207 | + generated_repo_path=generated_repo_path, golden_tree=golden_tree |
| 208 | + ) |
| 209 | + else: |
| 210 | + result = _read_golden_tree( |
| 211 | + generated_repo_path=generated_repo_path, golden_tree=golden_tree |
| 212 | + ) |
| 213 | + if result.returncode != 0: |
| 214 | + print("Generated repo differs from golden repo:") |
| 215 | + print() |
| 216 | + print("STDOUT:") |
| 217 | + print("-" * 80) |
| 218 | + print(result.stdout.decode("utf-8")) |
| 219 | + print("-" * 80) |
| 220 | + print("STDERR:") |
| 221 | + print("-" * 80) |
| 222 | + print(result.stderr.decode("utf-8")) |
| 223 | + print("-" * 80) |
| 224 | + assert result.returncode == 0 |
| 225 | + |
| 226 | + |
| 227 | +def _filter_generation_output( |
| 228 | + result: subprocess.CompletedProcess[bytes], / |
| 229 | +) -> tuple[bytes, bytes]: |
| 230 | + """Filter out some lines from the output. |
| 231 | +
|
| 232 | + This is necessary because the output of cookiecutter is not deterministic, so we |
| 233 | + just remove all non-deterministic lines. These are lines that contain the path to |
| 234 | + the generated repo (a temporary directory). |
| 235 | + """ |
| 236 | + stdout = b"\n".join( |
| 237 | + l |
| 238 | + for l in result.stdout.splitlines() |
| 239 | + if not l.startswith((b"WARNING: The replay file's `_template` (",)) |
| 240 | + ) |
| 241 | + return stdout, result.stderr |
| 242 | + |
| 243 | + |
69 | 244 | def _update_pyproject_repo_config_dep( |
70 | 245 | *, |
71 | 246 | repo_config_path: pathlib.Path, |
|
0 commit comments