pl-e2e-test 0.1.3__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.
- {pl_e2e_test-0.1.3/src/pl_e2e_test.egg-info → pl_e2e_test-0.1.5}/PKG-INFO +50 -3
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/README.md +49 -2
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/pyproject.toml +7 -4
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5/src/pl_e2e_test.egg-info}/PKG-INFO +50 -3
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/SOURCES.txt +3 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__init__.py +3 -0
- pl_e2e_test-0.1.5/src/prairielearn_e2e/config.py +68 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/discovery.py +106 -3
- pl_e2e_test-0.1.5/src/prairielearn_e2e/expected_errors.py +62 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/fuzz.py +6 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/harness.py +5 -2
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/html_checks.py +18 -7
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/pytest_plugin.py +77 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/question_tests.py +33 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/reporting.py +83 -2
- pl_e2e_test-0.1.5/tests/test_config.py +88 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_discovery.py +86 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_fuzz.py +7 -1
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_harness.py +29 -0
- pl_e2e_test-0.1.5/tests/test_pytest_plugin.py +202 -0
- pl_e2e_test-0.1.5/tests/test_question_tests.py +130 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_reporting.py +115 -1
- pl_e2e_test-0.1.3/src/prairielearn_e2e/config.json +0 -7
- pl_e2e_test-0.1.3/src/prairielearn_e2e/config.py +0 -30
- pl_e2e_test-0.1.3/tests/test_pytest_plugin.py +0 -84
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/LICENSE +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/MANIFEST.in +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/setup.cfg +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/dependency_links.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/entry_points.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/requires.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/pl_e2e_test.egg-info/top_level.txt +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/__main__.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/_git.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/backend.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/cli.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/contract.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/errors.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/metadata.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/parallel.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/regression.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/repository.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/runner.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/server.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/src/prairielearn_e2e/sharding.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/__init__.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/conftest.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/stub_git.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_cli.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_contract.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_git.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_html_checks.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_metadata.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_parallel.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_publish_version.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_regression.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_repository.py +0 -0
- {pl_e2e_test-0.1.3 → pl_e2e_test-0.1.5}/tests/test_server.py +0 -0
- {pl_e2e_test-0.1.3 → 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.
|
|
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
|
|
@@ -255,16 +255,43 @@ element controllers are archived into the test run's temporary directory. Standa
|
|
|
255
255
|
cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
|
|
256
256
|
CI systems can persist that directory between runs if desired.
|
|
257
257
|
|
|
258
|
+
### Harness configuration
|
|
259
|
+
|
|
260
|
+
Selector settings are read automatically from the course's `pyproject.toml`:
|
|
261
|
+
|
|
262
|
+
```toml
|
|
263
|
+
[tool.pl-e2e-test]
|
|
264
|
+
elementSolutionSelectors = ["pl-sketch"]
|
|
265
|
+
nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
|
|
266
|
+
```
|
|
267
|
+
|
|
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.
|
|
272
|
+
|
|
273
|
+
Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
274
|
+
`[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
|
|
275
|
+
`parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
|
|
276
|
+
|
|
258
277
|
### Question-local configuration
|
|
259
278
|
|
|
260
|
-
Store saved concrete seeds
|
|
261
|
-
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`:
|
|
262
281
|
|
|
263
282
|
```json
|
|
264
283
|
{
|
|
265
284
|
"regression_seeds": [1843927501],
|
|
266
285
|
"skip_methods": {
|
|
267
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
|
+
]
|
|
268
295
|
}
|
|
269
296
|
}
|
|
270
297
|
```
|
|
@@ -273,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
|
|
|
273
300
|
reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
|
|
274
301
|
questions automatically skip only the grade check.
|
|
275
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
|
+
|
|
276
318
|
Record the concrete seed shown in a failing pytest case with:
|
|
277
319
|
|
|
278
320
|
```sh
|
|
@@ -331,6 +373,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
|
|
|
331
373
|
increasingly complete portions of the lifecycle. `run_variant` remains an alias for
|
|
332
374
|
the complete grade path.
|
|
333
375
|
|
|
376
|
+
Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
|
|
377
|
+
course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
|
|
378
|
+
`load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
|
|
379
|
+
`config` uses the built-in defaults.
|
|
380
|
+
|
|
334
381
|
## Development
|
|
335
382
|
|
|
336
383
|
This project uses uv 0.9 or newer:
|
|
@@ -231,16 +231,43 @@ element controllers are archived into the test run's temporary directory. Standa
|
|
|
231
231
|
cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
|
|
232
232
|
CI systems can persist that directory between runs if desired.
|
|
233
233
|
|
|
234
|
+
### Harness configuration
|
|
235
|
+
|
|
236
|
+
Selector settings are read automatically from the course's `pyproject.toml`:
|
|
237
|
+
|
|
238
|
+
```toml
|
|
239
|
+
[tool.pl-e2e-test]
|
|
240
|
+
elementSolutionSelectors = ["pl-sketch"]
|
|
241
|
+
nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
|
|
242
|
+
```
|
|
243
|
+
|
|
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.
|
|
248
|
+
|
|
249
|
+
Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
250
|
+
`[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
|
|
251
|
+
`parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
|
|
252
|
+
|
|
234
253
|
### Question-local configuration
|
|
235
254
|
|
|
236
|
-
Store saved concrete seeds
|
|
237
|
-
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`:
|
|
238
257
|
|
|
239
258
|
```json
|
|
240
259
|
{
|
|
241
260
|
"regression_seeds": [1843927501],
|
|
242
261
|
"skip_methods": {
|
|
243
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
|
+
]
|
|
244
271
|
}
|
|
245
272
|
}
|
|
246
273
|
```
|
|
@@ -249,6 +276,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
|
|
|
249
276
|
reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
|
|
250
277
|
questions automatically skip only the grade check.
|
|
251
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
|
+
|
|
252
294
|
Record the concrete seed shown in a failing pytest case with:
|
|
253
295
|
|
|
254
296
|
```sh
|
|
@@ -307,6 +349,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
|
|
|
307
349
|
increasingly complete portions of the lifecycle. `run_variant` remains an alias for
|
|
308
350
|
the complete grade path.
|
|
309
351
|
|
|
352
|
+
Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
|
|
353
|
+
course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
|
|
354
|
+
`load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
|
|
355
|
+
`config` uses the built-in defaults.
|
|
356
|
+
|
|
310
357
|
## Development
|
|
311
358
|
|
|
312
359
|
This project uses uv 0.9 or newer:
|
|
@@ -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.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"
|
|
@@ -37,11 +37,14 @@ pl-e2e-test = "prairielearn_e2e.cli:main"
|
|
|
37
37
|
[tool.setuptools.packages.find]
|
|
38
38
|
where = ["src"]
|
|
39
39
|
|
|
40
|
-
[tool.setuptools.package-data]
|
|
41
|
-
prairielearn_e2e = ["config.json"]
|
|
42
|
-
|
|
43
40
|
[tool.pytest.ini_options]
|
|
44
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
|
+
]
|
|
45
48
|
|
|
46
49
|
[tool.pyright]
|
|
47
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.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
|
|
@@ -255,16 +255,43 @@ element controllers are archived into the test run's temporary directory. Standa
|
|
|
255
255
|
cache environment settings such as `XDG_CACHE_HOME` can relocate the managed cache;
|
|
256
256
|
CI systems can persist that directory between runs if desired.
|
|
257
257
|
|
|
258
|
+
### Harness configuration
|
|
259
|
+
|
|
260
|
+
Selector settings are read automatically from the course's `pyproject.toml`:
|
|
261
|
+
|
|
262
|
+
```toml
|
|
263
|
+
[tool.pl-e2e-test]
|
|
264
|
+
elementSolutionSelectors = ["pl-sketch"]
|
|
265
|
+
nonGradableSelectors = ['pl-sketch[read-only="true"]', 'pl-sketch[read-only="1"]']
|
|
266
|
+
```
|
|
267
|
+
|
|
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.
|
|
272
|
+
|
|
273
|
+
Use `--config path/to/settings.toml` to select another TOML file with the same
|
|
274
|
+
`[tool.pl-e2e-test]` table. This works with normal runs, `diff`, and arguments after
|
|
275
|
+
`parallel run` or `parallel diff`. Explicit paths are relative to the working directory.
|
|
276
|
+
|
|
258
277
|
### Question-local configuration
|
|
259
278
|
|
|
260
|
-
Store saved concrete seeds
|
|
261
|
-
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`:
|
|
262
281
|
|
|
263
282
|
```json
|
|
264
283
|
{
|
|
265
284
|
"regression_seeds": [1843927501],
|
|
266
285
|
"skip_methods": {
|
|
267
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
|
+
]
|
|
268
295
|
}
|
|
269
296
|
}
|
|
270
297
|
```
|
|
@@ -273,6 +300,21 @@ Saved regression seeds run before newly generated seeds. Skips require a nonempt
|
|
|
273
300
|
reason and may name only `generate`, `prepare`, `render`, or `grade`. Manual-grading
|
|
274
301
|
questions automatically skip only the grade check.
|
|
275
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
|
+
|
|
276
318
|
Record the concrete seed shown in a failing pytest case with:
|
|
277
319
|
|
|
278
320
|
```sh
|
|
@@ -331,6 +373,11 @@ from prairielearn_e2e import PrairieLearnBackend, QuestionCase, QuestionHarness
|
|
|
331
373
|
increasingly complete portions of the lifecycle. `run_variant` remains an alias for
|
|
332
374
|
the complete grade path.
|
|
333
375
|
|
|
376
|
+
Pass `config=load_config(course_root=course_root)` to `QuestionHarness` to load the
|
|
377
|
+
course's TOML settings, or `config=load_config("settings.toml")` for an explicit file.
|
|
378
|
+
`load_config` and `HarnessConfig` are exported from `prairielearn_e2e`. Omitting
|
|
379
|
+
`config` uses the built-in defaults.
|
|
380
|
+
|
|
334
381
|
## Development
|
|
335
382
|
|
|
336
383
|
This project uses uv 0.9 or newer:
|
|
@@ -13,11 +13,11 @@ src/prairielearn_e2e/__main__.py
|
|
|
13
13
|
src/prairielearn_e2e/_git.py
|
|
14
14
|
src/prairielearn_e2e/backend.py
|
|
15
15
|
src/prairielearn_e2e/cli.py
|
|
16
|
-
src/prairielearn_e2e/config.json
|
|
17
16
|
src/prairielearn_e2e/config.py
|
|
18
17
|
src/prairielearn_e2e/contract.py
|
|
19
18
|
src/prairielearn_e2e/discovery.py
|
|
20
19
|
src/prairielearn_e2e/errors.py
|
|
20
|
+
src/prairielearn_e2e/expected_errors.py
|
|
21
21
|
src/prairielearn_e2e/fuzz.py
|
|
22
22
|
src/prairielearn_e2e/harness.py
|
|
23
23
|
src/prairielearn_e2e/html_checks.py
|
|
@@ -35,6 +35,7 @@ tests/__init__.py
|
|
|
35
35
|
tests/conftest.py
|
|
36
36
|
tests/stub_git.py
|
|
37
37
|
tests/test_cli.py
|
|
38
|
+
tests/test_config.py
|
|
38
39
|
tests/test_contract.py
|
|
39
40
|
tests/test_discovery.py
|
|
40
41
|
tests/test_fuzz.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
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
from importlib.metadata import version
|
|
4
4
|
|
|
5
5
|
from .backend import PrairieLearnBackend
|
|
6
|
+
from .config import HarnessConfig, load_config
|
|
6
7
|
from .discovery import QuestionCase, discover_questions
|
|
7
8
|
from .harness import LifecycleResult, QuestionHarness
|
|
8
9
|
from .parallel import run_parallel
|
|
@@ -10,11 +11,13 @@ from .parallel import run_parallel
|
|
|
10
11
|
__version__ = version("pl-e2e-test")
|
|
11
12
|
|
|
12
13
|
__all__ = (
|
|
14
|
+
"HarnessConfig",
|
|
13
15
|
"LifecycleResult",
|
|
14
16
|
"PrairieLearnBackend",
|
|
15
17
|
"QuestionCase",
|
|
16
18
|
"QuestionHarness",
|
|
17
19
|
"__version__",
|
|
18
20
|
"discover_questions",
|
|
21
|
+
"load_config",
|
|
19
22
|
"run_parallel",
|
|
20
23
|
)
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""Load harness settings from the course's pyproject.toml."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import tomllib
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
@dataclass(frozen=True, slots=True)
|
|
11
|
+
class HarnessConfig:
|
|
12
|
+
element_solution_answer_selectors: tuple[str, ...] = ("pl-sketch",)
|
|
13
|
+
non_gradable_answer_selectors: tuple[str, ...] = (
|
|
14
|
+
'pl-sketch[read-only="true"]',
|
|
15
|
+
'pl-sketch[read-only="1"]',
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
DEFAULT_CONFIG = HarnessConfig()
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def load_config(
|
|
23
|
+
path: str | Path | None = None, *, course_root: str | Path | None = None
|
|
24
|
+
) -> HarnessConfig:
|
|
25
|
+
"""Read [tool.pl-e2e-test], preserving defaults for omitted settings.
|
|
26
|
+
|
|
27
|
+
Without an explicit path, look for pyproject.toml in the course root.
|
|
28
|
+
A missing implicit file is allowed; a missing explicit file is an error.
|
|
29
|
+
"""
|
|
30
|
+
config_path = (
|
|
31
|
+
Path(path)
|
|
32
|
+
if path is not None
|
|
33
|
+
else Path(course_root or Path.cwd()) / "pyproject.toml"
|
|
34
|
+
)
|
|
35
|
+
try:
|
|
36
|
+
with config_path.open("rb") as config_file:
|
|
37
|
+
document = tomllib.load(config_file)
|
|
38
|
+
except FileNotFoundError:
|
|
39
|
+
if path is not None:
|
|
40
|
+
raise
|
|
41
|
+
return DEFAULT_CONFIG
|
|
42
|
+
tool = document.get("tool", {})
|
|
43
|
+
if not isinstance(tool, dict):
|
|
44
|
+
raise TypeError("tool must be a table")
|
|
45
|
+
config = tool.get("pl-e2e-test", {})
|
|
46
|
+
if not isinstance(config, dict):
|
|
47
|
+
raise TypeError("tool.pl-e2e-test must be a table")
|
|
48
|
+
|
|
49
|
+
def selectors(key: str, default: tuple[str, ...]) -> tuple[str, ...]:
|
|
50
|
+
if key not in config:
|
|
51
|
+
return default
|
|
52
|
+
values = config[key]
|
|
53
|
+
if not isinstance(values, list) or any(
|
|
54
|
+
not isinstance(value, str) or not value.strip() for value in values
|
|
55
|
+
):
|
|
56
|
+
raise TypeError(
|
|
57
|
+
f"tool.pl-e2e-test.{key} must be an array of non-empty strings"
|
|
58
|
+
)
|
|
59
|
+
return tuple(values)
|
|
60
|
+
|
|
61
|
+
return HarnessConfig(
|
|
62
|
+
element_solution_answer_selectors=selectors(
|
|
63
|
+
"elementSolutionSelectors", DEFAULT_CONFIG.element_solution_answer_selectors
|
|
64
|
+
),
|
|
65
|
+
non_gradable_answer_selectors=selectors(
|
|
66
|
+
"nonGradableSelectors", DEFAULT_CONFIG.non_gradable_answer_selectors
|
|
67
|
+
),
|
|
68
|
+
)
|
|
@@ -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) - {
|
|
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
|
+
}
|
|
@@ -197,6 +197,9 @@ def positive_integer(value: str) -> int:
|
|
|
197
197
|
def main(argv: Sequence[str] | None = None) -> int:
|
|
198
198
|
parser = argparse.ArgumentParser(prog="pl-e2e-test diff", description=__doc__)
|
|
199
199
|
parser.add_argument("--course-root", type=Path)
|
|
200
|
+
parser.add_argument(
|
|
201
|
+
"--config", type=Path, help="TOML config (default: COURSE_ROOT/pyproject.toml)"
|
|
202
|
+
)
|
|
200
203
|
parser.add_argument("--base", default="main", help="base Git ref (default: main)")
|
|
201
204
|
parser.add_argument("--head", default="HEAD", help="head Git ref (default: HEAD)")
|
|
202
205
|
parser.add_argument("--seed-count", type=positive_integer, default=10)
|
|
@@ -276,6 +279,9 @@ def main(argv: Sequence[str] | None = None) -> int:
|
|
|
276
279
|
print(f"Variant seeds: {', '.join(str(seed) for seed in seeds)}")
|
|
277
280
|
replay_arguments = ["pl-e2e-test", "diff", "--fuzz-seed", str(fuzz_seed)]
|
|
278
281
|
test_arguments: list[str] = []
|
|
282
|
+
if args.config is not None:
|
|
283
|
+
replay_arguments.extend(("--config", os.fspath(args.config)))
|
|
284
|
+
test_arguments.extend(("--config", os.fspath(args.config)))
|
|
279
285
|
if args.prairielearn_path is not None:
|
|
280
286
|
replay_arguments.extend(
|
|
281
287
|
("--prairielearn-path", os.fspath(args.prairielearn_path))
|