Share a Flutter test and coverage report
flutter test --coverage gives you coverage/lcov.info. That is not a
report — it is a machine format that a tool is supposed to read. Yet it is
routinely what gets uploaded as a CI artifact and then never opened, since
opening it accomplishes nothing.
The readable artifact is one conversion away.
Convert it
flutter test --coverage || true
# strip generated code first — it dominates the numbers otherwise
lcov --remove coverage/lcov.info \
'*.g.dart' '*.freezed.dart' '*.mocks.dart' \
-o coverage/filtered.info
genhtml coverage/filtered.info -o coverage/html
# → coverage/html/index.html
genhtml ships with lcov (brew install lcov, apt install lcov). The
filtering step is not optional in practice: a Flutter project with
json_serializable and freezed generates a great deal of Dart, and
including it produces a coverage number that means nothing.
The output is a directory — index.html, a page per source file,
plus stylesheets — so publish the folder.
Drop the folder in
Drag coverage/html/ (or a zip) into Comma:
index.htmlbecomes the report body — the page carrying the comment layer.- The per-file pages and CSS upload alongside it, with relative references rewritten, so the drill-down from summary to directory to annotated Dart resolves.
- Scripts run inside a sandboxed iframe (
allow-scripts, noallow-same-origin), so the sortable columns keep working.
From CI
curl -fsS -X PATCH "https://commareports.com/api/v1/reports/$REPORT_ID" \
-H "Authorization: Bearer $COMMA_API_TOKEN" \
-H "Content-Type: application/json" \
-d "$(jq -n --rawfile html coverage/html/index.html \
--arg title "Coverage — $(git rev-parse --short HEAD)" \
'{title: $title, html: $html}')"
The API is JSON-only, so the per-file pages go up through
POST /api/v1/reports/$REPORT_ID/assets as base64 — one call per file,
worth scripting once. See the API reference.
The || true on the test command and an if: always() on the step keep
the publish alive when a test fails, which is when the report matters. Use
a scoped token (reports:write) from CI secrets and
PATCH a saved report id. See publishing from CI.
Test results, if you want them too
flutter test --machine emits a JSON event stream that JUnit converters
consume, which is the route if your pipeline wants a results view
alongside coverage:
flutter test --machine | tojunit --output junit.xml
See share a JUnit report. For integration tests driving a real device, the shape is closer to the Android and Xcode reports.
What the URL changes
- The PR links the coverage. Reviewers click instead of running the
suite and
genhtmllocally. - Review lands on the widget. Select the uncovered branch, leave a thread — "this is the error state, it has no test" — and it stays anchored across runs. See commenting on HTML.
- Revision diffs beat the percentage, especially in Flutter where the number swings whenever the generated-code filter changes.
Who can see it
The coverage report contains your Dart source. Access is per report — private, your team, any signed-in user with the link, or public — and team or private is the right default for a private repo. Domain-gating is Enterprise. See sharing & access control.
Limits
- Entry HTML: 5 MB. Assets: 25 MB per file, 250 MB and 500 files total. A large app can exceed 500 source pages — publish the feature directory under review rather than the whole tree.
- Scripts run, sandboxed — no same-origin access.
- 60 requests/minute per token.
Try it
Comma is free — unlimited reports, unlimited commenters, unlimited revision history.