pl-e2e-test 0.1.4__tar.gz → 0.1.6__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 (59) hide show
  1. {pl_e2e_test-0.1.4/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.6}/PKG-INFO +102 -6
  2. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/README.md +101 -5
  3. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/pyproject.toml +7 -1
  4. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6/src/pl_e2e_test.egg-info}/PKG-INFO +102 -6
  5. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/SOURCES.txt +2 -0
  6. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/contract.py +102 -20
  7. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/discovery.py +106 -3
  8. pl_e2e_test-0.1.6/src/prairielearn_e2e/expected_errors.py +62 -0
  9. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/harness.py +59 -7
  10. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/pytest_plugin.py +51 -1
  11. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/question_tests.py +27 -0
  12. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/reporting.py +82 -1
  13. pl_e2e_test-0.1.6/tests/test_contract.py +214 -0
  14. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_discovery.py +86 -0
  15. pl_e2e_test-0.1.6/tests/test_harness.py +321 -0
  16. pl_e2e_test-0.1.6/tests/test_pytest_plugin.py +202 -0
  17. pl_e2e_test-0.1.6/tests/test_question_tests.py +130 -0
  18. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_reporting.py +114 -0
  19. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_server.py +17 -0
  20. pl_e2e_test-0.1.4/tests/test_contract.py +0 -93
  21. pl_e2e_test-0.1.4/tests/test_harness.py +0 -100
  22. pl_e2e_test-0.1.4/tests/test_pytest_plugin.py +0 -86
  23. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/LICENSE +0 -0
  24. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/MANIFEST.in +0 -0
  25. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/setup.cfg +0 -0
  26. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
  27. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
  28. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/requires.txt +0 -0
  29. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
  30. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/__init__.py +0 -0
  31. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/__main__.py +0 -0
  32. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/_git.py +0 -0
  33. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/backend.py +0 -0
  34. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/cli.py +0 -0
  35. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/config.py +0 -0
  36. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/errors.py +0 -0
  37. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/fuzz.py +0 -0
  38. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/html_checks.py +0 -0
  39. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/metadata.py +0 -0
  40. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/parallel.py +0 -0
  41. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/regression.py +0 -0
  42. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/repository.py +0 -0
  43. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/runner.py +0 -0
  44. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/server.py +0 -0
  45. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/sharding.py +0 -0
  46. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/__init__.py +0 -0
  47. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/conftest.py +0 -0
  48. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/stub_git.py +0 -0
  49. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_cli.py +0 -0
  50. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_config.py +0 -0
  51. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_fuzz.py +0 -0
  52. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_git.py +0 -0
  53. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_html_checks.py +0 -0
  54. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_metadata.py +0 -0
  55. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_parallel.py +0 -0
  56. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_publish_version.py +0 -0
  57. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_regression.py +0 -0
  58. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_repository.py +0 -0
  59. {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/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.6
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
@@ -266,24 +266,105 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
266
266
  ```
267
267
 
268
268
  These are the defaults. `elementSolutionSelectors` identifies elements that provide
269
- solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
270
- display-only answers from grading checks. Each supplied list replaces its default;
271
- omitted settings keep their defaults, and `[]` disables a list.
269
+ solutions without a `correct_answers` entry. It only relaxes that entry requirement;
270
+ it does not generate a test submission. `nonGradableSelectors` excludes display-only
271
+ answers from grading checks. Each supplied list replaces its default; omitted settings
272
+ keep their defaults, and `[]` disables a list.
272
273
 
273
274
  Use `--config path/to/settings.toml` to select another TOML file with the same
274
275
  `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
276
  `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
277
 
278
+ ### Lifecycle JSON boundary
279
+
280
+ PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
281
+ and again after calling the question's `server.py` hook. The harness mirrors those
282
+ boundaries. It first checks the in-process result for legal field mutations, then
283
+ serializes and deserializes `data`; the normalized result is what the next hook or phase
284
+ receives.
285
+
286
+ This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
287
+ literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
288
+ and supported non-string object keys such as numeric drawing IDs normalize to strings.
289
+ This conversion can be lossy: if two keys normalize to the same string, the last key in
290
+ serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
291
+ floats, and integers outside JavaScript's safe range are rejected with the failing data
292
+ path.
293
+
294
+ ### Correct submissions for custom elements
295
+
296
+ The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
297
+ submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
298
+ question, then the harness calls `test(data)` in the question's `server.py`. A reusable
299
+ course element should normally implement the element hook. The question hook can fill
300
+ in a submission when the correct state is question-specific or when a custom element
301
+ does not provide one itself.
302
+
303
+ For every gradable `answers-name`, the correct test case must provide:
304
+
305
+ - a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
306
+ more compound input keys prefixed with `answers-name-`; and
307
+ - `data["partial_scores"][answers_name]["score"] = 1`.
308
+
309
+ Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
310
+ shape that the element's `parse` hook expects. Structured widgets commonly serialize
311
+ their state as a JSON string. The hook must derive its output only from deterministic
312
+ variant state such as `params` and `correct_answers`, mutate `data` in place, and return
313
+ `None`. The normal phase mutation checks and JSON-boundary normalization still apply.
314
+
315
+ For example, a question can supply the correct state for a drag-and-drop element with:
316
+
317
+ ```python
318
+ import json
319
+
320
+
321
+ def test(data):
322
+ if data["test_type"] != "correct":
323
+ return
324
+
325
+ answers_name = "ordering"
326
+ state = {
327
+ "solution": data["params"]["correct_order"],
328
+ "starter": [],
329
+ }
330
+ data["raw_submitted_answers"][answers_name] = json.dumps(state)
331
+ data["partial_scores"][answers_name] = {"score": 1}
332
+ ```
333
+
334
+ Compound elements keep the declared answer name as their grading identity while using
335
+ namespaced child fields for browser inputs. For example, an element with
336
+ `answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
337
+ then store its score in `partial_scores["match"]`; it does not need to invent a
338
+ `submitted_answers["match"]` value. After parsing, every raw field must either remain
339
+ non-null under its child key or produce a non-null value under its declared parent
340
+ answer. This also supports elements that normalize `answer-input` into `answer`.
341
+
342
+ This contract keeps element-specific synthesis with the element or question that owns
343
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
344
+ boundary, and its existing namespaced test fields require no harness adapter. A question
345
+ using `pl-sketch` without an element-owned solution can supply its deterministic
346
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
347
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
348
+ test hook that serializes its prepared correct tray and blank state.
349
+
277
350
  ### Question-local configuration
278
351
 
279
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
280
- question's `info.json`:
352
+ Store saved concrete seeds, narrow phase skips, and expected upstream errors in
353
+ `.question-e2e.json` beside the question's `info.json`:
281
354
 
282
355
  ```json
283
356
  {
284
357
  "regression_seeds": [1843927501],
285
358
  "skip_methods": {
286
359
  "grade": "Temporarily blocked by an upstream issue"
360
+ },
361
+ "expected_errors": {
362
+ "render": [
363
+ {
364
+ "match": "^render: known upstream failure$",
365
+ "reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
366
+ }
367
+ ]
287
368
  }
288
369
  }
289
370
  ```
@@ -292,6 +373,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
292
373
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
293
374
  questions automatically skip only the grade check.
294
375
 
376
+ Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
377
+ known upstream defect. Each rule requires a human-readable reason or issue URL and
378
+ exactly one matcher: either `type`, containing a fully qualified exception type, or
379
+ `match`, containing a Python regular expression searched against the exception message.
380
+ A matching exception is reported as `XFAIL`; a different exception fails normally,
381
+ while seeds that do not raise an exception pass normally. A method may not be both
382
+ skipped and expected to fail.
383
+
384
+ Rules under one method are alternatives. At least one rule must match across the selected
385
+ seeds for that question and method, or an otherwise-successful run fails an audit asking
386
+ you to remove or update the stale expectation. Parallel runs perform this audit after
387
+ merging all shards. Because one upstream error can prevent later code from running,
388
+ obsolete individual alternatives cannot be detected while another alternative still
389
+ matches.
390
+
295
391
  Record the concrete seed shown in a failing pytest case with:
296
392
 
297
393
  ```sh
@@ -242,24 +242,105 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
242
242
  ```
243
243
 
244
244
  These are the defaults. `elementSolutionSelectors` identifies elements that provide
245
- solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
246
- display-only answers from grading checks. Each supplied list replaces its default;
247
- omitted settings keep their defaults, and `[]` disables a list.
245
+ solutions without a `correct_answers` entry. It only relaxes that entry requirement;
246
+ it does not generate a test submission. `nonGradableSelectors` excludes display-only
247
+ answers from grading checks. Each supplied list replaces its default; omitted settings
248
+ keep their defaults, and `[]` disables a list.
248
249
 
249
250
  Use `--config path/to/settings.toml` to select another TOML file with the same
250
251
  `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
251
252
  `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
252
253
 
254
+ ### Lifecycle JSON boundary
255
+
256
+ PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
257
+ and again after calling the question's `server.py` hook. The harness mirrors those
258
+ boundaries. It first checks the in-process result for legal field mutations, then
259
+ serializes and deserializes `data`; the normalized result is what the next hook or phase
260
+ receives.
261
+
262
+ This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
263
+ literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
264
+ and supported non-string object keys such as numeric drawing IDs normalize to strings.
265
+ This conversion can be lossy: if two keys normalize to the same string, the last key in
266
+ serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
267
+ floats, and integers outside JavaScript's safe range are rejected with the failing data
268
+ path.
269
+
270
+ ### Correct submissions for custom elements
271
+
272
+ The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
273
+ submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
274
+ question, then the harness calls `test(data)` in the question's `server.py`. A reusable
275
+ course element should normally implement the element hook. The question hook can fill
276
+ in a submission when the correct state is question-specific or when a custom element
277
+ does not provide one itself.
278
+
279
+ For every gradable `answers-name`, the correct test case must provide:
280
+
281
+ - a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
282
+ more compound input keys prefixed with `answers-name-`; and
283
+ - `data["partial_scores"][answers_name]["score"] = 1`.
284
+
285
+ Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
286
+ shape that the element's `parse` hook expects. Structured widgets commonly serialize
287
+ their state as a JSON string. The hook must derive its output only from deterministic
288
+ variant state such as `params` and `correct_answers`, mutate `data` in place, and return
289
+ `None`. The normal phase mutation checks and JSON-boundary normalization still apply.
290
+
291
+ For example, a question can supply the correct state for a drag-and-drop element with:
292
+
293
+ ```python
294
+ import json
295
+
296
+
297
+ def test(data):
298
+ if data["test_type"] != "correct":
299
+ return
300
+
301
+ answers_name = "ordering"
302
+ state = {
303
+ "solution": data["params"]["correct_order"],
304
+ "starter": [],
305
+ }
306
+ data["raw_submitted_answers"][answers_name] = json.dumps(state)
307
+ data["partial_scores"][answers_name] = {"score": 1}
308
+ ```
309
+
310
+ Compound elements keep the declared answer name as their grading identity while using
311
+ namespaced child fields for browser inputs. For example, an element with
312
+ `answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
313
+ then store its score in `partial_scores["match"]`; it does not need to invent a
314
+ `submitted_answers["match"]` value. After parsing, every raw field must either remain
315
+ non-null under its child key or produce a non-null value under its declared parent
316
+ answer. This also supports elements that normalize `answer-input` into `answer`.
317
+
318
+ This contract keeps element-specific synthesis with the element or question that owns
319
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
320
+ boundary, and its existing namespaced test fields require no harness adapter. A question
321
+ using `pl-sketch` without an element-owned solution can supply its deterministic
322
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
323
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
324
+ test hook that serializes its prepared correct tray and blank state.
325
+
253
326
  ### Question-local configuration
254
327
 
255
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
256
- question's `info.json`:
328
+ Store saved concrete seeds, narrow phase skips, and expected upstream errors in
329
+ `.question-e2e.json` beside the question's `info.json`:
257
330
 
258
331
  ```json
259
332
  {
260
333
  "regression_seeds": [1843927501],
261
334
  "skip_methods": {
262
335
  "grade": "Temporarily blocked by an upstream issue"
336
+ },
337
+ "expected_errors": {
338
+ "render": [
339
+ {
340
+ "match": "^render: known upstream failure$",
341
+ "reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
342
+ }
343
+ ]
263
344
  }
264
345
  }
265
346
  ```
@@ -268,6 +349,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
268
349
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
269
350
  questions automatically skip only the grade check.
270
351
 
352
+ Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
353
+ known upstream defect. Each rule requires a human-readable reason or issue URL and
354
+ exactly one matcher: either `type`, containing a fully qualified exception type, or
355
+ `match`, containing a Python regular expression searched against the exception message.
356
+ A matching exception is reported as `XFAIL`; a different exception fails normally,
357
+ while seeds that do not raise an exception pass normally. A method may not be both
358
+ skipped and expected to fail.
359
+
360
+ Rules under one method are alternatives. At least one rule must match across the selected
361
+ seeds for that question and method, or an otherwise-successful run fails an audit asking
362
+ you to remove or update the stale expectation. Parallel runs perform this audit after
363
+ merging all shards. Because one upstream error can prevent later code from running,
364
+ obsolete individual alternatives cannot be detected while another alternative still
365
+ matches.
366
+
271
367
  Record the concrete seed shown in a failing pytest case with:
272
368
 
273
369
  ```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.6"
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.6
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
@@ -266,24 +266,105 @@ nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]
266
266
  ```
267
267
 
268
268
  These are the defaults. `elementSolutionSelectors` identifies elements that provide
269
- solutions without a `correct_answers` entry. `nonGradableSelectors` excludes
270
- display-only answers from grading checks. Each supplied list replaces its default;
271
- omitted settings keep their defaults, and `[]` disables a list.
269
+ solutions without a `correct_answers` entry. It only relaxes that entry requirement;
270
+ it does not generate a test submission. `nonGradableSelectors` excludes display-only
271
+ answers from grading checks. Each supplied list replaces its default; omitted settings
272
+ keep their defaults, and `[]` disables a list.
272
273
 
273
274
  Use `--config path/to/settings.toml` to select another TOML file with the same
274
275
  `[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
275
276
  `parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
276
277
 
278
+ ### Lifecycle JSON boundary
279
+
280
+ PrairieLearn crosses a JSON boundary after processing the elements in a lifecycle phase
281
+ and again after calling the question's `server.py` hook. The harness mirrors those
282
+ boundaries. It first checks the in-process result for legal field mutations, then
283
+ serializes and deserializes `data`; the normalized result is what the next hook or phase
284
+ receives.
285
+
286
+ This accepts the same Python values as PrairieLearn's JSON encoder rather than requiring
287
+ literal JSON-domain Python types. For example, tuples normalize to arrays (Python lists),
288
+ and supported non-string object keys such as numeric drawing IDs normalize to strings.
289
+ This conversion can be lossy: if two keys normalize to the same string, the last key in
290
+ serialized order wins, matching PrairieLearn's boundary. Unsupported objects, non-finite
291
+ floats, and integers outside JavaScript's safe range are rejected with the failing data
292
+ path.
293
+
294
+ ### Correct submissions for custom elements
295
+
296
+ The grade check uses PrairieLearn's `test` lifecycle to obtain a deterministic correct
297
+ submission. PrairieLearn first calls `test(element_html, data)` on the elements in the
298
+ question, then the harness calls `test(data)` in the question's `server.py`. A reusable
299
+ course element should normally implement the element hook. The question hook can fill
300
+ in a submission when the correct state is question-specific or when a custom element
301
+ does not provide one itself.
302
+
303
+ For every gradable `answers-name`, the correct test case must provide:
304
+
305
+ - a non-null value in `data["raw_submitted_answers"]` under the answer name, or one or
306
+ more compound input keys prefixed with `answers-name-`; and
307
+ - `data["partial_scores"][answers_name]["score"] = 1`.
308
+
309
+ Raw values must survive PrairieLearn's JSON boundary and have the same browser transport
310
+ shape that the element's `parse` hook expects. Structured widgets commonly serialize
311
+ their state as a JSON string. The hook must derive its output only from deterministic
312
+ variant state such as `params` and `correct_answers`, mutate `data` in place, and return
313
+ `None`. The normal phase mutation checks and JSON-boundary normalization still apply.
314
+
315
+ For example, a question can supply the correct state for a drag-and-drop element with:
316
+
317
+ ```python
318
+ import json
319
+
320
+
321
+ def test(data):
322
+ if data["test_type"] != "correct":
323
+ return
324
+
325
+ answers_name = "ordering"
326
+ state = {
327
+ "solution": data["params"]["correct_order"],
328
+ "starter": [],
329
+ }
330
+ data["raw_submitted_answers"][answers_name] = json.dumps(state)
331
+ data["partial_scores"][answers_name] = {"score": 1}
332
+ ```
333
+
334
+ Compound elements keep the declared answer name as their grading identity while using
335
+ namespaced child fields for browser inputs. For example, an element with
336
+ `answers-name="match"` may test and parse `match-dropdown-0` and `match-dropdown-1`,
337
+ then store its score in `partial_scores["match"]`; it does not need to invent a
338
+ `submitted_answers["match"]` value. After parsing, every raw field must either remain
339
+ non-null under its child key or produce a non-null value under its declared parent
340
+ answer. This also supports elements that normalize `answer-input` into `answer`.
341
+
342
+ This contract keeps element-specific synthesis with the element or question that owns
343
+ the data. PrairieLearn's tuple-based `pl-matching` state normalizes to arrays at the JSON
344
+ boundary, and its existing namespaced test fields require no harness adapter. A question
345
+ using `pl-sketch` without an element-owned solution can supply its deterministic
346
+ `answers-name-sketchresponse-submission` payload in `server.py`. A course-owned
347
+ drag-and-drop element such as `pl-faded-parsons` can implement one reusable element
348
+ test hook that serializes its prepared correct tray and blank state.
349
+
277
350
  ### Question-local configuration
278
351
 
279
- Store saved concrete seeds and narrow phase skips in `.question-e2e.json` beside the
280
- question's `info.json`:
352
+ Store saved concrete seeds, narrow phase skips, and expected upstream errors in
353
+ `.question-e2e.json` beside the question's `info.json`:
281
354
 
282
355
  ```json
283
356
  {
284
357
  "regression_seeds": [1843927501],
285
358
  "skip_methods": {
286
359
  "grade": "Temporarily blocked by an upstream issue"
360
+ },
361
+ "expected_errors": {
362
+ "render": [
363
+ {
364
+ "match": "^render: known upstream failure$",
365
+ "reason": "https://github.com/PrairieLearn/PrairieLearn/issues/12345"
366
+ }
367
+ ]
287
368
  }
288
369
  }
289
370
  ```
@@ -292,6 +373,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
292
373
  reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
293
374
  questions automatically skip only the grade check.
294
375
 
376
+ Prefer `expected_errors` to a skip when the lifecycle can run far enough to expose a
377
+ known upstream defect. Each rule requires a human-readable reason or issue URL and
378
+ exactly one matcher: either `type`, containing a fully qualified exception type, or
379
+ `match`, containing a Python regular expression searched against the exception message.
380
+ A matching exception is reported as `XFAIL`; a different exception fails normally,
381
+ while seeds that do not raise an exception pass normally. A method may not be both
382
+ skipped and expected to fail.
383
+
384
+ Rules under one method are alternatives. At least one rule must match across the selected
385
+ seeds for that question and method, or an otherwise-successful run fails an audit asking
386
+ you to remove or update the stale expectation. Parallel runs perform this audit after
387
+ merging all shards. Because one upstream error can prevent later code from running,
388
+ obsolete individual alternatives cannot be detected while another alternative still
389
+ matches.
390
+
295
391
  Record the concrete seed shown in a failing pytest case with:
296
392
 
297
393
  ```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
@@ -164,35 +164,117 @@ def validate_mutations(
164
164
  raise HarnessError(f"{phase}: data[{name!r}] was illegally modified")
165
165
 
166
166
 
167
- def validate_serializable(data: dict[str, Any], phase: Phase) -> None:
168
- def check_json(value: Any, path: str) -> None:
169
- if value is None or isinstance(value, (str, bool, int)):
170
- return
171
- if isinstance(value, float):
172
- if not math.isfinite(value):
173
- raise TypeError(f"{path} contains NaN or infinity")
174
- return
175
- if isinstance(value, list):
167
+ _JSON_ERRORS = (TypeError, ValueError, OverflowError, RecursionError)
168
+
169
+
170
+ def _validate_integer_limits(
171
+ value: Any, path: str, active_containers: set[int]
172
+ ) -> None:
173
+ """Apply PrairieLearn's integer limit to every JSON-supported container."""
174
+
175
+ if isinstance(value, int):
176
+ try:
177
+ assert_all_integers_within_limits(value)
178
+ except ValueError as exc:
179
+ raise ValueError(f"{path} contains oversized integer: {value}") from exc
180
+ return
181
+
182
+ if not isinstance(value, (dict, list, tuple)):
183
+ return
184
+
185
+ container_id = id(value)
186
+ if container_id in active_containers:
187
+ return
188
+ active_containers.add(container_id)
189
+ try:
190
+ if isinstance(value, dict):
191
+ for key, item in value.items():
192
+ if isinstance(key, int):
193
+ _validate_integer_limits(
194
+ key, f"{path} object key {key!r}", active_containers
195
+ )
196
+ _validate_integer_limits(item, f"{path}[{key!r}]", active_containers)
197
+ else:
176
198
  for index, item in enumerate(value):
177
- check_json(item, f"{path}[{index}]")
178
- return
199
+ _validate_integer_limits(item, f"{path}[{index}]", active_containers)
200
+ finally:
201
+ active_containers.remove(container_id)
202
+
203
+
204
+ def _json_fails(value: Any) -> bool:
205
+ try:
206
+ json.dumps(value, allow_nan=False)
207
+ except _JSON_ERRORS:
208
+ return True
209
+ return False
210
+
211
+
212
+ def _json_failure_path(value: Any, path: str, active_containers: set[int]) -> str:
213
+ """Locate an encoder failure after json.dumps has already rejected the value."""
214
+
215
+ if not isinstance(value, (dict, list, tuple)):
216
+ return path
217
+
218
+ container_id = id(value)
219
+ if container_id in active_containers:
220
+ return path
221
+ active_containers.add(container_id)
222
+ try:
179
223
  if isinstance(value, dict):
180
224
  for key, item in value.items():
181
- if not isinstance(key, str):
182
- raise TypeError(f"{path} has a non-string object key")
183
- check_json(item, f"{path}[{key!r}]")
184
- return
185
- raise TypeError(f"{path} contains {type(value).__name__}, which is not JSON")
225
+ if _json_fails({key: None}):
226
+ return f"{path} object key {key!r}"
227
+ if _json_fails(item):
228
+ return _json_failure_path(
229
+ item, f"{path}[{key!r}]", active_containers
230
+ )
231
+ else:
232
+ for index, item in enumerate(value):
233
+ if _json_fails(item):
234
+ return _json_failure_path(
235
+ item, f"{path}[{index}]", active_containers
236
+ )
237
+ finally:
238
+ active_containers.remove(container_id)
239
+ return path
240
+
241
+
242
+ def validate_serializable(data: dict[str, Any], phase: Phase) -> None:
243
+ """Validate and normalize data at PrairieLearn's Python-to-JSON boundary."""
186
244
 
187
245
  try:
188
- check_json(data, "data")
189
- assert_all_integers_within_limits(data)
190
- json.dumps(data, allow_nan=False)
191
- except (TypeError, ValueError) as exc:
246
+ _validate_integer_limits(data, "data", set())
247
+ except ValueError as exc:
192
248
  raise HarnessError(
193
249
  f"{phase}: data is not PrairieLearn JSON serializable: {exc}"
194
250
  ) from exc
195
251
 
252
+ try:
253
+ encoded = json.dumps(data, allow_nan=False)
254
+ except _JSON_ERRORS as exc:
255
+ try:
256
+ path = _json_failure_path(data, "data", set())
257
+ except RecursionError:
258
+ path = "data"
259
+ raise HarnessError(
260
+ f"{phase}: data is not PrairieLearn JSON serializable: {path}: {exc}"
261
+ ) from exc
262
+
263
+ try:
264
+ normalized = json.loads(encoded)
265
+ except _JSON_ERRORS as exc:
266
+ raise HarnessError(
267
+ f"{phase}: data is not PrairieLearn JSON serializable: data: {exc}"
268
+ ) from exc
269
+ if not isinstance(normalized, dict):
270
+ raise HarnessError(
271
+ f"{phase}: data is not PrairieLearn JSON serializable: "
272
+ "data did not normalize to an object"
273
+ )
274
+
275
+ data.clear()
276
+ data.update(normalized)
277
+
196
278
 
197
279
  def snapshot(data: dict[str, Any], phase: Phase) -> dict[str, Any]:
198
280
  validate_shape(data, phase)