pytestHTML Reporter
Home Docs A tour of the report
The report

A tour of the report

You have run pytest and you have one HTML file. This page walks it in the order you would explore it — the nine tabs down the left rail, the panels inside each one, and the interactions nobody finds without being shown: the status chips, the copy buttons folded behind an ellipsis, the deep links, and the dialogs that Escape closes.

One file, and a rail down the left

The report is a single static HTML document. A default run writes pytest_html_report.html, and that file is the whole product. There is no server to start, no assets/ folder that has to travel beside it, no build step, and no network access at any point after generation.

Everything the page draws with is written into the document itself at generation time, in load order: jQuery, jsPDF, dom-to-image, Bootstrap, DataTables, JSZip, the DataTables export buttons and Chart.js — along with the favicon and the logo as data: URIs. The loading spinner was one of those until 0.4.1, when the 70 KB animated GIF was replaced by a rotate-only CSS animation and the jQuery fade by an opacity transition. Both of the old ones were stepped by the main thread, and bringing a report up is one long block of main-thread work — so on Firefox and WebKit the spinner sat frozen through all of it and the overlay then jumped away rather than faded. The new pair run on the compositor and keep moving however long the page behind them takes. The icons are Font Awesome 4.7 glyphs shipped as cropped SVGs and painted as CSS masks, so an <i class="fa fa-home"> still inherits its size from the surrounding text and still takes its colour from currentcolor exactly as the font did — and still draws with the machine offline.

Try itEverything on this page is easier to read with the thing itself open beside it. The live demo report is the untouched output of a real pytest run — every tab, chart, dialog and deep link on it works exactly as it does on your own machine, served straight off this site.

That is what self-contained buys. The same file opens the same way off a CI artifact server, out of an email attachment, from file:// on a locked-down laptop, and out of an archive years after the CDN that used to serve those library versions stopped answering.

The Dashboard tab of a generated report: summary card, doughnut, Trends line, per-suite bars and Highlights The Dashboard tab of a generated report: summary card, doughnut, Trends line, per-suite bars and Highlights
The Dashboard of a 1,589-test run — the summary card and its doughnut, Trends across the retained builds, the per-suite bars and Highlights.

The one thing that lives beside it

Screenshots are the exception. They are written as real PNGs into pytest_screenshots/ next to the report and referenced relatively. The HTML alone opens anywhere and every tab works; the pictures travel with the folder.

shell What a finished report directory holds
report/
  pytest_html_report.html    the report
  output.json                this build, machine-readable
  archive/output_<ts>.json   every retained earlier build
  pytest_screenshots/*.png   the images
NoteThe archive/ JSON files are not decoration. The Archives tab, the Dashboard's Trends chart, the whole Analytics tab and the coverage trend are all read back out of them.

The side navigation

A fixed dark rail runs the full height of the window. The packaged logo sits at the top and is itself a link back to the Dashboard. Under it are the nine tabs, then any --report-link entries you added, then the theme switch and the sponsor mark — pinned to the foot of the rail whatever the viewport height.

Every tab link is a real #hash anchor. That makes tabs linkable, makes the browser's back and forward buttons move between them, and lets a URL pasted into a chat window open the report on the tab you meant.

TabHashThe question it answers
Dashboard#dashboardHow did this run go, in one screen?
Analytics#analyticsHow do these tests behave across every retained build?
Test Suites#suitesWhich suite is carrying the failures?
Test Metrics#test-metricsWhat happened to one particular test?
Test Steps#test-stepsWhat did that test actually do, and where did its time go?
Archives#archivesWhat did the builds before this one look like?
Screenshots#screenshotsWhat was on the screen when it broke?
API Logs#api-logsWhat did the service send back?
Test Coverage#coverageHow much of the code under test did this run touch?

Only one tab is displayed at a time. Showing a tab also re-sizes that tab's charts on the next tick, because a canvas in a hidden tab measures zero pixels and would otherwise be drawn one pixel high. Several tabs do their own setup on first open: Analytics builds its table and draws its charts, Test Steps builds its tree, API Logs builds its rail, and Test Coverage sorts its table ascending so the least-covered files lead.

Tip#attachments is kept as an alias for #api-logs, so a link handed round before the tab was renamed still lands correctly.

Below 768px the rail collapses to a 56px icon-only strip: the labels go to zero, the logo is hidden, and the sponsor keeps only its map pin. Entries added with --report-link open in a new tab, and any scheme that is not http, https or mailto is dropped on the way in — a nav entry can never become a way to run script in whoever opens the report. Relative paths such as ./htmlcov/index.html are kept, which is the point of the option.

Under every page sits one footer line: the project's name, and — from 0.4.3 — the version of the plugin that wrote this file, a step fainter beside it. A report outlives the run it came from, and the first question asked of an old one is which release drew it.

Dashboard

The landing tab, laid out to fit one screen at desktop width. Two rows of cards, answering one question: how did this run go?

The summary card

Top left. A strip along its top carries three controls: an Environment button that opens the environment dialog, a Test Coverage NN% chip that is present only when this run measured coverage — coloured by grade, and crossing to the Test Coverage tab when clicked — and a clock glyph reading Time taken, the run's wall time, written as 12.34 secs under a minute and HH:MM:SS Hrs above it.

Under that sit the report title from --title, the environment badge from --environment beside it, and the run date. The headline figure is the total test count in large type under the caption TEST CASES — pass plus fail plus skip plus error plus xpass plus xfail. Reruns are counted separately and are not in that total.

Beneath it is a doughnut with an 80% cut-out and six slices — PASS, FAIL, SKIP, XPASS, XFAIL and ERROR — in the run's status colours. Hovering a slice gives its name and its share as a percentage, and the pass percentage is drawn in the hole. Under the chart sit seven counters: passed, failed, skipped, xpassed, xfailed, error and rerun, each in its own status colour.

Since 0.4.3 each of those seven is a button, and the figure is the way to what it counted. Clicking 4 failed opens Test Metrics with the table already filtered to FAIL — in place of the three steps that used to take: read the number, cross to the tab, filter the table by hand. Rerun is the one counter that does not name an outcome, since rerun is a count in a column rather than a status a row carries: it filters that column to every test that ran more than once.

A counter standing at zero is disabled rather than merely styled flat, so it is not a tab stop and cannot be clicked into No matching records found — which is not what the figure it was read off said. The count is taken from the rendered counter, so a merged report is covered by the same rule as a single run.

Top right, Trends: a three-series line chart — Passed, Failed, Skipped — across this run plus up to five archived builds, each point labelled HH:MM | Mon DD. Failed here is failures and errors added together, read off the same per-build list the Highlights delta uses, so the two can never disagree.

NoteTrends reads at most six builds — this run and five archives. The Analytics trend charts read up to twenty, and the Analytics tables read every build still on disk. The three numbers are different on purpose.

A download glyph in this card's corner exports the whole Dashboard as pytest_html_reporter.pdf, a full-width raster in a landscape page. The exported PDF always shows the desktop dashboard whatever size the window is: below 1200px the report re-renders itself in an off-screen copy laid out against a 1600×900 viewport and captures that instead, with chart animation disabled so the image is deterministic rather than a half-swept doughnut.

Test Suite, and Highlights

Bottom left, Test Suite: a stacked bar chart with one column per suite and one series per outcome. Its tooltips are in index mode, so hovering a column reports every outcome in it at once rather than only the bar under the pointer. The heading carries the suite count.

Bottom right, Highlights, two figures. MOST FAILED SUITE shows N /M Times over a progress bar whose red portion is that suite's share of failing builds, with a tooltip carrying the suite's full name. Below it, SINCE LAST BUILD: the change in failures against the previous build, written with its sign even at zero (±0 failures), green when it fell and red when it rose. Hovering gives the two counts behind it — 12 failures this build, 9 in the build before it — because +3 reads very differently against 3 than against 300.

NoteOn a first build the delta entry is left out entirely, caption included, rather than showing "no change" against a build that does not exist. It appears as soon as a second build has been archived. There is nothing to configure.

Analytics

How these tests behave over time. Everything here is aggregated in Python before the page is written: output.json and every archive/*.json beside it are lined up oldest first and reduced to per-test histories. The subtitle states the scope out loud — across 14 builds, Aug 21 09:12 to Sep 03 14:41 — so the numbers are never read against a range you have to guess at.

Six headline tiles lead: stability score (0–100, graded strong, fair or low), pass rate this run with its movement in points against the previous build, flaky tests, always failing, builds analysed naming the oldest, and time in tests with the median build duration as its note.

PanelWhat it plots
Pass rate across buildsShare of decided tests that passed, over the last 20 builds. Barely smoothed, so the curve does not invent movement between points, and deliberately not zero-based — a suite living between 96% and 99% is exactly the one whose two-point drops matter.
What moved, build to buildRegressed, Fixed, Added and Dropped as a stacked bar per build step.
Where the time goesThis run's tests across seven duration bands, from < 100ms to 30s +, in one deepening ramp — the bars are one measure sliced up, not seven things being compared.
Who owns whatOne row per owner, worst first: tests held, share of the suite, mean pass rate, failing now, flaky, and time. Drawn only once something in the run carries the marker, with an Unowned row sorted last whatever its numbers.
How much it mattersThe same figures per severity level, in ladder order rather than by their own numbers, Unrated last. The line above it leads with what somebody came for — 1 critical test failing.
Test base growthTests collected per build, as a stepped line.
Slowest tests in this runThe top 10 as a horizontal bar chart, in seconds, with the test names on the axis.

Four movement cards follow — Newly failing, Newly fixed, New tests and No longer run — each listing up to six test names with the suite underneath.

Why this run failed

This panel is read off the current build alone, so it works on a very first run — which is exactly when a red run wants explaining. Failures and errors are grouped by the exception named in their message. Each group gets a count, a share, a bar, a movement chip against the previous build (new, level, +3) and the names of the tests in it.

Up to 8 groups and 4 names per group are shown, and the tail collapses into an and N more line. Unclassified is pinned to the bottom however large it gets, because a list headed "Unclassified: 40" has answered nothing. The heading note is a sentence — 12 failures, 9 are TimeoutException — since the count on its own is already on the Dashboard.

The The
"Why this run failed" groups the run’s failures by exception type and names the tests under each.

The "and N more" dialogs

Every tail on this tab — the fault groups and all four movement cards — shows a handful of names and folds the rest behind an and N more button. The button opens one shared dialog, titled after the card it came from, listing every name.

The dialog has a search box of its own (Search this list) and a live count beside the title. While a search is narrowing the list the count reads 7 of 42 tests rather than 42, because saying 42 with thirty-five rows filtered out reads as a list that lost them. A search matching nothing says Nothing here matches that. Escape closes it.

The stability table

The tab closes with a sortable table, ranked worst-first before anyone touches it: always-failing at the top, then flaky by how much they flip, then everything else by pass rate. Its columns are Test, Verdict, History, Pass rate, Builds, Flips, Retries, Current streak and Duration.

History is a sparkline — the last 12 outcomes as bare coloured blocks, described once as a sentence for a screen reader ("Last 12 builds, oldest first: 9 passed, 3 failed") rather than twelve blocks each carrying a tooltip of its own. Verdict, pass rate, streak, duration and the sparkline all sort on their underlying number rather than on the text you can see.

NoteOn a first run the trend panels are replaced by a note saying so. The duration panels are already real; the trends, the flake rates and the movement cards fill in from the second run onwards, over as many builds as --archive-count keeps. If nothing failed, the fault panel is simply absent.

Test Suites

Outcome breakdown for every test suite in this run. One sortable table, one row per suite, with columns Suite, Pass, Fail, Skip, xPass, xFail, Error and Rerun. It sorts on Fail descending by default, so the suites worth opening the tab for lead.

Every count is a pill, and a pill reading 0 is dimmed so a row of noughts recedes and the numbers that matter stand out. The Suite cell is split so the filename leads in full weight and its directories stay in the background.

Above the table is a summary chip row built live from the rows the search box is currently showing: the suite count, then one chip per outcome the run actually produced. An outcome with a zero total is left out rather than shown empty.

Each Suite cell is also a jump cell, titled Open this suite in Test Steps. Clicking it — or pressing Enter or Space on it, since it is focusable — crosses to Test Steps with that suite isolated: every other group in the rail shut, that suite's own first failure opened in the reading pane, and its heading flashed so the eye knows where it landed.

The Test Suites tab: summary chips, the toolbar with its export icons and search box, and ten suite rows sorted by failures The Test Suites tab: summary chips, the toolbar with its export icons and search box, and ten suite rows sorted by failures
Test Suites, sorted Fail-descending by default — one row per file, with dimmed zeros where an outcome never happened.

Test Metrics

Every test case with its status, duration and error. The densest tab, and the hub. Ten columns: Suite, Test Case, Status, Time, Rerun, Error Message, Logs, Data, Steps and Screens. It sorts on Time descending by default.

Status is a coloured pill. Time carries the unit that fits the figure rather than a bare number of seconds — 0.44 ms, 250 ms, 1.23 s — and sorts on the raw seconds behind the cell, so the slowest test of the run is still the first row and a test measured in microseconds no longer reads as 0.0. Suite and Test Case are both jump cells into Test Steps — the suite cell opens the group, the test cell opens that exact test. The error cell holds the first 50 characters of the message; when the message ran past that, the last 8 characters fade out rather than ending in an ellipsis. A test with nothing to say gets no buttons at all.

The last four columns are counts that are also doors. Logs shows the number of captured output lines and opens the captured-output panel. Data shows the number of attachments and crosses to API Logs scoped to that one test. Steps shows the number of named steps and crosses to Test Steps with that test open. Screens is a strip of live thumbnails that open the lightbox. A column with nothing in it shows an em dash instead of a zero-count button.

The Test Metrics tab: status chips, toolbar, and a table of tests with status, duration, rerun count, error message and Logs, Data, Steps and Screens columns The Test Metrics tab: status chips, toolbar, and a table of tests with status, duration, rerun count, error message and Logs, Data, Steps and Screens columns
Test Metrics — every test case in the run, with its error message truncated behind an ellipsis button and a live thumbnail in Screens.

The Rerun trail

New in 0.4.2A non-zero Rerun count is a button, and the panel behind it lists every attempt the test made.

A retried test is one row carrying the outcome that stuck. That is the honest shape, but the row shows the message of that attempt — and an attempt that stuck by passing has no message, so a test that failed twice and then passed said PASS  2 and nothing anywhere in the report said what it had failed with.

output Behind a Rerun count of 2
Attempt 1   FAIL   0.31s
    AssertionError: connection refused: could not connect to postgres on localhost:5432
      the container was still starting when the fixture handed back

Attempt 2   FAIL   0.28s
    ValueError: stale cache handed back order #7 after the write to #8

Attempt 3   PASS   0.30s                                                    KEPT

The list ends on the attempt the row itself is showing, marked kept: ending on the last attempt that was thrown away would leave a panel whose final line disagrees with the row that opened it. Two failures for two different reasons is a different bug report from the same failure twice, and neither is visible from a count — so the panel's Copy button hands the whole trail over as one line per attempt followed by that attempt's message, which is the shape somebody pastes into an issue. Under -n, each attempt also says which xdist worker ran it.

The count and the trail always agree, and that holds across both folds — a retry inside one process, and a node id that ran in two shards — so a test retried twice on a shard that then ran again on another machine reports four attempts and shows four. The trail is parked outside the table, so it never reaches the search index or the CSV, Excel and print exports, and the Rerun column still exports the single number it always did.

NoteA test that ran once keeps the plain 0 the column has always shown, with the button disabled rather than only styled flat, so it is not a tab stop either. A build archived before 0.4.2 does the same: the count was stored, the attempts behind it were not, and an empty panel would be worse than none.

The status filter chips

The chips above the table are filters, not decoration. Clicking any outcome chip filters the Status column to it; clicking the active chip clears it. The run total is always the unfiltered position and is the way back.

The search term is anchored — ^pass$ — so filtering to passes does not also pull in the xPASS rows. The chip set itself is captured while no filter is on and then held, because filtering to failures leaves every other outcome with no rows to count, and the numbers you filter from have to stay put. Focus is handed back to the chip you clicked after the table redraws, so the row does not lose your place.

From 0.4.3 the filter is also in the address, as #test-metrics?status=FAIL, so "the failures in last night's build" is a link rather than a set of instructions. Opening one lands on the filtered table directly, and the chips keep the address in step as they are clicked. Toggling a chip replaces that address rather than pushing a new one each time, so Back returns to the dashboard you came from rather than walking out through every chip you tried on the way.

A filtered table also carries a Clear filter control beside the chips, shown only while something is filtering. Clicking the chip that is holding has always cleared it, but that is only discoverable once you know it — and a table opened on a link was never clicked into a chip at all.

Search, paging, and the five exports

Four tables run on the same engine and behave identically: Test Suites, Test Metrics, Test Coverage and the Analytics stability table. Each has a page-length menu (10 / 25 / 50 / 100 / All), a search box with a table-specific placeholder — Search suites, Search tests, Search files — sortable headers on every column, and a footer reading Showing 1 to 25 of 340, with (filtered from N) appended when a search is on.

Five buttons sit between the length menu and the search box, each an icon with a tooltip:

ButtonWhat it produces
CopyThe visible columns of the visible rows, to the clipboard.
CSVA download named from the table and the local date and time, e.g. SuiteMetrics-3/9/2026, 1:22:41 PM.csv.
ExcelThe same rows as an .xlsx. This is what JSZip is bundled for.
PrintA print-formatted view of the visible, left-aligned rows of that table alone.
Hide ColumnA two-column column-visibility menu with a Restore entry appended.

Every export takes the visible columns only, and the Analytics sparkline is excluded because a strip of coloured blocks has no text to export. This is also the reason captured output, attachment payloads and step trees are parked in hidden stores rather than in cells: a cell holding a few thousand lines would be swept into the search index and into all five of these exports.

TipA cut error message keeps a hidden ellipsis in the cell. The CSV, the Excel sheet and the print-out are built from cell text and cannot carry the on-screen fade, so they still say the message was cut — a message trimmed with nothing to say so is a trap in a file read a week later.

The row action strip, and the fold behind the ellipsis

A row whose test has an error message carries an action strip in its error cell. It rests as a single button, titled Show what can be done with this failure; pressing it reveals four more, which have been in the markup the whole time — hidden by width and opacity rather than by display, which cannot be transitioned. If the strip has to wrap onto the next line when it opens, it animates from where it was to where it now belongs, so the move reads as travel rather than as a reappearance.

shell The four actions behind the fold, by their own tooltips
Show the full error                        the overlay, traceback in a <pre>
Copy the full error                        clipboard, plus a toast previewing it
Copy the command that runs this test again pytest tests/test_login.py::TestAuth::test_declined_card
Copy a link to this test                   <report url>#test-<slug>-<hash>

The rerun command is built from the test's node id, not by joining the suite and test names in the row — a test inside a class is listed under its own name where pytest wants the class in front of it. A node id a shell would not read as one word is quoted, since a parametrised test's brackets are a glob pattern to zsh. A record with no node id gets no button at all, rather than being offered a command that would run everything.

Every copy confirms twice: the button turns to a tick for a moment, and a toast appears mid-screen naming what landed — Error copied, Command copied, Link copied — and previewing up to 600 characters of it. Twenty-two pixels of button at the end of a long row is not legible from wherever the pointer is, and the toast also says which of the three landed. It takes no clicks, so the row underneath stays live while it is up. The toast is announced to a screen reader; the preview beneath it is not, because "Error copied" is the news and forty lines of traceback read out after it is not.

Where the clipboard API is unavailable — the common case from file:// — a throwaway textarea takes over, and if even that is refused the toast reads Press Ctrl+C to copy rather than leaving a button that did nothing and no reason why.

Only one strip is open at a time. A click anywhere else, or Escape, shuts it.

The captured-output panel

The Logs column opens an overlay carrying that test's captured output, each log section under its own heading — Captured stdout call, Captured log call and the rest. The same panel serves the Show the full error action, where it holds one failure's whole traceback in a <pre> so its line breaks and indentation survive.

The panel's Copy button is bound to whichever of the two was loaded, decided when the panel is filled rather than read back off the screen, because output sections and a traceback are assembled differently.

CarefulIf the Logs column cannot be filled — almost always because pytest is running under -s or --capture=no — a notice above the table says exactly that, rather than leaving a column of dashes to read as a broken feature. See Configuration for what each capture mode keeps.

Every row carries an anchor of its own, and Copy a link to this test hands you the URL. The anchor is built from the node id: flattened to lowercase letters, digits and dashes, cut back to a whole word at 72 characters, with six hex digits of the whole node id's SHA-1 after it.

shell The anchor for tests/test_login.py::TestAuth::test_declined_card
pytest_html_report.html#test-tests-test-login-py-testauth-test-declined-card-ff68f3
                             └── slug from the node id ────────────────────┘ └SHA-1┘

It is deliberately not positional. A positional anchor still resolves — it opens whichever test has since taken that place, with nothing on the page to say it is not the one that was sent. Building it from the node id means the link survives three tests being added in front of it, survives a re-sort and a re-page, and keeps pointing at the same test in a CI job's latest report build after build. The hash digest keeps two tests apart when their slugs are cut to the same string — test_one[a-b] beside test_one[a_b] — and a node id that appears twice, under pytest-repeat or a collected rerun, has its repeats numbered.

Opening such a link does real work. It switches to Test Metrics, builds the table if the page is still coming up, and finds the row through the table's own API rather than by element id, because only the current page of the table is in the document and a row on page four is not there to be found. It then clears whatever was hiding it — the search box and the status chips, since "show me the failures" is the likeliest thing to be hiding the row a link was pasted for — pages the table to that row, scrolls it to the centre and flashes it for a couple of seconds.

TipThe same mechanism addresses a build: #list-item-7 opens the report on Archives with build 7 highlighted and scrolled into view, and from 0.4.3 a tab hash can carry a filter behind a ?#test-metrics?status=FAIL is the whole failing table rather than one row. Tab hashes, #list-item-N and #test-… are all read on load and on every hash change, so the back button works across all of them.

Test Steps

What each test did, step by step, and where its time went. Suite, then a test in it, then what that test did. It is a page of its own rather than a panel inside Test Suites, precisely so the high-level page stays skimmable.

It is never empty. Even a suite that has never named a step gets a tree: every test has a set up, a test body and a tear down, each independently timed, and every test carries its markers, its parameters, the fixtures it named and its docstring. Naming steps makes the tree deeper; it does not bring it into existence. A phase with no steps of its own is still drawn, because its duration is the answer to "where did the time go" for the test whose setup is the slow part — which is exactly the test least likely to have named anything.

The rail on the left lists suites as collapsible groups. Each test is a button with a status dot, its name, a one-line note (14 steps, the scenario name, or no steps named) and its duration. On a first visit every group is shut, so a 600-test run shows the shape of the suite rather than nine screens of names. Expand all and Collapse all sit in the toolbar beside the filter pills — All, Failed, Scenarios, Tests, each carrying a count and each omitted when it would match nothing — and a search box, Search by test, step, marker or feature. Underscores are indexed both ways, so typing declined card finds test_a_declined_card; a search opens any shut group holding a match, and clearing the box leaves groups as they are rather than shutting one you opened by hand.

From 0.4.1 the pills can run to three rows, because they answer different questions and this team's blockers needs all three answered at once. The second row appears once anything in the run carries an owner: one pill per team, counted, busiest first, with Unowned at the end. The third appears once anything carries a severity, drawn in ladder order rather than by count, with Unrated last. Each row counts inside the rows above it, so a pill's number is always what the rail will show if you press it, and a row is not drawn at all for a run that wrote none of that marker.

The reading pane on the right opens with the test's name and suite and a status badge reading FAIL · 4.20 s. A BDD test leads with a Feature strip naming the feature, the scenario and the file. Then a row of facts — description, markers, parameters, fixtures — as badges, with a marker's tooltip saying where it was written ("user marker, from the class"), which is the answer when nobody remembers applying it. owner and severity are pulled out into rows of their own ahead of the tags, because who do I tell and how bad is this are not the question the rest of the row answers; a severity that was overridden by a nearer marker is still shown, struck through beside the one that won. Markers named in report_link_pattern become links, grouped under the marker they were written as, so a Jira row and a Testcase row each say which system their ids belong to. Then the three phase blocks with their own timings and the step lines inside them.

A step line carries a depth rail, a Given/When/Then badge for Gherkin steps only, its title, its parameters as the call that was actually made, a paperclip chip counting anything attached while it was open, a duration bar and its time. The bar is scaled against the slowest step of this test, never of the run — the question here is always which part of this test was slow. A step that threw shows its error inline in a <pre>.

The Test Steps tab: the suite rail on the left, filter pills above, and a failing scenario open on the right with its Given/When/Then tree, error and screenshot The Test Steps tab: the suite rail on the left, filter pills above, and a failing scenario open on the right with its Given/When/Then tree, error and screenshot
Test Steps — the rail on the left, and on the right the phases of one test with its Gherkin steps, the failure’s message and the screenshot taken on the step that failed.

What opens by default is not the first row in collection order — that is almost always a test that named no steps, and the tab would introduce itself with three empty phases. The pick is the first failure that has a tree, then the first test with a tree, then whatever is there.

Screenshots are filed under the step they belong to. A picture taken while a step was open says so itself; the automatic capture runs from the teardown with nothing open, and those are filed against the step that threw, because a photograph of the browser at the end of a failing test is a photograph of the state that step left behind. Those thumbnails form their own lightbox group, so arrowing across stays inside the test being read.

A How it works button in the header opens the How test steps work cheatsheet. Its contents are the same markup as the setup guide below the tree, cloned on open, so the guide someone reads on an empty run and the one they open from the button can never drift apart.

Archives

Every retained build, side by side. A fixed 220px rail runs down the right edge listing each one newest first — a tick or a cross in the run's pass/fail colour, build #N, and the date and time it started. The main column is one card per build under a Build #N heading, carrying that build's total TEST CASES count, its date, a stacked horizontal bar chart of PASS / FAIL / SKIP / XPASS / XFAIL / ERROR, and a seven-cell counter strip beneath: PASSED, FAILED, SKIPPED, XPASSED, XFAILED, ERROR, RERUN.

The rail and the cards are wired as a scrollspy, so scrolling highlights the build you are looking at. Clicking a build in the rail does not simply follow its anchor: showing a tab re-sizes its charts, which would shift the layout out from under a plain anchor jump, and re-clicking the build you are already on fires no navigation at all. The click is intercepted, the hash written, and the build scrolled into view once the relayout has settled. That is what makes #list-item-7 work as a link from anywhere.

The Archives tab: a build card with its total, date, stacked bar and seven counters, and the build rail down the right The Archives tab: a build card with its total, date, stacked bar and seven counters, and the build rail down the right
Archives — one card per retained build, with the rail on the right jumping between them.

What survives is set by --archive-count, --archive-days and --archive-since, which intersect: a build has to satisfy every limit that is set. Setting none keeps everything until the report is slow to open. The build being reported now counts against --archive-count, so one fewer is kept on disk, and --archive-count=0 deletes the archive directory outright. Below 1200px the rail stops being fixed and becomes a scrollable strip above the cards.

Screenshots

The page each test was looking at, beside the suite and the error it belongs to. A responsive grid of cards. Each card is the image itself with an expand glyph on hover and a status badge in its corner, and below it the test name, the suite behind a briefcase icon, and the error the picture explains. A passing test's screenshot simply carries no note line.

A toolbar sits above the grid: status filter pills in a fixed order — All, then Failed, Error, Passed, Skipped, xPassed, xFailed, Rerun — and a search box, Search by test, suite or error. Which pills exist is decided by the gallery, never by the search, because a row that gained and lost buttons as you typed would move the search box out from under the cursor; a pill the search has emptied goes quiet rather than disappearing. Their counts are computed against the search alone, not against a sibling pill's selection, because a pill has to say how many it would show. When only one status exists the whole row is dropped: one pill is not a choice, it is the gallery with a label on it.

Summary chips above the toolbar count what is actually on screen — screenshots shown, distinct suites, failures — so the numbers can never disagree with the cards beneath them.

The Screenshots gallery: four cards, each with a FAIL badge, the capture, the test and suite name and the error underneath The Screenshots gallery: four cards, each with a FAIL badge, the capture, the test and suite name and the error underneath
The Screenshots gallery — one card per capture, badged with the outcome and carrying the error that produced it.

The lightbox

Clicking any thumbnail anywhere in the report opens the report's own lightbox — its own, because the library the gallery used to use could not be redistributed under MIT. It shows the caption, SUITE: … :: SCENARIO: …, and an n / N counter, and offers previous and next buttons, the and arrow keys and Escape.

Its strip is the gallery as it currently stands: arrowing out of a filtered set into a screenshot you have just filtered away would undo the filter for you. Groups keep the strips separate — the Test Metrics row thumbnails travel the whole table, the Test Steps thumbnails stay inside the one test being read, and the gallery is its own group. Closing drops the image source, so a large screenshot is not left sitting in memory behind a shut overlay.

A search matching nothing says No screenshot matches what you asked for, which is distinct from the run having attached nothing — the three-step setup guide would be a strange answer to a typo. Capture itself is covered in full on Screenshots.

API Logs

The request and response behind each test, with the curl line that repeats the call. Every payload the run attached is already in the page, parked in a hidden store. The rail is the index into it: filtering hides entries, opening one clones its parts into the viewer. Nothing is fetched, so the tab behaves identically from a file:// report, from an email attachment and off a CI server.

The rail gives one button per attachment carrying a kind badge (API, JSON, Text or File), the title, the test name with its suite beneath, an HTTP status chip coloured by class — 2xx, 4xx, 5xx, since what you scan for is which of these went wrong, not which returned 204 — and a detail cell showing the call's duration or the payload's size. Kind pills with counts and a search box sit above it; the search, Search by test, title, URL or status, matches suite, test, title, kind, status code and every meta value, so a URL, a method or a status code finds the call. The payloads themselves are not indexed: they are thousands of characters each, and repeating them in an attribute would double the file size to no end.

The viewer shows the title, the test and suite, a meta row of Method, URL, Status, Time, Size and Content-Type, then a tab strip and the payload. An API attachment has five parts — Response body, Request body, Request headers, Response headers and cURL — each formatted by its declared format. A single-part attachment collapses the strip rather than offering one button that does nothing. Copy puts the open part on the clipboard, with the same textarea fallback the other panels use; Download saves it as a file named after the test and title, with the extension chosen from the format.

The API Logs tab: kind pills, a rail of attachments with status codes and durations, and one call open showing its meta row and formatted response body The API Logs tab: kind pills, a rail of attachments with status codes and durations, and one call open showing its meta row and formatted response body
API Logs — the rail on the left, and one call open on the right with its meta row and the five parts of the exchange.

Arriving from the Data button on a Test Metrics row narrows the rail to that one test and shows a banner — Showing what test_creates_an_order attached — with a Show all button. While a scope is on, the pill counts are computed within it: an "API 3" that silently included two other tests' calls would be a filter lying about what it does. Summary chips above the rail count what it is showing: attachments, API calls, ok, failed, the slowest call and the total payload size.

CarefulCredentials are blanked out before anything is written — in headers, in a ?api_key= query string, in the curl line and in the fields of a JSON body. Matching is on a substring of the lower-cased name, so X-Api-Key, Proxy-Authorization and refresh_token are all covered without listing every spelling anyone has used. A report is a build artifact, and build artifacts get published.

Test Coverage

How much of the code under test this run actually ran. Whatever pytest-cov measured is read straight out of the finished run, so the number here is the number the terminal just printed. Nothing is re-executed.

The hero card is an SVG ring with the percentage and the word covered in its centre, coloured by grade. Grading follows the project's own line when it has one: a project running --cov-fail-under has already said where its bar is, and colouring the ring against a number this plugin picked would put the report at odds with the build that just passed. With no target set, the bands are 90% strong, 75% fair, below that low.

Beside the ring sit stat tiles — Statements, Covered, Missing, Files — plus Branches and Partial only when branch coverage was actually switched on, since a Branches 0/0 tile says nothing except that --cov-branch was not passed. Under them a meta row says where the numbers came from and when (Measured by pytest-cov during this run, or Read from coverage.xml, written 2026-09-03 08:12), the delta since the last build that measured any, the target when one is set, and an Annotated source link.

Coverage across the last builds is an area line, oldest first because drift reads left to right, pinned to a full 0–100 axis in steps of 25 — an axis that fits itself to the data turns half a point of movement into a cliff. A build that measured nothing leaves a gap rather than a drop to zero it never took, and the chart is drawn only when at least two retained builds measured coverage.

The per-file table has columns File, Statements, Missing, Branches, Coverage and Missing lines, and sorts ascending on Coverage by default, because the files worth opening the tab for are the ones at the bottom. The Branches cell shows an em dash, not a 0, for a file with no branches to cover — that is not the same statement as "none of its branches are covered" — and the whole Branches column is hidden when the run measured no branches at all. Missing lines are written the way coverage.py writes them (12-15, 88, 91-140).

The Test Coverage tab: the percentage ring, statement counts, the coverage trend across builds and the per-file table sorted least-covered first The Test Coverage tab: the percentage ring, statement counts, the coverage trend across builds and the per-file table sorted least-covered first
Test Coverage — the ring, the counts, the trend across builds and the per-file table, least covered first.
NoteThe annotated source is linked, never embedded. Framing htmlcov in would empty this tab the moment the report was mailed on its own; a link that does not resolve at least says so in the address bar. Use --report-link "Coverage=htmlcov/index.html" to keep it in the side nav as well.

The Environment dialog

Opened from the Environment chip on the Dashboard, and rendered as an overlay rather than as a card row so the dashboard cards keep their full height. It is a two-column grid of labelled values, closed by its × button, by clicking the backdrop, or by Escape.

If --environment was given it leads the list and also appears as a badge beside the report title on the Dashboard. Any number of --build-info key=value pairs are inserted after it — and they win: a branch, commit, ci or pipeline you have named yourself is the answer shown, and the detected one is dropped rather than rendered beside it disagreeing.

One row is opt-in. --report-packages adds a Packages row holding every installed distribution and its version, the way pip freeze reads, with the count in the label. It is off by default: a few hundred entries nobody reads until the day the report is the only surviving record of what was installed.

NoteUnder a sharded run this panel is filled through a seam rather than read directly, so a merged report names every shard's machine and not the machine that did the merging, which ran no tests at all.

Escape closes any dialog

Five overlays share one visual language — a backdrop that closes on click, a titled head with a × button, and Escape closing all of them: the Environment dialog, the captured-output and full-error panel, the and N more list, the How test steps work cheatsheet, and the screenshot lightbox. Escape also shuts an open row action strip on Test Metrics.

KeyWhat it does
EscCloses any open overlay, and shuts an open row action strip.
/ Previous and next screenshot while the lightbox is open.
Enter / SpaceActivates a jump cell — a suite or test name — exactly as a click does.

Light and dark themes

The switch sits at the foot of the side nav, labelled Dark mode, because the rail is the one thing on screen in every tab. It is a real switch for assistive technology, and its state and its title both flip with the theme — drawn from the same attribute the page settled before it painted, so the two cannot disagree on first paint.

The choice is remembered per reader and follows the operating system until it is touched. Before anything paints, a small script reads the stored choice; with none stored it asks the system preference, and either way it writes an explicit theme onto the document. That is why there is no white flash on the way into a dark report. A change to the OS preference re-themes the page only while nothing is stored — once you use the switch, the report stops speaking for you.

Screenshot: assets/img/shots/report-theme-switch.gif A 5-second looping GIF at 1440px, recorded on the Dashboard so the doughnut, the Trends line and the stacked Test Suite bars are all in frame. The cursor moves to the Dark mode switch at the foot of the rail and clicks; every chart re-colours in place with no reload and no flash; hold two seconds on the dark result, then click back to light. The point of the shot is that the charts re-theme without the page reloading.

Every colour in the report is a token, grouped by role — page grounds, surfaces, the rail, borders, text, tables, code bodies — and the status language is carried across both themes unchanged: pass green, fail red, skip amber, xpass grey, xfail magenta, error dark red, rerun gold. The light column is the palette the report has always shipped, kept value for value, so a reader who never touches the switch sees exactly what they saw before.

The charts follow. Each one declares its palette by token name and is re-resolved and redrawn on a theme change, so a switch re-colours every doughnut, line and bar in place rather than needing a reload. prefers-reduced-motion: reduce is honoured for the row-action slide, the target-row flash and the other transitions.

NoteThere is no flag and no ini key for the theme. It is a reader-side switch, not run configuration. A report opened from file:// can have storage refused outright by the browser, which costs that reader their saved choice — not the theme itself.

Where to go next

This page is the walk-through. The tabs that carry the most behaviour have pages of their own.

If a tab in your own report is showing a setup guide rather than data, that is the report saying the run produced nothing for it — not that the feature is broken. Four tabs do this: Screenshots, API Logs, Test Coverage and Test Steps. Each guide is three copy-pasteable steps, and the flag or call it names is in the CLI reference and the Python API. For everything the plugin can do at a glance, see Features; for the first run, Getting started.