Some checks failed
Code scanning / CodeQL (push) Failing after 15s
Zulip production suite / Ubuntu 22.04 production build (push) Failing after 26s
Zulip production suite / Debian 12 production install with custom db name and user (push) Has been skipped
Zulip production suite / Ubuntu 22.04 production install and PostgreSQL upgrade with pgroonga (push) Has been skipped
Zulip production suite / Ubuntu 24.04 production install (push) Has been skipped
Zulip production suite / Ubuntu 26.04 production install (push) Has been skipped
Zulip production suite / Debian 13 production install (push) Has been skipped
Zulip production suite / 7.0 Version Upgrade (push) Has been skipped
Zulip production suite / 8.0 Version Upgrade (push) Has been skipped
Zulip production suite / 6.0 Version Upgrade (push) Has been skipped
Zulip production suite / 10.0 Version Upgrade (push) Has been skipped
Zulip production suite / 9.0 Version Upgrade (push) Has been skipped
Zulip production suite / 12.0 Version Upgrade (push) Has been skipped
Zulip production suite / 11.0 Version Upgrade (push) Has been skipped
GitHub Actions Security Analysis with zizmor / zizmor (push) Failing after 3s
Zulip CI / Debian 12 (Python 3.11, backend + documentation) (push) Failing after 26s
Zulip CI / Ubuntu 22.04 (Python 3.10, backend + frontend) (push) Failing after 3s
Zulip CI / Ubuntu 24.04 (Python 3.12, backend) (push) Failing after 25s
Zulip CI / Ubuntu 26.04 (Python 3.14, backend) (push) Failing after 25s
Zulip CI / Debian 13 (Python 3.13, backend) (push) Failing after 28s
Zulip production suite / Required jobs (push) Has been cancelled
Zulip CI / Required jobs (push) Has been cancelled
API Documentation Update Check / check-feature-level-updated (push) Has been cancelled
API Documentation Update Check / notify-if-api-docs-changed (push) Has been cancelled
224 lines
7.0 KiB
Python
Executable File
224 lines
7.0 KiB
Python
Executable File
#!/usr/bin/env python3
|
|
"""Analyze backend test coverage and report missing lines with source context.
|
|
|
|
Usage:
|
|
# First, generate coverage data:
|
|
./tools/test-backend --coverage --no-cov-cleanup --no-html-report <module>
|
|
|
|
# Then analyze specific files:
|
|
./.claude/fix-backend-coverage/analyze-coverage zerver/tests/test_import_export.py
|
|
|
|
# Or analyze all enforced files with missing coverage:
|
|
./.claude/fix-backend-coverage/analyze-coverage
|
|
"""
|
|
|
|
import glob
|
|
import os
|
|
import sys
|
|
|
|
SCRIPT_DIR = os.path.dirname(os.path.abspath(__file__))
|
|
ROOT_DIR = os.path.dirname(os.path.dirname(SCRIPT_DIR))
|
|
os.chdir(ROOT_DIR)
|
|
sys.path.insert(0, ROOT_DIR)
|
|
|
|
from tools.lib import sanity_check
|
|
|
|
sanity_check.check_venv(__file__)
|
|
|
|
import coverage
|
|
|
|
# These lists are duplicated from tools/test-backend so that this script
|
|
# can run independently. Keep them in sync.
|
|
source_files = [
|
|
"analytics/**/*.py",
|
|
"confirmation/**/*.py",
|
|
"corporate/**/*.py",
|
|
"pgroonga/**/*.py",
|
|
"zerver/**/*.py",
|
|
"zilencer/**/*.py",
|
|
"zproject/**/*.py",
|
|
]
|
|
|
|
not_yet_fully_covered = [
|
|
"*/migrations/*.py",
|
|
"*/management/commands/*.py",
|
|
"analytics/lib/fixtures.py",
|
|
"analytics/views/stats.py",
|
|
"corporate/views/installation_activity.py",
|
|
"corporate/views/plan_activity.py",
|
|
"corporate/views/realm_activity.py",
|
|
"corporate/views/remote_billing_page.py",
|
|
"corporate/views/audit_logs.py",
|
|
"corporate/views/support.py",
|
|
"corporate/lib/activity.py",
|
|
"corporate/lib/remote_billing_util.py",
|
|
"zerver/lib/addressee.py",
|
|
"zerver/lib/markdown/__init__.py",
|
|
"zerver/lib/cache.py",
|
|
"zerver/lib/cache_helpers.py",
|
|
"zerver/lib/i18n.py",
|
|
"zerver/lib/send_email.py",
|
|
"zerver/lib/url_preview/preview.py",
|
|
"zerver/lib/markdown/api_arguments_table_generator.py",
|
|
"zerver/lib/markdown/fenced_code.py",
|
|
"zerver/lib/markdown/nested_code_blocks.py",
|
|
"zerver/worker/deferred_work.py",
|
|
"zerver/worker/missedmessage_emails.py",
|
|
"zerver/worker/base.py",
|
|
"zerver/worker/queue_processors.py",
|
|
"zerver/worker/test.py",
|
|
"zerver/middleware.py",
|
|
"zerver/lib/bot_lib.py",
|
|
"zerver/lib/camo.py",
|
|
"zerver/lib/debug.py",
|
|
"zerver/lib/export.py",
|
|
"zerver/lib/fix_unreads.py",
|
|
"zerver/lib/import_realm.py",
|
|
"zerver/lib/logging_util.py",
|
|
"zerver/lib/profile.py",
|
|
"zerver/lib/queue.py",
|
|
"zerver/lib/sqlalchemy_utils.py",
|
|
"zerver/lib/storage.py",
|
|
"zerver/lib/templates.py",
|
|
"zerver/lib/generate_test_data.py",
|
|
"zerver/lib/server_initialization.py",
|
|
"zerver/lib/test_fixtures.py",
|
|
"zerver/lib/test_runner.py",
|
|
"zerver/lib/test_console_output.py",
|
|
"zerver/lib/zstd_level9.py",
|
|
"zerver/openapi/curl_param_value_generators.py",
|
|
"zerver/openapi/javascript_examples.py",
|
|
"zerver/openapi/python_examples.py",
|
|
"zerver/openapi/test_curl_examples.py",
|
|
"zerver/openapi/merge_api_changelogs.py",
|
|
"zerver/tornado/descriptors.py",
|
|
"zerver/tornado/django_api.py",
|
|
"zerver/tornado/event_queue.py",
|
|
"zerver/tornado/exceptions.py",
|
|
"zerver/tornado/handlers.py",
|
|
"zerver/tornado/ioloop_logging.py",
|
|
"zerver/tornado/sharding.py",
|
|
"zerver/tornado/views.py",
|
|
"zerver/data_import/slack.py",
|
|
"zerver/data_import/import_util.py",
|
|
"zerver/webhooks/greenhouse/view.py",
|
|
"zerver/webhooks/jira/view.py",
|
|
"zerver/webhooks/teamcity/view.py",
|
|
"zerver/webhooks/travis/view.py",
|
|
"zerver/webhooks/zapier/view.py",
|
|
"zerver/views/sentry.py",
|
|
"zerver/lib/safe_session_cached_db.py",
|
|
"zerver/lib/singleton_bmemcached.py",
|
|
"zerver/lib/migrate.py",
|
|
"zproject/computed_settings.py",
|
|
"zproject/custom_dev_settings.py",
|
|
"zproject/dev_settings.py",
|
|
"zproject/test_extra_settings.py",
|
|
"zproject/sentry.py",
|
|
"zproject/wsgi.py",
|
|
]
|
|
|
|
enforce_fully_covered = sorted(
|
|
{path for target in source_files for path in glob.glob(target, recursive=True)}
|
|
- {path for target in not_yet_fully_covered for path in glob.glob(target, recursive=True)}
|
|
)
|
|
|
|
not_yet_set = {
|
|
path for target in not_yet_fully_covered for path in glob.glob(target, recursive=True)
|
|
}
|
|
|
|
|
|
def classify_path(path: str) -> str:
|
|
if path in enforce_fully_covered:
|
|
return "ENFORCED - must be 100%"
|
|
if path in not_yet_set:
|
|
return "EXEMPT - in not_yet_fully_covered"
|
|
return "UNKNOWN - not in source_files"
|
|
|
|
|
|
def print_source_context(path: str, line_no: int) -> None:
|
|
"""Print a missing line with 1 line of context above and below."""
|
|
try:
|
|
with open(path) as f:
|
|
lines = f.readlines()
|
|
except OSError:
|
|
print(f" (could not read {path})")
|
|
return
|
|
|
|
start = max(0, line_no - 2)
|
|
end = min(len(lines), line_no + 1)
|
|
for i in range(start, end):
|
|
prefix = " >> " if i == line_no - 1 else " "
|
|
print(f"{prefix}{i + 1:>5}| {lines[i].rstrip()}")
|
|
|
|
|
|
def analyze_file(cov: coverage.Coverage, path: str) -> bool:
|
|
"""Analyze coverage for a single file. Returns True if fully covered."""
|
|
classification = classify_path(path)
|
|
try:
|
|
_filename, statements, excluded, missing, _formatted = cov.analysis2(path)
|
|
except coverage.misc.NoSource:
|
|
print(f"=== {path} [{classification}] ===")
|
|
print(" No source found in coverage data.\n")
|
|
return True
|
|
|
|
total = len(statements)
|
|
excluded_count = len(excluded)
|
|
missing_count = len(missing)
|
|
covered = total - missing_count
|
|
pct = (covered / total * 100) if total > 0 else 100.0
|
|
|
|
print(f"=== {path} [{classification}] ===")
|
|
print(
|
|
f"Statements: {total} | Excluded: {excluded_count} | Missing: {missing_count} | Coverage: {pct:.1f}%"
|
|
)
|
|
|
|
if missing:
|
|
print("\nMissing lines:")
|
|
for line_no in missing:
|
|
print()
|
|
print_source_context(path, line_no)
|
|
print()
|
|
else:
|
|
print(" All lines covered!\n")
|
|
|
|
if excluded:
|
|
excluded_str = ", ".join(str(line) for line in excluded)
|
|
print(f"Excluded lines: {excluded_str}\n")
|
|
|
|
return missing_count == 0
|
|
|
|
|
|
def main() -> None:
|
|
cov = coverage.Coverage(config_file="tools/coveragerc")
|
|
try:
|
|
cov.load()
|
|
except coverage.misc.NoDataError:
|
|
print("ERROR: No coverage data found at var/.coverage")
|
|
print("Run tests with --coverage first:")
|
|
print(" ./tools/test-backend --coverage --no-cov-cleanup --no-html-report <module>")
|
|
sys.exit(1)
|
|
|
|
files = sys.argv[1:]
|
|
if not files:
|
|
# Analyze all enforced files that have missing coverage.
|
|
print("No files specified; checking all enforced files for missing coverage...\n")
|
|
any_missing = False
|
|
for path in enforce_fully_covered:
|
|
try:
|
|
missing_lines = cov.analysis2(path)[3]
|
|
except coverage.misc.NoSource:
|
|
continue
|
|
if missing_lines:
|
|
analyze_file(cov, path)
|
|
any_missing = True
|
|
if not any_missing:
|
|
print("All enforced files have 100% coverage!")
|
|
else:
|
|
for path in files:
|
|
analyze_file(cov, path)
|
|
|
|
|
|
if __name__ == "__main__":
|
|
main()
|