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.
- {pl_e2e_test-0.1.4/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.6}/PKG-INFO +102 -6
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/README.md +101 -5
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/pyproject.toml +7 -1
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6/src/pl_e2e_test.egg-info}/PKG-INFO +102 -6
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/SOURCES.txt +2 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/contract.py +102 -20
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/discovery.py +106 -3
- pl_e2e_test-0.1.6/src/prairielearn_e2e/expected_errors.py +62 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/harness.py +59 -7
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/pytest_plugin.py +51 -1
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/question_tests.py +27 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/reporting.py +82 -1
- pl_e2e_test-0.1.6/tests/test_contract.py +214 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_discovery.py +86 -0
- pl_e2e_test-0.1.6/tests/test_harness.py +321 -0
- pl_e2e_test-0.1.6/tests/test_pytest_plugin.py +202 -0
- pl_e2e_test-0.1.6/tests/test_question_tests.py +130 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_reporting.py +114 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_server.py +17 -0
- pl_e2e_test-0.1.4/tests/test_contract.py +0 -93
- pl_e2e_test-0.1.4/tests/test_harness.py +0 -100
- pl_e2e_test-0.1.4/tests/test_pytest_plugin.py +0 -86
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/LICENSE +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/setup.cfg +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/requires.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/__init__.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/cli.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/config.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/fuzz.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/html_checks.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/parallel.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_cli.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_config.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_fuzz.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_parallel.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_publish_version.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.4 → pl_e2e_test-0.1.6}/tests/test_repository.py +0 -0
- {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.
|
|
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.
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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.
|
|
246
|
-
|
|
247
|
-
|
|
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
|
|
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.
|
|
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.
|
|
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.
|
|
270
|
-
|
|
271
|
-
|
|
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
|
|
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
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
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
|
-
|
|
178
|
-
|
|
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
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
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)
|