From c8157493cda9ee5437020b8077843206952005b9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Afonso=20Janu=C3=A1rio?= Date: Tue, 8 Sep 2026 10:58:28 +0100 Subject: [PATCH] Keep extras appended during a fixture's own teardown The extras/extra fixtures only ever fed their stashed list into report.extras during the "call" phase, and cleared the stash as soon as their own teardown ran. That's a problem for any fixture that depends on extras and appends to it after its own yield: that append happens during the overall test's teardown phase, by which point the "call" report has already been built and the stash has already been wiped by the extras fixture's own cleanup. The appended extra never makes it into the HTML report at all, silently. pytest_runtest_makereport now also looks at the stash during the "teardown" phase, taking only whatever was added after "call" already took its snapshot so nothing gets duplicated, and clears the stash itself once it's done with it. The extras/extra fixtures no longer clear the stash in their own teardown, since something depending on them may still need to write to it after they've already yielded. Added a regression test that appends an extra from a dependent fixture's teardown and checks it shows up in the report's embedded JSON data (the legacy suite's older tests assert on literal / tags, which the current JS-based renderer doesn't emit directly into the HTML anymore, so this one reads the data-jsonblob attribute instead). Confirmed it fails on unmodified master and passes with the fix. --- src/pytest_html/fixtures.py | 10 +++++++-- src/pytest_html/plugin.py | 15 ++++++++++++++ testing/legacy_test_pytest_html.py | 33 ++++++++++++++++++++++++++++++ 3 files changed, 56 insertions(+), 2 deletions(-) diff --git a/src/pytest_html/fixtures.py b/src/pytest_html/fixtures.py index 88ce828d..6d5e40b2 100644 --- a/src/pytest_html/fixtures.py +++ b/src/pytest_html/fixtures.py @@ -27,7 +27,10 @@ def test_foo(extra): ) pytestconfig.stash[extras_stash_key] = [] yield pytestconfig.stash[extras_stash_key] - del pytestconfig.stash[extras_stash_key][:] + # Deliberately not cleared here: a fixture that depends on this one may + # still append to the list during its own teardown, which runs after + # this point. pytest_runtest_makereport reads it once more for the + # "teardown" phase and clears it there instead. @pytest.fixture @@ -44,4 +47,7 @@ def test_foo(extras): """ pytestconfig.stash[extras_stash_key] = [] yield pytestconfig.stash[extras_stash_key] - del pytestconfig.stash[extras_stash_key][:] + # Deliberately not cleared here: a fixture that depends on this one may + # still append to the list during its own teardown, which runs after + # this point. pytest_runtest_makereport reads it once more for the + # "teardown" phase and clears it there instead. diff --git a/src/pytest_html/plugin.py b/src/pytest_html/plugin.py index 949a6ffa..be6e583e 100644 --- a/src/pytest_html/plugin.py +++ b/src/pytest_html/plugin.py @@ -138,3 +138,18 @@ def pytest_runtest_makereport(item, call): fixture_extras = item.config.stash.get(extras_stash_key, []) plugin_extras = getattr(report, "extras", []) report.extras = fixture_extras + plugin_extras + deprecated_extra + # Remember how many stash entries "call" already claimed, so that + # anything a fixture appends during its own teardown (which the + # stash still holds at this point) isn't attached twice below. + item._html_report_extras_seen = len(fixture_extras) + elif report.when == "teardown": + fixture_extras = item.config.stash.get(extras_stash_key, []) + already_seen = getattr(item, "_html_report_extras_seen", 0) + new_extras = fixture_extras[already_seen:] + if new_extras: + report.extras = new_extras + getattr(report, "extras", []) + # The "extras"/"extra" fixtures no longer clear the stash on their + # own teardown, since a fixture depending on them may still append + # to it during its own teardown afterwards. Do it here instead, + # once nothing else is left to read it for this test. + del fixture_extras[:] diff --git a/testing/legacy_test_pytest_html.py b/testing/legacy_test_pytest_html.py index 08ad208d..1fa71c56 100644 --- a/testing/legacy_test_pytest_html.py +++ b/testing/legacy_test_pytest_html.py @@ -8,6 +8,7 @@ import re import sys from base64 import b64encode +from html import unescape import pkg_resources import pytest @@ -706,6 +707,38 @@ def test_pass(extra): src = f"data:image/png;base64,{content}" assert f'' in html + def test_extra_fixture_teardown(self, testdir): + content = str(random.random()) + testdir.makepyfile( + f""" + import pytest + + @pytest.fixture + def cleanup(extras): + yield + from pytest_html import extras as extras_module + extras.append(extras_module.text('{content}')) + + def test_pass(cleanup): + pass + """ + ) + result, html = run(testdir, "report.html", "--self-contained-html") + assert result.ret == 0 + + match = re.search(r'data-jsonblob="([^"]*)"', html) + assert match + report_data = json.loads(unescape(match.group(1))) + test_extras = report_data["tests"]["test_extra_fixture_teardown.py::test_pass"][ + 0 + ]["extras"] + assert len(test_extras) == 1 + encoded = b64encode(content.encode("utf-8")).decode("ascii") + assert ( + test_extras[0]["content"] + == f"data:text/plain;charset=utf-8;base64,{encoded}" + ) + def test_no_invalid_characters_in_filename(self, testdir): testdir.makeconftest( """