The coverage summary loads, the file links 404
htmlcov/index.html opens, the table of files renders, the percentages
look right — and clicking any filename gives File not found.
Only the index travelled
Every coverage tool emits a directory, not a file. The index is a table of contents; the content is one generated page per source file:
htmlcov/ # coverage.py
├── index.html ← the summary table
├── style.css coverage_html.js ← shared assets
├── z_a1b2c3_models_py.html ← one page per source file
├── z_a1b2c3_views_py.html
└── … several hundred more
The names differ, the shape does not:
| Tool | Directory | Assets |
|---|---|---|
| coverage.py | htmlcov/ |
style.css, coverage_html.js |
| Istanbul / nyc | coverage/lcov-report/ |
prettify.js, base.css |
| JaCoCo | target/site/jacoco/ |
jacoco-resources/ |
| SimpleCov | coverage/ |
assets/<version>/ |
| genhtml (lcov) | out/ |
gcov.css, amber.png |
Go (go tool cover) |
single file | — (the exception) |
Attach index.html to a ticket, drop it in a chat, copy it out of the
build directory — and you have shipped the table of contents of a book
you left behind.
The variants of this bug
Sorting and search stop working too. Those are coverage_html.js
and friends. Missing assets take the interactivity with them, which
looks like a separate bug and is the same one —
the five causes of a report losing its CSS.
It works locally, 404s in CI. The artifact upload had a path: that
matched only the entry file, or the copy step used the file instead of
the directory.
It works in CI's viewer, breaks after download. Now you are on
file://, where relative paths and any XHR-loaded data both fail. See
why localhost was the wrong fix.
Everything 404s except index. Some tools emit paths with characters that a static host or bucket key-encodes differently than the browser requests them. Bucket hosting also needs per-extension content types, or the pages download instead of rendering.
Keep the whole tree
Publish the directory as a unit. index.html becomes the report body;
every generated page, stylesheet and script uploads alongside it with
relative references rewritten to the uploaded copies, so the drill-down
resolves over HTTPS for anyone with the link.
From CI, at the end of the job:
- name: Publish coverage
if: always()
run: |
coverage html # or nyc report --reporter=lcov-report
# upload the folder — multi-file call in /docs/ci
Limits: entry HTML 5 MB; assets 25 MB per file, 250 MB and 500 files per report. A large monorepo's per-file pages can approach the file count — scope the report to the package under review, which is usually what the reviewer wanted anyway.
Why the drill-down is the point
"Coverage dropped 0.4%" starts an argument that nobody can settle in chat. The per-line view settles it: the reviewer opens the file, sees the uncovered branch, selects it, and comments there — comment on HTML. Publish the same report id every run and each build appends a revision, so the trend and the argument live at one address.
Try it
Comma is free — unlimited reports, unlimited commenters, unlimited revision history.