pl-e2e-test 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- pl_e2e_test-0.1.0.dist-info/METADATA +322 -0
- pl_e2e_test-0.1.0.dist-info/RECORD +27 -0
- pl_e2e_test-0.1.0.dist-info/WHEEL +5 -0
- pl_e2e_test-0.1.0.dist-info/entry_points.txt +5 -0
- pl_e2e_test-0.1.0.dist-info/licenses/LICENSE +21 -0
- pl_e2e_test-0.1.0.dist-info/top_level.txt +1 -0
- prairielearn_e2e/__init__.py +18 -0
- prairielearn_e2e/__main__.py +3 -0
- prairielearn_e2e/_git.py +167 -0
- prairielearn_e2e/backend.py +168 -0
- prairielearn_e2e/config.json +7 -0
- prairielearn_e2e/config.py +30 -0
- prairielearn_e2e/contract.py +199 -0
- prairielearn_e2e/discovery.py +212 -0
- prairielearn_e2e/errors.py +9 -0
- prairielearn_e2e/fuzz.py +293 -0
- prairielearn_e2e/harness.py +457 -0
- prairielearn_e2e/html_checks.py +133 -0
- prairielearn_e2e/metadata.py +56 -0
- prairielearn_e2e/pytest_plugin.py +335 -0
- prairielearn_e2e/question_tests.py +68 -0
- prairielearn_e2e/regression.py +128 -0
- prairielearn_e2e/reporting.py +675 -0
- prairielearn_e2e/repository.py +298 -0
- prairielearn_e2e/runner.py +28 -0
- prairielearn_e2e/server.py +128 -0
- prairielearn_e2e/sharding.py +46 -0
prairielearn_e2e/fuzz.py
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
"""Run lifecycle fuzz tests for questions changed between two Git refs."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
import secrets
|
|
8
|
+
import shlex
|
|
9
|
+
import subprocess
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
from tempfile import TemporaryDirectory
|
|
13
|
+
|
|
14
|
+
from .discovery import variant_seeds
|
|
15
|
+
from .reporting import write_empty_shard_report
|
|
16
|
+
from .runner import run_tests
|
|
17
|
+
from .sharding import Shard, parse_shard
|
|
18
|
+
|
|
19
|
+
QUESTIONS_ROOT = Path("questions")
|
|
20
|
+
DEFAULT_E2E_DIFF_IGNORE = Path(".question-e2e.diffignore")
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def git_repository_root(start: Path) -> Path:
|
|
24
|
+
"""Return the Git repository containing *start*."""
|
|
25
|
+
|
|
26
|
+
result = subprocess.run(
|
|
27
|
+
["git", "-C", os.fspath(start), "rev-parse", "--show-toplevel"],
|
|
28
|
+
check=True,
|
|
29
|
+
capture_output=True,
|
|
30
|
+
text=True,
|
|
31
|
+
)
|
|
32
|
+
return Path(result.stdout.strip()).resolve()
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def load_e2e_diff_ignore(config_path: Path, *, missing_ok: bool = False) -> list[str]:
|
|
36
|
+
"""Load Git-style ignore rules used by E2E change detection."""
|
|
37
|
+
|
|
38
|
+
try:
|
|
39
|
+
return config_path.read_text(encoding="utf-8").splitlines()
|
|
40
|
+
except FileNotFoundError:
|
|
41
|
+
if missing_ok:
|
|
42
|
+
return []
|
|
43
|
+
raise ValueError(f"Unable to read {config_path}: file does not exist") from None
|
|
44
|
+
except OSError as exc:
|
|
45
|
+
raise ValueError(f"Unable to read {config_path}: {exc}") from exc
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def e2e_diff_ignored_files(
|
|
49
|
+
changed_files: Sequence[Path], ignore_rules: Sequence[str]
|
|
50
|
+
) -> set[Path]:
|
|
51
|
+
"""Return changed files matched using Git's native ignore semantics."""
|
|
52
|
+
|
|
53
|
+
if not changed_files or not ignore_rules:
|
|
54
|
+
return set()
|
|
55
|
+
|
|
56
|
+
with TemporaryDirectory(prefix="e2e-diff-ignore-") as temporary_directory:
|
|
57
|
+
temporary_root = Path(temporary_directory)
|
|
58
|
+
repository = temporary_root / "repository"
|
|
59
|
+
ignore_file = temporary_root / "exclude"
|
|
60
|
+
ignore_file.write_text("\n".join(ignore_rules), encoding="utf-8")
|
|
61
|
+
|
|
62
|
+
subprocess.run(
|
|
63
|
+
["git", "init", "--quiet", os.fspath(repository)],
|
|
64
|
+
check=True,
|
|
65
|
+
stdout=subprocess.DEVNULL,
|
|
66
|
+
stderr=subprocess.PIPE,
|
|
67
|
+
)
|
|
68
|
+
result = subprocess.run(
|
|
69
|
+
[
|
|
70
|
+
"git",
|
|
71
|
+
"-C",
|
|
72
|
+
os.fspath(repository),
|
|
73
|
+
"-c",
|
|
74
|
+
f"core.excludesFile={ignore_file}",
|
|
75
|
+
"check-ignore",
|
|
76
|
+
"--no-index",
|
|
77
|
+
"--stdin",
|
|
78
|
+
"-z",
|
|
79
|
+
],
|
|
80
|
+
check=False,
|
|
81
|
+
capture_output=True,
|
|
82
|
+
input=b"\0".join(os.fsencode(path) for path in changed_files) + b"\0",
|
|
83
|
+
)
|
|
84
|
+
if result.returncode not in (0, 1):
|
|
85
|
+
raise subprocess.CalledProcessError(
|
|
86
|
+
result.returncode,
|
|
87
|
+
result.args,
|
|
88
|
+
output=result.stdout,
|
|
89
|
+
stderr=result.stderr,
|
|
90
|
+
)
|
|
91
|
+
|
|
92
|
+
return {Path(os.fsdecode(path)) for path in result.stdout.split(b"\0") if path}
|
|
93
|
+
|
|
94
|
+
|
|
95
|
+
def nonignored_files_outside_questions(
|
|
96
|
+
changed_files: Sequence[Path], ignore_rules: Sequence[str]
|
|
97
|
+
) -> list[Path]:
|
|
98
|
+
"""Return sorted changed files that cause every question to be tested."""
|
|
99
|
+
|
|
100
|
+
ignored_files = e2e_diff_ignored_files(changed_files, ignore_rules)
|
|
101
|
+
return sorted(
|
|
102
|
+
(
|
|
103
|
+
path
|
|
104
|
+
for path in changed_files
|
|
105
|
+
if QUESTIONS_ROOT not in path.parents and path not in ignored_files
|
|
106
|
+
),
|
|
107
|
+
key=lambda path: path.as_posix(),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def log_full_question_run(
|
|
112
|
+
changed_files: Sequence[Path], ignored_patterns: Sequence[str]
|
|
113
|
+
) -> None:
|
|
114
|
+
"""Log the changed files responsible for testing every question."""
|
|
115
|
+
|
|
116
|
+
outside_files = nonignored_files_outside_questions(changed_files, ignored_patterns)
|
|
117
|
+
print("Non-ignored changed files outside questions; testing every question:")
|
|
118
|
+
for path in outside_files:
|
|
119
|
+
print(f" - {shlex.quote(path.as_posix())}")
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
def question_paths_from_changed_files(
|
|
123
|
+
changed_files: Sequence[Path],
|
|
124
|
+
repository_root: Path,
|
|
125
|
+
ignore_rules: Sequence[str] = (),
|
|
126
|
+
) -> list[Path]:
|
|
127
|
+
"""Select changed questions, or all questions after a shared-file change."""
|
|
128
|
+
|
|
129
|
+
ignored_files = e2e_diff_ignored_files(changed_files, ignore_rules)
|
|
130
|
+
relevant_files = [path for path in changed_files if path not in ignored_files]
|
|
131
|
+
if any(QUESTIONS_ROOT not in path.parents for path in relevant_files):
|
|
132
|
+
return [QUESTIONS_ROOT]
|
|
133
|
+
|
|
134
|
+
question_paths: set[Path] = set()
|
|
135
|
+
for changed_file in relevant_files:
|
|
136
|
+
candidate = changed_file.parent
|
|
137
|
+
while candidate != QUESTIONS_ROOT and QUESTIONS_ROOT in candidate.parents:
|
|
138
|
+
if (repository_root / candidate / "info.json").is_file():
|
|
139
|
+
question_paths.add(candidate)
|
|
140
|
+
break
|
|
141
|
+
candidate = candidate.parent
|
|
142
|
+
|
|
143
|
+
return sorted(question_paths, key=lambda path: path.as_posix())
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def changed_files_between(base: str, head: str, repository_root: Path) -> list[Path]:
|
|
147
|
+
"""Return repository-relative files changed between two Git refs."""
|
|
148
|
+
|
|
149
|
+
result = subprocess.run(
|
|
150
|
+
[
|
|
151
|
+
"git",
|
|
152
|
+
"diff",
|
|
153
|
+
"--name-only",
|
|
154
|
+
"-z",
|
|
155
|
+
"--no-renames",
|
|
156
|
+
"--diff-filter=ACDMRT",
|
|
157
|
+
base,
|
|
158
|
+
head,
|
|
159
|
+
"--",
|
|
160
|
+
],
|
|
161
|
+
cwd=repository_root,
|
|
162
|
+
check=True,
|
|
163
|
+
stdout=subprocess.PIPE,
|
|
164
|
+
)
|
|
165
|
+
return [Path(os.fsdecode(path)) for path in result.stdout.split(b"\0") if path]
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def positive_integer(value: str) -> int:
|
|
169
|
+
parsed = int(value)
|
|
170
|
+
if parsed < 1:
|
|
171
|
+
raise argparse.ArgumentTypeError("must be at least 1")
|
|
172
|
+
if parsed > 2**32:
|
|
173
|
+
raise argparse.ArgumentTypeError("must not exceed 2**32")
|
|
174
|
+
return parsed
|
|
175
|
+
|
|
176
|
+
|
|
177
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
178
|
+
parser = argparse.ArgumentParser(description=__doc__)
|
|
179
|
+
parser.add_argument("--course-root", type=Path)
|
|
180
|
+
parser.add_argument("--base", default="main", help="base Git ref (default: main)")
|
|
181
|
+
parser.add_argument("--head", default="HEAD", help="head Git ref (default: HEAD)")
|
|
182
|
+
parser.add_argument("--seed-count", type=positive_integer, default=10)
|
|
183
|
+
parser.add_argument(
|
|
184
|
+
"--fuzz-seed",
|
|
185
|
+
type=int,
|
|
186
|
+
help="master seed for reproducible pseudo-random variant seeds",
|
|
187
|
+
)
|
|
188
|
+
parser.add_argument(
|
|
189
|
+
"--prairielearn-path",
|
|
190
|
+
type=Path,
|
|
191
|
+
help=(
|
|
192
|
+
"PrairieLearn Git repository containing the installed pinned revision "
|
|
193
|
+
"(default: managed user cache)"
|
|
194
|
+
),
|
|
195
|
+
)
|
|
196
|
+
parser.add_argument(
|
|
197
|
+
"--e2ediffignore",
|
|
198
|
+
type=Path,
|
|
199
|
+
help="E2E change-detection ignore file (default: COURSE_ROOT/.question-e2e.diffignore)",
|
|
200
|
+
)
|
|
201
|
+
parser.add_argument(
|
|
202
|
+
"--shard",
|
|
203
|
+
type=parse_shard,
|
|
204
|
+
metavar="INDEX/COUNT",
|
|
205
|
+
help="run one one-based shard of the selected question variants",
|
|
206
|
+
)
|
|
207
|
+
parser.add_argument(
|
|
208
|
+
"--shard-report",
|
|
209
|
+
type=Path,
|
|
210
|
+
metavar="PATH",
|
|
211
|
+
help="write a mergeable JSON result blob for this shard",
|
|
212
|
+
)
|
|
213
|
+
args, pytest_args = parser.parse_known_args(argv)
|
|
214
|
+
shard: Shard | None = args.shard
|
|
215
|
+
if args.shard_report is not None and shard is None:
|
|
216
|
+
parser.error("--shard-report requires --shard")
|
|
217
|
+
if shard is not None and args.fuzz_seed is None:
|
|
218
|
+
parser.error("sharded fuzz runs require an explicit shared --fuzz-seed")
|
|
219
|
+
|
|
220
|
+
try:
|
|
221
|
+
repository_root = (
|
|
222
|
+
args.course_root.resolve()
|
|
223
|
+
if args.course_root is not None
|
|
224
|
+
else git_repository_root(Path.cwd())
|
|
225
|
+
)
|
|
226
|
+
except (OSError, subprocess.CalledProcessError) as exc:
|
|
227
|
+
parser.error(f"could not find the course Git repository: {exc}")
|
|
228
|
+
|
|
229
|
+
ignore_path = args.e2ediffignore
|
|
230
|
+
if ignore_path is None:
|
|
231
|
+
ignore_path = repository_root / DEFAULT_E2E_DIFF_IGNORE
|
|
232
|
+
missing_ok = True
|
|
233
|
+
else:
|
|
234
|
+
if not ignore_path.is_absolute():
|
|
235
|
+
ignore_path = repository_root / ignore_path
|
|
236
|
+
missing_ok = False
|
|
237
|
+
try:
|
|
238
|
+
ignored_patterns = load_e2e_diff_ignore(ignore_path, missing_ok=missing_ok)
|
|
239
|
+
except ValueError as exc:
|
|
240
|
+
parser.error(str(exc))
|
|
241
|
+
|
|
242
|
+
changed_files = changed_files_between(args.base, args.head, repository_root)
|
|
243
|
+
question_paths = question_paths_from_changed_files(
|
|
244
|
+
changed_files, repository_root, ignored_patterns
|
|
245
|
+
)
|
|
246
|
+
if not question_paths:
|
|
247
|
+
print("No non-ignored changed questions; skipping end-to-end fuzz tests.")
|
|
248
|
+
if args.shard_report is not None:
|
|
249
|
+
assert shard is not None
|
|
250
|
+
write_empty_shard_report(args.shard_report, shard, fuzz_seed=args.fuzz_seed)
|
|
251
|
+
return 0
|
|
252
|
+
if question_paths == [QUESTIONS_ROOT]:
|
|
253
|
+
log_full_question_run(changed_files, ignored_patterns)
|
|
254
|
+
|
|
255
|
+
fuzz_seed = args.fuzz_seed
|
|
256
|
+
if fuzz_seed is None:
|
|
257
|
+
fuzz_seed = secrets.randbits(64)
|
|
258
|
+
seeds = variant_seeds({}, args.seed_count, fuzz_seed)
|
|
259
|
+
print(f"E2E fuzz seed: {fuzz_seed}")
|
|
260
|
+
print(f"Variant seeds: {', '.join(str(seed) for seed in seeds)}")
|
|
261
|
+
replay_arguments = ["pl-e2e-test-diff", "--fuzz-seed", str(fuzz_seed)]
|
|
262
|
+
test_arguments: list[str] = []
|
|
263
|
+
if args.prairielearn_path is not None:
|
|
264
|
+
replay_arguments.extend(
|
|
265
|
+
("--prairielearn-path", os.fspath(args.prairielearn_path))
|
|
266
|
+
)
|
|
267
|
+
test_arguments.extend(
|
|
268
|
+
("--prairielearn-path", os.fspath(args.prairielearn_path))
|
|
269
|
+
)
|
|
270
|
+
if shard is not None:
|
|
271
|
+
replay_arguments.extend(("--shard", str(shard)))
|
|
272
|
+
test_arguments.extend(("--shard", str(shard)))
|
|
273
|
+
if args.shard_report is not None:
|
|
274
|
+
test_arguments.extend(("--shard-report", os.fspath(args.shard_report)))
|
|
275
|
+
print(f"Replay with: {shlex.join(replay_arguments)}", flush=True)
|
|
276
|
+
|
|
277
|
+
test_arguments.extend(
|
|
278
|
+
[
|
|
279
|
+
"--seed-count",
|
|
280
|
+
str(args.seed_count),
|
|
281
|
+
"--fuzz-seed",
|
|
282
|
+
str(fuzz_seed),
|
|
283
|
+
]
|
|
284
|
+
)
|
|
285
|
+
for question_path in question_paths:
|
|
286
|
+
print(
|
|
287
|
+
f"Testing {shlex.quote(os.fspath(question_path))} "
|
|
288
|
+
f"with {args.seed_count} fuzz seeds.",
|
|
289
|
+
flush=True,
|
|
290
|
+
)
|
|
291
|
+
test_arguments.extend(("--question-path", os.fspath(question_path)))
|
|
292
|
+
test_arguments.extend(pytest_args)
|
|
293
|
+
return run_tests(test_arguments, course_root=repository_root)
|