pl-e2e-test 0.1.4__tar.gz → 0.1.5__tar.gz

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.
Files changed (57) hide show
  1. {pl_e2e_test-0.1.4/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.5}/PKG-INFO +26 -3
  2. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/README.md +25 -2
  3. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/pyproject.toml +7 -1
  4. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5/src/pl_e2e_test.egg-info}/PKG-INFO +26 -3
  5. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/SOURCES.txt +2 -0
  6. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/discovery.py +106 -3
  7. pl_e2e_test-0.1.5/src/prairielearn_e2e/expected_errors.py +62 -0
  8. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/pytest_plugin.py +51 -1
  9. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/question_tests.py +27 -0
  10. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/reporting.py +82 -1
  11. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_discovery.py +86 -0
  12. pl_e2e_test-0.1.5/tests/test_pytest_plugin.py +202 -0
  13. pl_e2e_test-0.1.5/tests/test_question_tests.py +130 -0
  14. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_reporting.py +114 -0
  15. pl_e2e_test-0.1.4/tests/test_pytest_plugin.py +0 -86
  16. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/LICENSE +0 -0
  17. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/MANIFEST.in +0 -0
  18. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/setup.cfg +0 -0
  19. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  20. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  21. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  22. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  23. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__init__.py +0 -0
  24. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__main__.py +0 -0
  25. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/_git.py +0 -0
  26. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/backend.py +0 -0
  27. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/cli.py +0 -0
  28. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/config.py +0 -0
  29. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/contract.py +0 -0
  30. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/errors.py +0 -0
  31. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/fuzz.py +0 -0
  32. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/harness.py +0 -0
  33. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/html_checks.py +0 -0
  34. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/metadata.py +0 -0
  35. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/parallel.py +0 -0
  36. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/regression.py +0 -0
  37. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/repository.py +0 -0
  38. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/runner.py +0 -0
  39. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/server.py +0 -0
  40. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/sharding.py +0 -0
  41. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/__init__.py +0 -0
  42. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/conftest.py +0 -0
  43. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/stub_git.py +0 -0
  44. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_cli.py +0 -0
  45. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_config.py +0 -0
  46. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_contract.py +0 -0
  47. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_fuzz.py +0 -0
  48. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_git.py +0 -0
  49. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_harness.py +0 -0
  50. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_html_checks.py +0 -0
  51. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_metadata.py +0 -0
  52. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_parallel.py +0 -0
  53. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_publish_version.py +0 -0
  54. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_regression.py +0 -0
  55. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_repository.py +0 -0
  56. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_server.py +0 -0
  57. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.5}/tests/test_sharding.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pl-e2e-test
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
@@ -276,14 +276,22 @@ Use `--config path/to/settings.toml` to select another TOML file with the same
276
276
 
277
277
  ### Question-local configuration
278
278
 
279
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
280
- question's `info.json`:
279
+ Store saved concrete seeds, narrow phase skips, and expected upstream errors in
280
+ `.question-e2e.json` beside the question's `info.json`:
281
281
 
282
282
  ```json
283
283
  {
284
284
  "regression_seeds": [1843927501],
285
285
  "skip_methods": {
286
286
  "grade": "Temporarily blocked by an upstream issue"
287
+ },
288
+ "expected_errors": {
289
+ "render": [
290
+ {
291
+ "match": "^render: known upstream failure$",
292
+ "reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
293
+ }
294
+ ]
287
295
  }
288
296
  }
289
297
  ```
@@ -292,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
292
300
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
293
301
  questions automatically skip only the grade check.
294
302
 
303
+ Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
304
+ known upstream defect. Each rule requires a human-readable reason or issue URL and
305
+ exactly one matcher: either `type`, containing a fully qualified exception type, or
306
+ `match`, containing a Python regular expression searched against the exception message.
307
+ A matching exception is reported as `XFAIL`; a different exception fails normally,
308
+ while seeds that do not raise an exception pass normally. A method may not be both
309
+ skipped and expected to fail.
310
+
311
+ Rules under one method are alternatives. At least one rule must match across the selected
312
+ seeds for that question and method, or an otherwise-successful run fails an audit asking
313
+ you to remove or update the stale expectation. Parallel runs perform this audit after
314
+ merging all shards. Because one upstream error can prevent later code from running,
315
+ obsolete individual alternatives cannot be detected while another alternative still
316
+ matches.
317
+
295
318
  Record the concrete seed shown in a failing pytest case with:
296
319
 
297
320
  ```sh
@@ -252,14 +252,22 @@ Use `--config path/to/settings.toml` to select another TOML file with the same
252
252
 
253
253
  ### Question-local configuration
254
254
 
255
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
256
- question's `info.json`:
255
+ Store saved concrete seeds, narrow phase skips, and expected upstream errors in
256
+ `.question-e2e.json` beside the question's `info.json`:
257
257
 
258
258
  ```json
259
259
  {
260
260
  "regression_seeds": [1843927501],
261
261
  "skip_methods": {
262
262
  "grade": "Temporarily blocked by an upstream issue"
263
+ },
264
+ "expected_errors": {
265
+ "render": [
266
+ {
267
+ "match": "^render: known upstream failure$",
268
+ "reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
269
+ }
270
+ ]
263
271
  }
264
272
  }
265
273
  ```
@@ -268,6 +276,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
268
276
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
269
277
  questions automatically skip only the grade check.
270
278
 
279
+ Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
280
+ known upstream defect. Each rule requires a human-readable reason or issue URL and
281
+ exactly one matcher: either `type`, containing a fully qualified exception type, or
282
+ `match`, containing a Python regular expression searched against the exception message.
283
+ A matching exception is reported as `XFAIL`; a different exception fails normally,
284
+ while seeds that do not raise an exception pass normally. A method may not be both
285
+ skipped and expected to fail.
286
+
287
+ Rules under one method are alternatives. At least one rule must match across the selected
288
+ seeds for that question and method, or an otherwise-successful run fails an audit asking
289
+ you to remove or update the stale expectation. Parallel runs perform this audit after
290
+ merging all shards. Because one upstream error can prevent later code from running,
291
+ obsolete individual alternatives cannot be detected while another alternative still
292
+ matches.
293
+
271
294
  Record the concrete seed shown in a failing pytest case with:
272
295
 
273
296
  ```sh
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "pl-e2e-test"
7
- version = "0.1.4"
7
+ version = "0.1.5"
8
8
  description = "Fuzz PrairieLearn question lifecycles against the real Python element runtime"
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.13"
@@ -39,6 +39,12 @@ where = ["src"]
39
39
 
40
40
  [tool.pytest.ini_options]
41
41
  testpaths = ["tests"]
42
+ markers = [
43
+ "generate: question generate-phase lifecycle checks",
44
+ "prepare: question prepare-phase lifecycle checks",
45
+ "render: question render-phase lifecycle checks",
46
+ "grade: question grading lifecycle checks",
47
+ ]
42
48
 
43
49
  [tool.pyright]
44
50
  include = ["src/prairielearn_e2e", "tests"]
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: pl-e2e-test
3
- Version: 0.1.4
3
+ Version: 0.1.5
4
4
  Summary: Fuzz PrairieLearn question lifecycles against the real Python element runtime
5
5
  License-Expression: MIT
6
6
  Project-URL: Homepage, https://github.com/PrairieLearn/pl-e2e-test
@@ -276,14 +276,22 @@ Use `--config path/to/settings.toml` to select another TOML file with the same
276
276
 
277
277
  ### Question-local configuration
278
278
 
279
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
280
- question's `info.json`:
279
+ Store saved concrete seeds, narrow phase skips, and expected upstream errors in
280
+ `.question-e2e.json` beside the question's `info.json`:
281
281
 
282
282
  ```json
283
283
  {
284
284
  "regression_seeds": [1843927501],
285
285
  "skip_methods": {
286
286
  "grade": "Temporarily blocked by an upstream issue"
287
+ },
288
+ "expected_errors": {
289
+ "render": [
290
+ {
291
+ "match": "^render: known upstream failure$",
292
+ "reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
293
+ }
294
+ ]
287
295
  }
288
296
  }
289
297
  ```
@@ -292,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
292
300
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
293
301
  questions automatically skip only the grade check.
294
302
 
303
+ Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
304
+ known upstream defect. Each rule requires a human-readable reason or issue URL and
305
+ exactly one matcher: either `type`, containing a fully qualified exception type, or
306
+ `match`, containing a Python regular expression searched against the exception message.
307
+ A matching exception is reported as `XFAIL`; a different exception fails normally,
308
+ while seeds that do not raise an exception pass normally. A method may not be both
309
+ skipped and expected to fail.
310
+
311
+ Rules under one method are alternatives. At least one rule must match across the selected
312
+ seeds for that question and method, or an otherwise-successful run fails an audit asking
313
+ you to remove or update the stale expectation. Parallel runs perform this audit after
314
+ merging all shards. Because one upstream error can prevent later code from running,
315
+ obsolete individual alternatives cannot be detected while another alternative still
316
+ matches.
317
+
295
318
  Record the concrete seed shown in a failing pytest case with:
296
319
 
297
320
  ```sh
@@ -17,6 +17,7 @@ src/prairielearn_e2e/config.py
17
17
  src/prairielearn_e2e/contract.py
18
18
  src/prairielearn_e2e/discovery.py
19
19
  src/prairielearn_e2e/errors.py
20
+ src/prairielearn_e2e/expected_errors.py
20
21
  src/prairielearn_e2e/fuzz.py
21
22
  src/prairielearn_e2e/harness.py
22
23
  src/prairielearn_e2e/html_checks.py
@@ -45,6 +46,7 @@ tests/test_metadata.py
45
46
  tests/test_parallel.py
46
47
  tests/test_publish_version.py
47
48
  tests/test_pytest_plugin.py
49
+ tests/test_question_tests.py
48
50
  tests/test_regression.py
49
51
  tests/test_reporting.py
50
52
  tests/test_repository.py
@@ -1,8 +1,9 @@
1
1
  from __future__ import annotations
2
2
 
3
3
  import random
4
+ import re
4
5
  from collections.abc import Sequence
5
- from dataclasses import dataclass
6
+ from dataclasses import dataclass, field
6
7
  from pathlib import Path
7
8
  from typing import Any, Literal
8
9
 
@@ -22,25 +23,49 @@ LIFECYCLE_METHODS: frozenset[str] = frozenset(
22
23
  )
23
24
 
24
25
 
26
+ @dataclass(frozen=True, slots=True)
27
+ class ExpectedError:
28
+ """One acceptable exception signature for a lifecycle method."""
29
+
30
+ reason: str
31
+ exception_type: str | None = None
32
+ match: str | None = None
33
+
34
+ def matches(self, exception: Exception) -> bool:
35
+ if self.exception_type is not None:
36
+ exception_type = type(exception)
37
+ qualified_type = (
38
+ f"{exception_type.__module__}.{exception_type.__qualname__}"
39
+ )
40
+ return self.exception_type == qualified_type
41
+ assert self.match is not None
42
+ return re.search(self.match, str(exception)) is not None
43
+
44
+
25
45
  @dataclass(frozen=True, slots=True)
26
46
  class E2EConfig:
27
47
  """Question-local end-to-end test configuration."""
28
48
 
29
49
  skip_methods: dict[str, str]
30
50
  regression_seeds: tuple[int, ...]
51
+ expected_errors: dict[str, tuple[ExpectedError, ...]] = field(default_factory=dict)
31
52
 
32
53
 
33
54
  def load_e2e_config(config_path: Path) -> E2EConfig:
34
55
  """Load and validate a question-local E2E configuration file."""
35
56
 
36
57
  if not config_path.is_file():
37
- return E2EConfig(skip_methods={}, regression_seeds=())
58
+ return E2EConfig(skip_methods={}, regression_seeds=(), expected_errors={})
38
59
 
39
60
  config = load_json(config_path)
40
61
  if not isinstance(config, dict):
41
62
  raise HarnessError(f"{config_path}: top-level JSON value must be an object")
42
63
 
43
- if unknown_keys := set(config) - {"regression_seeds", "skip_methods"}:
64
+ if unknown_keys := set(config) - {
65
+ "expected_errors",
66
+ "regression_seeds",
67
+ "skip_methods",
68
+ }:
44
69
  raise HarnessError(
45
70
  f"{config_path}: unknown keys: {', '.join(sorted(unknown_keys))}"
46
71
  )
@@ -62,6 +87,80 @@ def load_e2e_config(config_path: Path) -> E2EConfig:
62
87
  )
63
88
  validated_skip_methods[method] = reason.strip()
64
89
 
90
+ expected_errors = config.get("expected_errors", {})
91
+ if not isinstance(expected_errors, dict):
92
+ raise HarnessError(f"{config_path}: expected_errors must be an object")
93
+ if unknown_methods := set(expected_errors) - LIFECYCLE_METHODS:
94
+ raise HarnessError(
95
+ f"{config_path}: unknown expected-error methods: "
96
+ f"{', '.join(sorted(unknown_methods))}"
97
+ )
98
+ if conflicts := set(expected_errors) & set(validated_skip_methods):
99
+ raise HarnessError(
100
+ f"{config_path}: methods cannot be both skipped and expected to fail: "
101
+ f"{', '.join(sorted(conflicts))}"
102
+ )
103
+
104
+ validated_expected_errors: dict[str, tuple[ExpectedError, ...]] = {}
105
+ allowed_rule_keys = {"type", "match", "reason"}
106
+ for method, rules in expected_errors.items():
107
+ context = f"{config_path}: expected_errors[{method!r}]"
108
+ if not isinstance(rules, list) or not rules:
109
+ raise HarnessError(f"{context} must be a non-empty array")
110
+ validated_rules: list[ExpectedError] = []
111
+ for index, rule in enumerate(rules):
112
+ rule_context = f"{context}[{index}]"
113
+ if not isinstance(rule, dict):
114
+ raise HarnessError(f"{rule_context} must be an object")
115
+ if unknown_rule_keys := set(rule) - allowed_rule_keys:
116
+ raise HarnessError(
117
+ f"{rule_context} has unknown keys: "
118
+ f"{', '.join(sorted(unknown_rule_keys))}"
119
+ )
120
+ if "reason" not in rule:
121
+ raise HarnessError(f"{rule_context} is missing key: reason")
122
+ has_type = "type" in rule
123
+ has_match = "match" in rule
124
+ if has_type == has_match:
125
+ raise HarnessError(
126
+ f"{rule_context} must contain exactly one of type or match"
127
+ )
128
+ exception_type = rule.get("type")
129
+ match = rule.get("match")
130
+ reason = rule["reason"]
131
+ if has_type:
132
+ if not isinstance(exception_type, str):
133
+ raise HarnessError(
134
+ f"{rule_context}.type must be a fully qualified exception type"
135
+ )
136
+ type_parts = exception_type.strip().split(".")
137
+ if len(type_parts) < 2 or not all(
138
+ part.isidentifier() for part in type_parts
139
+ ):
140
+ raise HarnessError(
141
+ f"{rule_context}.type must be a fully qualified exception type"
142
+ )
143
+ exception_type = exception_type.strip()
144
+ else:
145
+ if not isinstance(match, str) or not match:
146
+ raise HarnessError(
147
+ f"{rule_context}.match must be a non-empty string"
148
+ )
149
+ try:
150
+ re.compile(match)
151
+ except re.error as exc:
152
+ raise HarnessError(
153
+ f"{rule_context}.match is not a valid regular expression: {exc}"
154
+ ) from exc
155
+ if not isinstance(reason, str) or not reason.strip():
156
+ raise HarnessError(f"{rule_context}.reason must be a non-empty string")
157
+ validated_rules.append(
158
+ ExpectedError(
159
+ reason=reason.strip(), exception_type=exception_type, match=match
160
+ )
161
+ )
162
+ validated_expected_errors[method] = tuple(validated_rules)
163
+
65
164
  regression_seeds = config.get("regression_seeds", [])
66
165
  if not isinstance(regression_seeds, list):
67
166
  raise HarnessError(f"{config_path}: regression_seeds must be an array")
@@ -88,6 +187,7 @@ def load_e2e_config(config_path: Path) -> E2EConfig:
88
187
  return E2EConfig(
89
188
  skip_methods=validated_skip_methods,
90
189
  regression_seeds=tuple(validated_regression_seeds),
190
+ expected_errors=validated_expected_errors,
91
191
  )
92
192
 
93
193
 
@@ -126,6 +226,9 @@ class QuestionCase:
126
226
  def e2e_skip_reason(self, method: LifecycleMethod) -> str | None:
127
227
  return self.e2e_config().skip_methods.get(method)
128
228
 
229
+ def e2e_expected_errors(self, method: LifecycleMethod) -> tuple[ExpectedError, ...]:
230
+ return self.e2e_config().expected_errors.get(method, ())
231
+
129
232
  def unvalidated_metadata(self) -> dict[str, Any]:
130
233
  value = load_json(self.info_path)
131
234
  return value if isinstance(value, dict) else {}
@@ -0,0 +1,62 @@
1
+ """Session-level accounting for configured expected upstream errors."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass, field
6
+
7
+
8
+ @dataclass(frozen=True, slots=True)
9
+ class ExpectedErrorGroup:
10
+ """A question lifecycle method whose expected errors are alternatives."""
11
+
12
+ group_id: str
13
+ question: str
14
+ method: str
15
+ config_path: str
16
+
17
+ def payload(self) -> dict[str, str]:
18
+ return {
19
+ "id": self.group_id,
20
+ "question": self.question,
21
+ "method": self.method,
22
+ "config_path": self.config_path,
23
+ }
24
+
25
+
26
+ @dataclass(slots=True)
27
+ class ExpectedErrorAudit:
28
+ """Track expected-error groups reached and observed during one pytest run."""
29
+
30
+ required: dict[str, ExpectedErrorGroup] = field(default_factory=dict)
31
+ matched: set[str] = field(default_factory=set)
32
+
33
+ def require(
34
+ self, *, question: str, method: str, config_path: str
35
+ ) -> ExpectedErrorGroup:
36
+ group_id = f"{question}::{method}"
37
+ group = ExpectedErrorGroup(group_id, question, method, config_path)
38
+ existing = self.required.setdefault(group_id, group)
39
+ if existing != group:
40
+ raise ValueError(f"conflicting expected-error group {group_id!r}")
41
+ return existing
42
+
43
+ def record_match(self, group: ExpectedErrorGroup) -> None:
44
+ if self.required.get(group.group_id) != group:
45
+ raise ValueError(
46
+ f"expected-error group {group.group_id!r} was not registered"
47
+ )
48
+ self.matched.add(group.group_id)
49
+
50
+ def missing(self) -> tuple[ExpectedErrorGroup, ...]:
51
+ return tuple(
52
+ self.required[group_id]
53
+ for group_id in sorted(set(self.required) - self.matched)
54
+ )
55
+
56
+ def payload(self) -> dict[str, object]:
57
+ return {
58
+ "required": [
59
+ self.required[group_id].payload() for group_id in sorted(self.required)
60
+ ],
61
+ "matched": sorted(self.matched),
62
+ }
@@ -5,8 +5,9 @@ from __future__ import annotations
5
5
  import secrets
6
6
  import shlex
7
7
  import sys
8
+ import time
8
9
  import warnings
9
- from collections.abc import Hashable
10
+ from collections.abc import Generator, Hashable
10
11
  from pathlib import Path
11
12
  from typing import Any, cast
12
13
 
@@ -16,12 +17,14 @@ from .backend import PrairieLearnBackend
16
17
  from .config import HarnessConfig, load_config
17
18
  from .discovery import QuestionCase, discover_questions, variant_seeds
18
19
  from .errors import HarnessError
20
+ from .expected_errors import ExpectedErrorAudit, ExpectedErrorGroup
19
21
  from .reporting import ShardReportRecorder, serialize_pytest_report
20
22
  from .sharding import Shard, parse_shard, selected_group_keys
21
23
 
22
24
  _CONFIG_KEY: pytest.StashKey[HarnessConfig] = pytest.StashKey()
23
25
  _REPORTER_KEY: pytest.StashKey[ShardReportRecorder] = pytest.StashKey()
24
26
  _EMPTY_SHARD_KEY: pytest.StashKey[bool] = pytest.StashKey()
27
+ _EXPECTED_ERROR_AUDIT_KEY: pytest.StashKey[ExpectedErrorAudit] = pytest.StashKey()
25
28
  _active_recorder: ShardReportRecorder | None = None
26
29
 
27
30
 
@@ -98,6 +101,9 @@ def _course_root(config: pytest.Config) -> Path:
98
101
  def pytest_configure(config: pytest.Config) -> None:
99
102
  global _active_recorder
100
103
 
104
+ expected_error_audit = ExpectedErrorAudit()
105
+ config.stash[_EXPECTED_ERROR_AUDIT_KEY] = expected_error_audit
106
+
101
107
  for marker, description in (
102
108
  ("generate", "question generate-phase lifecycle checks"),
103
109
  ("prepare", "question prepare-phase lifecycle checks"),
@@ -140,6 +146,7 @@ def pytest_configure(config: pytest.Config) -> None:
140
146
  recorder = ShardReportRecorder(
141
147
  path=report_path, shard=cast(Shard, shard), fuzz_seed=fuzz_seed
142
148
  )
149
+ recorder.expected_error_audit = expected_error_audit
143
150
  config.stash[_REPORTER_KEY] = recorder
144
151
  _active_recorder = recorder
145
152
 
@@ -279,6 +286,44 @@ def pytest_runtest_logreport(report: pytest.TestReport) -> None:
279
286
  _active_recorder.reports.append(serialize_pytest_report(report))
280
287
 
281
288
 
289
+ def _missing_expected_error_report(group: ExpectedErrorGroup) -> pytest.TestReport:
290
+ now = time.time()
291
+ return pytest.TestReport(
292
+ nodeid=(
293
+ f"prairielearn_e2e::expected_error_audit[{group.question}:{group.method}]"
294
+ ),
295
+ location=(group.config_path, None, f"expected error audit: {group.method}"),
296
+ keywords={},
297
+ outcome="failed",
298
+ longrepr=(
299
+ f"Expected upstream error was not observed for {group.question} during "
300
+ f"{group.method}; all selected cases passed. Remove or update "
301
+ f"expected_errors in {group.config_path}."
302
+ ),
303
+ when="call",
304
+ duration=0.0,
305
+ start=now,
306
+ stop=now,
307
+ )
308
+
309
+
310
+ @pytest.hookimpl(wrapper=True)
311
+ def pytest_runtestloop(session: pytest.Session) -> Generator[None, object, object]:
312
+ result = yield
313
+ shard = cast(Shard | None, session.config.getoption("shard"))
314
+ if shard is not None or session.testsfailed:
315
+ return result
316
+ audit = session.config.stash[_EXPECTED_ERROR_AUDIT_KEY]
317
+ missing = audit.missing()
318
+ session.testscollected += len(missing)
319
+ for group in missing:
320
+ session.config.hook.pytest_runtest_logreport(
321
+ report=_missing_expected_error_report(group)
322
+ )
323
+ session.testsfailed += 1
324
+ return result
325
+
326
+
282
327
  def pytest_collectreport(report: pytest.CollectReport) -> None:
283
328
  if _active_recorder is not None and report.failed:
284
329
  _active_recorder.collection_errors.append(serialize_pytest_report(report))
@@ -359,3 +404,8 @@ def prairielearn_backend(
359
404
  @pytest.fixture(scope="session")
360
405
  def harness_config(request: pytest.FixtureRequest) -> HarnessConfig:
361
406
  return request.config.stash[_CONFIG_KEY]
407
+
408
+
409
+ @pytest.fixture(scope="session")
410
+ def expected_error_audit(request: pytest.FixtureRequest) -> ExpectedErrorAudit:
411
+ return request.config.stash[_EXPECTED_ERROR_AUDIT_KEY]
@@ -3,12 +3,14 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  from collections.abc import Callable
6
+ from typing import cast
6
7
 
7
8
  import pytest
8
9
 
9
10
  from .backend import PrairieLearnBackend
10
11
  from .discovery import LifecycleMethod, QuestionCase
11
12
  from .errors import UnsupportedQuestion
13
+ from .expected_errors import ExpectedErrorAudit
12
14
  from .harness import QuestionHarness
13
15
 
14
16
  type LifecycleCheck = Callable[[QuestionHarness, int], object]
@@ -26,6 +28,18 @@ def _run_check(
26
28
  assert question is not None
27
29
  if reason := question.e2e_skip_reason(method):
28
30
  pytest.skip(reason)
31
+ expected_errors = question.e2e_expected_errors(method)
32
+ audit: ExpectedErrorAudit | None = None
33
+ group = None
34
+ if expected_errors:
35
+ audit = cast(
36
+ ExpectedErrorAudit, request.getfixturevalue("expected_error_audit")
37
+ )
38
+ group = audit.require(
39
+ question=question.qid,
40
+ method=method,
41
+ config_path=str(question.e2e_config_path),
42
+ )
29
43
  backend: PrairieLearnBackend = request.getfixturevalue("prairielearn_backend")
30
44
  try:
31
45
  check(
@@ -36,6 +50,19 @@ def _run_check(
36
50
  )
37
51
  except UnsupportedQuestion as exc:
38
52
  pytest.skip(str(exc))
53
+ except Exception as exc:
54
+ matching_rule = next(
55
+ (rule for rule in expected_errors if rule.matches(exc)), None
56
+ )
57
+ if matching_rule is None:
58
+ raise
59
+ assert audit is not None and group is not None
60
+ audit.record_match(group)
61
+ exception_type = type(exc)
62
+ qualified_type = f"{exception_type.__module__}.{exception_type.__qualname__}"
63
+ pytest.xfail(
64
+ f"expected upstream error: {matching_rule.reason} [{qualified_type}: {exc}]"
65
+ )
39
66
 
40
67
 
41
68
  @pytest.mark.generate