feat(test): expose the platform test run's report as a retrievable artifact#132
Open
blarghmatey wants to merge 3 commits into
Open
feat(test): expose the platform test run's report as a retrievable artifact#132blarghmatey wants to merge 3 commits into
blarghmatey wants to merge 3 commits into
Conversation
…tifact `platform test` returned pytest stdout and nothing else, so a run's result was unarchivable and — more importantly — unattributable. Because a plugin that ships no tests collects nothing and never fails, "this plugin's suite passed" and "this plugin shipped no suite" are indistinguishable in the exit code. That distinction is exactly what tells us whether a `[tests]` extra has landed upstream yet, and it is knowable only after collection. The run now always writes a JUnit report plus a per-target summary (edx-platform vs each plugin package, with `contributing_plugins` / `silent_plugins`), and a new `platform test-report` returns that directory so CI can publish it. It runs the pytest exec with `expect=ANY` on purpose: a report you can only retrieve when everything passed is useless. `test` keeps its exit-code-as-gate contract untouched and both share one prepared run, so the artifact can never describe a different run than the gate executed. Folding the two pytest paths into the single driver script is what makes the report unconditional rather than a privilege of the plugin-inclusive path; resolving the cell via `_resolve_cell` drops a duplicate of the resolution every other verification entry point already shared. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`src/lehrer/core/test_report.py` matches pytest's own `test_*.py` collection glob. `testpaths` keeps it out of scope today, but a module that would be imported as a test file the moment anyone runs `pytest src` is a trap, and `tests/core/test_test_report.py` was an unreadable name for its suite. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
There was a problem hiding this comment.
Pull request overview
Adds exportable JUnit and per-plugin reports for platform test runs.
Changes:
- Generates XML, JSON, and Markdown test reports.
- Adds
platform test-reportand CLI forwarding. - Adds summarizer and driver tests.
Reviewed changes
Copilot reviewed 8 out of 8 changed files in this pull request and generated 1 comment.
Show a summary per file
| File | Description |
|---|---|
src/lehrer/core/junit_report.py |
Summarizes JUnit results by target. |
src/lehrer/core/plugin_tests.py |
Generates reports after pytest. |
src/lehrer/core/platform.py |
Adds report-returning test API. |
src/lehrer/cli/build.py |
Exposes test-report. |
tests/core/test_junit_report.py |
Tests report summarization. |
tests/core/test_plugin_tests.py |
Tests generated driver reporting. |
README.md |
Documents the command. |
deployments/mit-ol/build.md |
Documents report export and contents. |
… ones Copilot caught that the previous claim was wrong. `test` and `test_report` built their own `with_exec` differing only in `expect`, which is part of the node's digest — so they were two cache entries and two runs of pytest. Sharing `_prepare_test_run` bought identical *configuration*, not a shared execution, and against a flaky or stateful suite the gate and the artifact could disagree. The exec now happens once inside `_prepare_test_run` with `expect=ANY`, and both entry points read that single node: same args and same expect means the same digest, so gating with `test` and then exporting with `test-report` is served the first run's filesystem. Gating moves into `test` as an explicit `exit_code()` check, because a differing `expect` is exactly what forked the digest before. `expect=ANY` also means Dagger no longer prints a failing exec's output, so `_TestRunFailed` carries it — quoting the tail of stdout/stderr, since pytest puts the summary and traceback at the end and a truncated head would bury them. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What are the relevant tickets?
N/A — follow-up to the review of #111, where a
--junitxmlflag was written inside the container but unreachable (the container was evaluated forstdout(), so noFile/Directorywas ever returned) and was removed rather than shipped broken.Description (What does it do?)
platform testreturned pytest stdout and nothing else, so a run's result was unarchivable and — more importantly — unattributable. Because a plugin that ships no tests collects nothing and never fails the run, "this plugin's suite passed" and "this plugin shipped no suite" are indistinguishable in the exit code. That distinction is exactly what tells us whether a plugin's[tests]extra has landed upstream yet (see mitodl/open-edx-plugins#830), and it is knowable only after collection.--junitxml, then parses the result and writesreport.xml,summary.jsonandsummary.mdto/openedx/reports.summary.jsonattributes every test case to a target — edx-platform, or the specific plugin package it came from — and namescontributing_plugins/silent_plugins.platform test-reportfunction (lehrer build test-report) returns that directory instead of stdout, so... export --path ./reportsworks. It runs the pytest exec withexpect=ReturnType.ANYon purpose: a report you can only retrieve when everything passed is useless — a failing suite is precisely when you want it.testkeeps its exit-code-as-gate contract, unchanged: noexpect=override, so a non-zero pytest run still fails thedagger call. It also echoes the summary table at the end of its stdout._prepare_test_runhelper, so the artifact can never describe a different run than the gate executed.Two supporting cleanups, both load-bearing for the above:
--include-plugins) are folded into the single driver script. That is what makes the report unconditional rather than a privilege of the plugin-inclusive path. With--no-include-pluginsthe script degrades to a plain edx-platform run and now stays silent about plugins instead of printing a misleadingno installed plugin packages to scan.testnow resolves its cell via the existing_resolve_cell, dropping a hand-rolled duplicate of the resolution thatcheck_deployment/verify_settings/regenerate_aqueduct_settingsalready share.The summarizer lives in
src/lehrer/core/junit_report.py— stdlib-only, no Dagger imports — and is injected into the container as a file, so the code that runs next to pytest is the very same code the host-side unit tests cover.How can this be tested?
Automated (15 new tests,
uv run pytest— 230 pass):tests/core/test_junit_report.pycovers the summarizer: attribution byclassname, unattributable cases falling back to edx-platform rather than vanishing from the totals, plugins that collected nothing still getting a row, and the JSON/Markdown rendering.tests/core/test_plugin_tests.pyexecutes the generated driver for real in a subprocess against a stubpytestthat writes a known JUnit XML — the report tail only runs afterpytest.main()returns, so reading the source can't check it. This asserts the artifacts are written, that a failing exit code survives summarizing (a bug here would launder a red suite into green), and that an unparseable report printsREPORT SUMMARY FAILEDwithout turning a passing suite red.Manual:
A real end-to-end run needs a full platform image build (tens of minutes) and was not run here; the container-independent half is covered by the subprocess test above.
lehrer build test-report --cell mit-ol/master/mitxonline \ --custom-settings ./deployments/mit-ol/settings \ export --path ./reportsAdditional Context
Reviewer questions worth weighing:
expect=ANYontest-report. This deliberately swallows pytest's exit code — the function's whole contract is "give me the artifact regardless". A caller who wants both a gate and an artifact runstestandtest-reportas separate steps. Flagging it because it's the one place a failing suite does not fail the call.platform testyet, so nothing publishes this today. That's intentional scope: adding a full test tier to CI is its own decision (wall-clock and cost), and this PR only removes the blocker that made it impossible to archive results.python -cis retained rather than running the driver as a file: it keepssys.path[0]as the empty string, so pytest and edx-platform's conftests resolve imports relative to the/openedx/edx-platformworkdir. Running a script file would put the driver's own directory there instead.