| # Licensed under the Apache License: http://www.apache.org/licenses/LICENSE-2.0 |
| # For details: https://github.com/nedbat/coveragepy/blob/master/NOTICE.txt |
| |
| """Tests that HTML generation is awesome.""" |
| |
| from __future__ import annotations |
| |
| import collections |
| import datetime |
| import glob |
| import json |
| import os |
| import os.path |
| import re |
| import sys |
| |
| from html.parser import HTMLParser |
| from typing import Any, IO |
| from unittest import mock |
| |
| import pytest |
| |
| import coverage |
| from coverage import env, Coverage |
| from coverage.exceptions import NoDataError, NotPython, NoSource |
| from coverage.files import abs_file, flat_rootname |
| import coverage.html |
| from coverage.report_core import get_analysis_to_report |
| from coverage.types import TLineNo, TMorf |
| |
| from tests import testenv |
| from tests.coveragetest import CoverageTest, TESTS_DIR |
| from tests.goldtest import gold_path |
| from tests.goldtest import compare, contains, contains_rx, doesnt_contain, contains_any |
| from tests.helpers import assert_coverage_warnings, change_dir |
| |
| |
| class HtmlTestHelpers(CoverageTest): |
| """Methods that help with HTML tests.""" |
| |
| def create_initial_files(self) -> None: |
| """Create the source files we need to run these tests.""" |
| self.make_file( |
| "main_file.py", |
| """\ |
| import helper1, helper2 |
| helper1.func1(12) |
| helper2.func2(12) |
| """, |
| ) |
| self.make_file( |
| "helper1.py", |
| """\ |
| def func1(x): |
| if x % 2: |
| print("odd") |
| """, |
| ) |
| self.make_file( |
| "helper2.py", |
| """\ |
| def func2(x): |
| print("x is %d" % x) |
| """, |
| ) |
| |
| def run_coverage( |
| self, |
| covargs: dict[str, Any] | None = None, |
| htmlargs: dict[str, Any] | None = None, |
| ) -> float: |
| """Run coverage.py on main_file.py, and create an HTML report.""" |
| self.clean_local_file_imports() |
| cov = coverage.Coverage(**(covargs or {})) |
| self.start_import_stop(cov, "main_file") |
| ret = cov.html_report(**(htmlargs or {})) |
| self.assert_valid_hrefs() |
| return ret |
| |
| def get_html_report_content(self, module: str) -> str: |
| """Return the content of the HTML report for `module`.""" |
| filename = flat_rootname(module) + ".html" |
| filename = os.path.join("htmlcov", filename) |
| with open(filename, encoding="utf-8") as f: |
| return f.read() |
| |
| def get_html_index_content(self) -> str: |
| """Return the content of index.html. |
| |
| Time stamps are replaced with a placeholder so that clocks don't matter. |
| |
| """ |
| with open("htmlcov/index.html", encoding="utf-8") as f: |
| index = f.read() |
| index = re.sub( |
| r"created at \d{4}-\d{2}-\d{2} \d{2}:\d{2} \+\d{4}", |
| r"created at YYYY-MM-DD HH:MM +ZZZZ", |
| index, |
| ) |
| index = re.sub( |
| r"created at \d{4}-\d{2}-\d{2} \d{2}:\d{2}", |
| r"created at YYYY-MM-DD HH:MM", |
| index, |
| ) |
| return index |
| |
| def get_html_report_text_lines(self, module: str) -> list[str]: |
| """Parse the HTML report, and return a list of strings, the text rendered.""" |
| parser = HtmlReportParser() |
| parser.feed(self.get_html_report_content(module)) |
| return parser.text() |
| |
| def assert_correct_timestamp(self, html: str) -> None: |
| """Extract the time stamp from `html`, and assert it is recent.""" |
| timestamp_pat = r"created at (\d{4})-(\d{2})-(\d{2}) (\d{2}):(\d{2})" |
| m = re.search(timestamp_pat, html) |
| assert m, "Didn't find a time stamp!" |
| timestamp = datetime.datetime(*[int(v) for v in m.groups()]) # type: ignore[arg-type] |
| # The time stamp only records the minute, so the delta could be from |
| # 12:00 to 12:01:59, or two minutes. |
| self.assert_recent_datetime( |
| timestamp, |
| seconds=120, |
| msg=f"Time stamp is wrong: {timestamp}", |
| ) |
| |
| def assert_valid_hrefs(self, directory: str = "htmlcov") -> None: |
| """Assert that the hrefs in htmlcov/*.html are valid. |
| |
| Doesn't check external links (those with a protocol). |
| """ |
| hrefs = collections.defaultdict(set) |
| for fname in glob.glob(f"{directory}/*.html"): |
| with open(fname, encoding="utf-8") as fhtml: |
| html = fhtml.read() |
| for href in re.findall(r""" href=['"]([^'"]*)['"]""", html): |
| if href.startswith("#"): |
| assert re.search(rf""" id=['"]{href[1:]}['"]""", html), ( |
| f"Fragment {href!r} in {fname} has no anchor" |
| ) |
| continue |
| if "://" in href: |
| continue |
| href = href.partition("#")[0] # ignore fragment in URLs. |
| hrefs[href].add(fname) |
| for href, sources in hrefs.items(): |
| assert os.path.exists(f"{directory}/{href}"), ( |
| f"These files link to {href!r}, which doesn't exist: {', '.join(sources)}" |
| ) |
| |
| |
| class HtmlReportParser(HTMLParser): |
| """An HTML parser for our HTML reports. |
| |
| Assertions are made about the structure we expect. |
| """ |
| |
| def __init__(self) -> None: |
| super().__init__() |
| self.lines: list[list[str]] = [] |
| self.in_source = False |
| |
| def handle_starttag(self, tag: str, attrs: list[tuple[str, str | None]]) -> None: |
| if tag == "main": |
| assert attrs == [("id", "source")] |
| self.in_source = True |
| elif self.in_source and tag == "a": |
| dattrs = dict(attrs) |
| assert "id" in dattrs |
| ida = dattrs["id"] |
| assert ida is not None |
| assert ida[0] == "t" |
| line_no = int(ida[1:]) |
| self.lines.append([]) |
| assert line_no == len(self.lines) |
| |
| def handle_endtag(self, tag: str) -> None: |
| if tag == "main": |
| self.in_source = False |
| |
| def handle_data(self, data: str) -> None: |
| if self.in_source and self.lines: |
| self.lines[-1].append(data) |
| |
| def text(self) -> list[str]: |
| """Get the rendered text as a list of strings, one per line.""" |
| return ["".join(l).rstrip() for l in self.lines] |
| |
| |
| class FileWriteTracker: |
| """A fake object to track how `open` is used to write files.""" |
| |
| def __init__(self, written: set[str]) -> None: |
| self.written = written |
| |
| def open(self, filename: str, mode: str = "r", encoding: str | None = None) -> IO[str]: |
| """Be just like `open`, but write written file names to `self.written`.""" |
| if mode.startswith("w"): |
| self.written.add(filename.replace("\\", "/")) |
| return open(filename, mode, encoding=encoding) |
| |
| |
| class HtmlDeltaTest(HtmlTestHelpers, CoverageTest): |
| """Tests of the HTML delta speed-ups.""" |
| |
| def setUp(self) -> None: |
| super().setUp() |
| |
| # At least one of our tests monkey-patches the version of coverage.py, |
| # so grab it here to restore it later. |
| self.real_coverage_version = coverage.__version__ |
| self.addCleanup(setattr, coverage, "__version__", self.real_coverage_version) |
| |
| self.files_written: set[str] |
| |
| def run_coverage( |
| self, |
| covargs: dict[str, Any] | None = None, |
| htmlargs: dict[str, Any] | None = None, |
| ) -> float: |
| """Run coverage in-process for the delta tests. |
| |
| For the delta tests, we always want `source=.` and we want to track |
| which files are written. `self.files_written` will be the file names |
| that were opened for writing in html.py. |
| |
| """ |
| covargs = covargs or {} |
| covargs["source"] = "." |
| self.files_written = set() |
| mock_open = FileWriteTracker(self.files_written).open |
| with mock.patch("coverage.html.open", mock_open): |
| return super().run_coverage(covargs=covargs, htmlargs=htmlargs) |
| |
| def assert_htmlcov_files_exist(self) -> None: |
| """Assert that all the expected htmlcov files exist.""" |
| self.assert_exists("htmlcov/index.html") |
| self.assert_exists("htmlcov/function_index.html") |
| self.assert_exists("htmlcov/class_index.html") |
| self.assert_exists("htmlcov/main_file_py.html") |
| self.assert_exists("htmlcov/helper1_py.html") |
| self.assert_exists("htmlcov/helper2_py.html") |
| self.assert_exists("htmlcov/.gitignore") |
| # Cache-busted files have random data in the name, but they should all |
| # be there, and there should only be one of each. |
| statics = ["style.css", "coverage_html.js", "keybd_closed.png", "favicon_32.png"] |
| files = os.listdir("htmlcov") |
| for static in statics: |
| base, ext = os.path.splitext(static) |
| busted_file_pattern = rf"{base}_cb_\w{{8}}{ext}" |
| matches = [m for f in files if (m := re.fullmatch(busted_file_pattern, f))] |
| assert len(matches) == 1, f"Found {len(matches)} files for {static}" |
| |
| def test_html_created(self) -> None: |
| # Test basic HTML generation: files should be created. |
| self.create_initial_files() |
| self.run_coverage() |
| self.assert_htmlcov_files_exist() |
| |
| def test_html_delta_from_source_change(self) -> None: |
| # HTML generation can create only the files that have changed. |
| # In this case, helper1 changes because its source is different. |
| self.create_initial_files() |
| self.run_coverage() |
| index1 = self.get_html_index_content() |
| |
| # Now change a file (but only in a comment) and do it again. |
| self.make_file( |
| "helper1.py", |
| """\ |
| def func1(x): # A nice function |
| if x % 2: |
| print("odd") |
| """, |
| ) |
| |
| self.run_coverage() |
| |
| # Only the changed files should have been created. |
| self.assert_htmlcov_files_exist() |
| assert "htmlcov/index.html" in self.files_written |
| assert "htmlcov/helper1_py.html" in self.files_written |
| assert "htmlcov/helper2_py.html" not in self.files_written |
| assert "htmlcov/main_file_py.html" not in self.files_written |
| |
| # Because the source change was only a comment, the index is the same. |
| index2 = self.get_html_index_content() |
| assert index1 == index2 |
| |
| def test_html_delta_from_coverage_change(self) -> None: |
| # HTML generation can create only the files that have changed. |
| # In this case, helper1 changes because its coverage is different. |
| self.create_initial_files() |
| self.run_coverage() |
| |
| # Now change a file and do it again. main_file is different, and calls |
| # helper1 differently. |
| self.make_file( |
| "main_file.py", |
| """\ |
| import helper1, helper2 |
| helper1.func1(23) |
| helper2.func2(23) |
| """, |
| ) |
| |
| self.run_coverage() |
| |
| # Only the changed files should have been created. |
| self.assert_htmlcov_files_exist() |
| assert "htmlcov/index.html" in self.files_written |
| assert "htmlcov/helper1_py.html" in self.files_written |
| assert "htmlcov/helper2_py.html" not in self.files_written |
| assert "htmlcov/main_file_py.html" in self.files_written |
| |
| def test_html_delta_from_settings_change(self) -> None: |
| # HTML generation can create only the files that have changed. |
| # In this case, everything changes because the coverage.py settings |
| # have changed. |
| self.create_initial_files() |
| self.run_coverage(covargs=dict(omit=[])) |
| index1 = self.get_html_index_content() |
| |
| self.run_coverage(covargs=dict(omit=["xyzzy*"])) |
| |
| # All the files have been reported again. |
| self.assert_htmlcov_files_exist() |
| assert "htmlcov/index.html" in self.files_written |
| assert "htmlcov/helper1_py.html" in self.files_written |
| assert "htmlcov/helper2_py.html" in self.files_written |
| assert "htmlcov/main_file_py.html" in self.files_written |
| |
| index2 = self.get_html_index_content() |
| assert index1 == index2 |
| |
| def test_html_delta_from_coverage_version_change(self) -> None: |
| # HTML generation can create only the files that have changed. |
| # In this case, everything changes because the coverage.py version has |
| # changed. |
| self.create_initial_files() |
| self.run_coverage() |
| index1 = self.get_html_index_content() |
| |
| # "Upgrade" coverage.py! |
| coverage.__version__ = "XYZZY" |
| |
| self.run_coverage() |
| |
| # All the files have been reported again. |
| self.assert_htmlcov_files_exist() |
| assert "htmlcov/index.html" in self.files_written |
| assert "htmlcov/helper1_py.html" in self.files_written |
| assert "htmlcov/helper2_py.html" in self.files_written |
| assert "htmlcov/main_file_py.html" in self.files_written |
| |
| index2 = self.get_html_index_content() |
| fixed_index2 = index2.replace("XYZZY", self.real_coverage_version) |
| assert index1 == fixed_index2 |
| |
| def test_file_becomes_100(self) -> None: |
| self.create_initial_files() |
| self.run_coverage() |
| |
| # Now change a file and do it again |
| self.make_file( |
| "main_file.py", |
| """\ |
| import helper1, helper2 |
| # helper1 is now 100% |
| helper1.func1(12) |
| helper1.func1(23) |
| """, |
| ) |
| |
| self.run_coverage(htmlargs=dict(skip_covered=True)) |
| |
| # The 100% file, skipped, shouldn't be here. |
| self.assert_doesnt_exist("htmlcov/helper1_py.html") |
| |
| def test_status_format_change(self) -> None: |
| self.create_initial_files() |
| self.run_coverage() |
| |
| with open("htmlcov/status.json", encoding="utf-8") as status_json: |
| status_data = json.load(status_json) |
| |
| assert status_data["format"] == 5 |
| status_data["format"] = 99 |
| with open("htmlcov/status.json", "w", encoding="utf-8") as status_json: |
| json.dump(status_data, status_json) |
| |
| self.run_coverage() |
| |
| # All the files have been reported again. |
| self.assert_htmlcov_files_exist() |
| assert "htmlcov/index.html" in self.files_written |
| assert "htmlcov/helper1_py.html" in self.files_written |
| assert "htmlcov/helper2_py.html" in self.files_written |
| assert "htmlcov/main_file_py.html" in self.files_written |
| |
| def test_dont_overwrite_gitignore(self) -> None: |
| self.create_initial_files() |
| self.make_file("htmlcov/.gitignore", "# ignore nothing") |
| self.run_coverage() |
| with open("htmlcov/.gitignore", encoding="utf-8") as fgi: |
| assert fgi.read() == "# ignore nothing" |
| |
| def test_dont_write_gitignore_into_existing_directory(self) -> None: |
| self.create_initial_files() |
| self.make_file("htmlcov/README", "My files: don't touch!") |
| self.run_coverage() |
| self.assert_doesnt_exist("htmlcov/.gitignore") |
| self.assert_exists("htmlcov/index.html") |
| |
| |
| class HtmlTitleTest(HtmlTestHelpers, CoverageTest): |
| """Tests of the HTML title support.""" |
| |
| def test_default_title(self) -> None: |
| self.create_initial_files() |
| self.run_coverage() |
| index = self.get_html_index_content() |
| assert "<title>Coverage report</title>" in index |
| assert "<h1>Coverage report:" in index |
| |
| def test_title_set_in_config_file(self) -> None: |
| self.create_initial_files() |
| self.make_file(".coveragerc", "[html]\ntitle = Metrics & stuff!\n") |
| self.run_coverage() |
| index = self.get_html_index_content() |
| assert "<title>Metrics & stuff!</title>" in index |
| assert "<h1>Metrics & stuff!:" in index |
| |
| def test_non_ascii_title_set_in_config_file(self) -> None: |
| self.create_initial_files() |
| self.make_file(".coveragerc", "[html]\ntitle = «ταБЬℓσ» numbers") |
| self.run_coverage() |
| index = self.get_html_index_content() |
| assert "<title>«ταБЬℓσ» numbers" in index |
| assert "<h1>«ταБЬℓσ» numbers" in index |
| |
| def test_title_set_in_args(self) -> None: |
| self.create_initial_files() |
| self.make_file(".coveragerc", "[html]\ntitle = Good title\n") |
| self.run_coverage(htmlargs=dict(title="«ταБЬℓσ» & stüff!")) |
| index = self.get_html_index_content() |
| expected = ( |
| "<title>«ταБЬℓσ» " |
| + "& stüff!</title>" |
| ) |
| assert expected in index |
| assert "<h1>«ταБЬℓσ» & stüff!:" in index |
| |
| |
| class HtmlWithUnparsableFilesTest(HtmlTestHelpers, CoverageTest): |
| """Test the behavior when measuring unparsable files.""" |
| |
| def test_dotpy_not_python(self) -> None: |
| self.make_file("main.py", "import innocuous") |
| self.make_file("innocuous.py", "a = 1") |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "main") |
| self.make_file("innocuous.py", "<h1>This isn't python!</h1>") |
| msg = "Couldn't parse '.*innocuous.py' as Python source: .* at line 1" |
| with pytest.raises(NotPython, match=msg): |
| cov.html_report() |
| |
| def test_dotpy_not_python_ignored(self) -> None: |
| self.make_file("main.py", "import innocuous") |
| self.make_file("innocuous.py", "a = 2") |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "main") |
| |
| self.make_file("innocuous.py", "<h1>This isn't python!</h1>") |
| with pytest.warns(Warning) as warns: |
| cov.html_report(ignore_errors=True) |
| assert_coverage_warnings( |
| warns, |
| re.compile(r"Couldn't parse Python file '.*innocuous.py' \(couldnt-parse\)"), |
| ) |
| self.assert_exists("htmlcov/index.html") |
| # This would be better as a glob, if the HTML layout changes: |
| self.assert_doesnt_exist("htmlcov/innocuous.html") |
| |
| def test_dothtml_not_python(self) -> None: |
| # Run an "HTML" file |
| self.make_file("innocuous.html", "a = 3") |
| self.make_data_file(lines={abs_file("innocuous.html"): [1]}) |
| # Before reporting, change it to be an HTML file. |
| self.make_file("innocuous.html", "<h1>This isn't python at all!</h1>") |
| cov = coverage.Coverage() |
| cov.load() |
| with pytest.raises(NoDataError, match="No data to report."): |
| cov.html_report() |
| |
| def test_execed_liar_ignored(self) -> None: |
| # Jinja2 sets __file__ to be a non-Python file, and then execs code. |
| # If that file contains non-Python code, a TokenError shouldn't |
| # have been raised when writing the HTML report. |
| source = "exec(compile('','','exec'), {'__file__': 'liar.html'})" |
| self.make_file("liar.py", source) |
| self.make_file("liar.html", "{# Whoops, not python code #}") |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "liar") |
| cov.html_report() |
| self.assert_exists("htmlcov/index.html") |
| |
| def test_execed_liar_ignored_indentation_error(self) -> None: |
| # Jinja2 sets __file__ to be a non-Python file, and then execs code. |
| # If that file contains untokenizable code, we shouldn't get an |
| # exception. |
| source = "exec(compile('','','exec'), {'__file__': 'liar.html'})" |
| self.make_file("liar.py", source) |
| # Tokenize will raise an IndentationError if it can't dedent. |
| self.make_file("liar.html", "0\n 2\n 1\n") |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "liar") |
| cov.html_report() |
| self.assert_exists("htmlcov/index.html") |
| |
| def test_decode_error(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/351 |
| # imp.load_module won't load a file with an undecodable character |
| # in a comment, though Python will run them. So we'll change the |
| # file after running. |
| self.make_file("main.py", "import sub.not_ascii") |
| self.make_file("sub/__init__.py") |
| self.make_file( |
| "sub/not_ascii.py", |
| """\ |
| # coding: utf-8 |
| a = 1 # Isn't this great?! |
| """, |
| ) |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "main") |
| |
| # Create the undecodable version of the file. make_file is too helpful, |
| # so get down and dirty with bytes. |
| with open("sub/not_ascii.py", "wb") as f: |
| f.write(b"# coding: utf-8\na = 1 # Isn't this great?\xcb!\n") |
| |
| with open("sub/not_ascii.py", "rb") as f: |
| undecodable = f.read() |
| assert b"?\xcb!" in undecodable |
| |
| cov.html_report() |
| |
| html_report = self.get_html_report_content("sub/not_ascii.py") |
| expected = "# Isn't this great?�!" |
| assert expected in html_report |
| |
| def test_formfeeds(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/360 |
| self.make_file("formfeed.py", "line_one = 1\n\f\nline_two = 2\n") |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "formfeed") |
| cov.html_report() |
| |
| formfeed_html = self.get_html_report_content("formfeed.py") |
| assert "line_two" in formfeed_html |
| |
| def test_splitlines_special_chars(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/1512 |
| # See https://docs.python.org/3/library/stdtypes.html#str.splitlines for |
| # the characters splitlines treats specially that readlines does not. |
| |
| # I'm not exactly sure why we need the "a" strings here, but the old |
| # code wasn't failing without them. |
| self.make_file( |
| "splitlines_is_weird.py", |
| """\ |
| test = { |
| "0b": ["\x0b0"], "a1": "this is line 2", |
| "0c": ["\x0c0"], "a2": "this is line 3", |
| "1c": ["\x1c0"], "a3": "this is line 4", |
| "1d": ["\x1d0"], "a4": "this is line 5", |
| "1e": ["\x1e0"], "a5": "this is line 6", |
| "85": ["\x850"], "a6": "this is line 7", |
| "2028": ["\u20280"], "a7": "this is line 8", |
| "2029": ["\u20290"], "a8": "this is line 9", |
| } |
| DONE = 1 |
| """, |
| ) |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "splitlines_is_weird") |
| cov.html_report() |
| |
| the_html = self.get_html_report_content("splitlines_is_weird.py") |
| assert "DONE" in the_html |
| |
| # Check that the lines are properly decoded and reported... |
| html_lines = the_html.split("\n") |
| assert any(re.search(r'id="t2".*"this is line 2"', line) for line in html_lines) |
| assert any(re.search(r'id="t9".*"this is line 9"', line) for line in html_lines) |
| |
| |
| class HtmlTest(HtmlTestHelpers, CoverageTest): |
| """Moar HTML tests.""" |
| |
| def test_missing_source_file_incorrect_message(self) -> None: |
| # https://github.com/nedbat/coveragepy/issues/60 |
| self.make_file("thefile.py", "import sub.another\n") |
| self.make_file("sub/__init__.py", "") |
| self.make_file("sub/another.py", "print('another')\n") |
| cov = coverage.Coverage() |
| self.start_import_stop(cov, "thefile") |
| os.remove("sub/another.py") |
| |
| missing_file = os.path.join(self.temp_dir, "sub", "another.py") |
| missing_file = os.path.realpath(missing_file) |
| msg = "(?i)No source for code: '%s'" % re.escape(missing_file) |
| with pytest.raises(NoSource, match=msg): |
| cov.html_report() |
| |
| def test_extensionless_file_collides_with_extension(self) -> None: |
| # It used to be that "program" and "program.py" would both be reported |
| # to "program.html". Now they are not. |
| # https://github.com/nedbat/coveragepy/issues/69 |
| self.make_file("program", "import program\n") |
| self.make_file("program.py", "a = 1\n") |
| self.make_data_file( |
| lines={ |
| abs_file("program"): [1], |
| abs_file("program.py"): [1], |
| } |
| ) |
| cov = coverage.Coverage() |
| cov.load() |
| cov.html_report() |
| self.assert_exists("htmlcov/index.html") |
| self.assert_exists("htmlcov/program.html") |
| self.assert_exists("htmlcov/program_py.html") |
| |
| def test_has_date_stamp_in_files(self) -> None: |
| self.create_initial_files() |
| self.run_coverage() |
| |
| with open("htmlcov/index.html", encoding="utf-8") as f: |
| self.assert_correct_timestamp(f.read()) |
| with open("htmlcov/main_file_py.html", encoding="utf-8") as f: |
| self.assert_correct_timestamp(f.read()) |
| |
| def test_reporting_on_unmeasured_file(self) -> None: |
| # It should be ok to ask for an HTML report on a file that wasn't even |
| # measured at all. https://github.com/nedbat/coveragepy/issues/403 |
| self.create_initial_files() |
| self.make_file("other.py", "a = 1\n") |
| self.run_coverage(htmlargs=dict(morfs=["other.py"])) |
| self.assert_exists("htmlcov/index.html") |
| self.assert_exists("htmlcov/other_py.html") |
| |
| def make_main_and_not_covered(self) -> None: |
| """Helper to create files for skip_covered scenarios.""" |
| self.make_file( |
| "main_file.py", |
| """\ |
| import not_covered |
| |
| def normal(): |
| print("z") |
| normal() |
| """, |
| ) |
| self.make_file( |
| "not_covered.py", |
| """\ |
| def not_covered(): |
| print("n") |
| """, |
| ) |
| |
| def test_report_skip_covered(self) -> None: |
| self.make_main_and_not_covered() |
| self.run_coverage(htmlargs=dict(skip_covered=True)) |
| self.assert_exists("htmlcov/index.html") |
| self.assert_doesnt_exist("htmlcov/main_file_py.html") |
| self.assert_exists("htmlcov/not_covered_py.html") |
| |
| def test_html_skip_covered(self) -> None: |
| self.make_main_and_not_covered() |
| self.make_file(".coveragerc", "[html]\nskip_covered = True") |
| self.run_coverage() |
| self.assert_exists("htmlcov/index.html") |
| self.assert_doesnt_exist("htmlcov/main_file_py.html") |
| self.assert_exists("htmlcov/not_covered_py.html") |
| index = self.get_html_index_content() |
| assert "1 file skipped due to complete coverage." in index |
| |
| def test_report_skip_covered_branches(self) -> None: |
| self.make_main_and_not_covered() |
| self.run_coverage(covargs=dict(branch=True), htmlargs=dict(skip_covered=True)) |
| self.assert_exists("htmlcov/index.html") |
| self.assert_doesnt_exist("htmlcov/main_file_py.html") |
| self.assert_exists("htmlcov/not_covered_py.html") |
| |
| def test_report_skip_covered_100(self) -> None: |
| self.make_file( |
| "main_file.py", |
| """\ |
| def normal(): |
| print("z") |
| normal() |
| """, |
| ) |
| res = self.run_coverage(covargs=dict(source="."), htmlargs=dict(skip_covered=True)) |
| assert res == 100.0 |
| self.assert_doesnt_exist("htmlcov/main_file_py.html") |
| # Since there are no files to report, we can't collect any region |
| # information, so there are no region-based index pages. |
| self.assert_doesnt_exist("htmlcov/function_index.html") |
| self.assert_doesnt_exist("htmlcov/class_index.html") |
| |
| def test_report_skip_covered_100_functions(self) -> None: |
| self.make_file( |
| "main_file.py", |
| """\ |
| def normal(): |
| print("z") |
| def abnormal(): |
| print("a") |
| normal() |
| """, |
| ) |
| res = self.run_coverage(covargs=dict(source="."), htmlargs=dict(skip_covered=True)) |
| assert res == 80.0 |
| self.assert_exists("htmlcov/main_file_py.html") |
| # We have a file to report, so we get function and class index pages, |
| # even though there are no classes. |
| self.assert_exists("htmlcov/function_index.html") |
| self.assert_exists("htmlcov/class_index.html") |
| |
| def make_init_and_main(self) -> None: |
| """Helper to create files for skip_empty scenarios.""" |
| self.make_file("submodule/__init__.py", "") |
| self.make_file( |
| "main_file.py", |
| """\ |
| import submodule |
| |
| def normal(): |
| print("z") |
| normal() |
| """, |
| ) |
| |
| def test_report_skip_empty(self) -> None: |
| self.make_init_and_main() |
| self.run_coverage(htmlargs=dict(skip_empty=True)) |
| self.assert_exists("htmlcov/index.html") |
| self.assert_exists("htmlcov/main_file_py.html") |
| self.assert_doesnt_exist("htmlcov/submodule___init___py.html") |
| index = self.get_html_index_content() |
| assert "1 empty file skipped." in index |
| |
| def test_html_skip_empty(self) -> None: |
| self.make_init_and_main() |
| self.make_file(".coveragerc", "[html]\nskip_empty = True") |
| self.run_coverage() |
| self.assert_exists("htmlcov/index.html") |
| self.assert_exists("htmlcov/main_file_py.html") |
| self.assert_doesnt_exist("htmlcov/submodule___init___py.html") |
| |
| |
| def filepath_to_regex(path: str) -> str: |
| """Create a regex for scrubbing a file path.""" |
| regex = re.escape(path) |
| # If there's a backslash, let it match either slash. |
| regex = regex.replace(r"\\", r"[\\/]") |
| if env.WINDOWS: |
| regex = "(?i)" + regex |
| return regex |
| |
| |
| def compare_html( |
| expected: str, |
| actual: str, |
| extra_scrubs: list[tuple[str, str]] | None = None, |
| ) -> None: |
| """Specialized compare function for our HTML files.""" |
| __tracebackhide__ = True # pytest, please don't show me this function. |
| scrubs = [ |
| (r"/coverage\.readthedocs\.io/?[-.\w/]*", "/coverage.readthedocs.io/VER"), |
| (r"coverage\.py v[\d.abcdev]+", "coverage.py vVER"), |
| (r"created at \d\d\d\d-\d\d-\d\d \d\d:\d\d [-+]\d\d\d\d", "created at DATE"), |
| (r"created at \d\d\d\d-\d\d-\d\d \d\d:\d\d", "created at DATE"), |
| # Static files have cache busting. |
| (r"_cb_\w{8}\.", "_CB."), |
| # Occasionally an absolute path is in the HTML report. |
| (filepath_to_regex(TESTS_DIR), "TESTS_DIR"), |
| (filepath_to_regex(flat_rootname(str(TESTS_DIR))), "_TESTS_DIR"), |
| # The temp dir the tests make. |
| (filepath_to_regex(os.getcwd()), "TEST_TMPDIR"), |
| (filepath_to_regex(flat_rootname(str(os.getcwd()))), "_TEST_TMPDIR"), |
| (filepath_to_regex(abs_file(os.getcwd())), "TEST_TMPDIR"), |
| (filepath_to_regex(flat_rootname(str(abs_file(os.getcwd())))), "_TEST_TMPDIR"), |
| (r"/private/var/[\w/]+/pytest-of-\w+/pytest-\d+/(popen-gw\d+/)?t\d+", "TEST_TMPDIR"), |
| # If the gold files were created on Windows, we need to scrub Windows paths also: |
| (r"[A-Z]:\\Users\\[\w\\]+\\pytest-of-\w+\\pytest-\d+\\(popen-gw\d+\\)?t\d+", "TEST_TMPDIR"), |
| ] |
| if extra_scrubs: |
| scrubs += extra_scrubs |
| compare(expected, actual, file_pattern="*.html", scrubs=scrubs) |
| |
| |
| def unbust(directory: str) -> None: |
| """Find files with cache busting, and rename them to simple names. |
| |
| This makes it possible for us to compare gold files. |
| """ |
| with change_dir(directory): |
| for fname in os.listdir("."): |
| base, ext = os.path.splitext(fname) |
| base, _, _ = base.partition("_cb_") |
| if base != fname: |
| os.rename(fname, base + ext) |
| |
| |
| class HtmlGoldTest(HtmlTestHelpers, CoverageTest): |
| """Tests of HTML reporting that use gold files.""" |
| |
| def test_a(self) -> None: |
| self.make_file( |
| "a.py", |
| """\ |
| if 1 < 2: |
| # Needed a < to look at HTML entities. |
| a = 3 |
| else: |
| a = 4 |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| a = self.start_import_stop(cov, "a") |
| cov.html_report(a, directory="out/a") |
| |
| compare_html(gold_path("html/a"), "out/a") |
| contains( |
| "out/a/a_py.html", |
| ( |
| '<span class="key">if</span> <span class="num">1</span> ' |
| + '<span class="op"><</span> <span class="num">2</span>' |
| ), |
| ( |
| ' <span class="nam">a</span> ' |
| + '<span class="op">=</span> <span class="num">3</span>' |
| ), |
| '<span class="pc_cov">67%</span>', |
| ) |
| contains( |
| "out/a/index.html", |
| '<a href="a_py.html">a.py</a>', |
| '<span class="pc_cov">67%</span>', |
| '<td class="right" data-ratio="2 3">67%</td>', |
| ) |
| |
| def test_b_branch(self) -> None: |
| self.make_file( |
| "b.py", |
| """\ |
| def one(x): |
| # This will be a branch that misses the else. |
| if x < 2: |
| a = 3 |
| else: |
| a = 4 |
| |
| one(1) |
| |
| def two(x): |
| # A missed else that branches to "exit" |
| if x: |
| a = 5 |
| |
| two(1) |
| |
| def three(): |
| try: |
| # This if has two branches, *neither* one taken. |
| if name_error_this_variable_doesnt_exist: |
| a = 1 |
| else: |
| a = 2 |
| except: |
| pass |
| |
| three() |
| """, |
| ) |
| |
| cov = coverage.Coverage(branch=True) |
| b = self.start_import_stop(cov, "b") |
| cov.html_report(b, directory="out/b_branch") |
| compare_html(gold_path("html/b_branch"), "out/b_branch") |
| contains( |
| "out/b_branch/b_py.html", |
| ( |
| '<span class="key">if</span> <span class="nam">x</span> ' |
| + '<span class="op"><</span> <span class="num">2</span>' |
| ), |
| ( |
| ' <span class="nam">a</span> <span class="op">=</span> ' |
| + '<span class="num">3</span>' |
| ), |
| '<span class="pc_cov">70%</span>', |
| ( |
| '<span class="annotate short">3 ↛ 6</span>' |
| + '<span class="annotate long">line 3 didn\'t jump to line 6 ' |
| + "because the condition on line 3 was always true</span>" |
| ), |
| ( |
| '<span class="annotate short">12 ↛ exit</span>' |
| + "<span class=\"annotate long\">line 12 didn't return from function 'two' " |
| + "because the condition on line 12 was always true</span>" |
| ), |
| ( |
| '<span class="annotate short">20 ↛ anywhere</span>' |
| + '<span class="annotate long">line 20 didn\'t jump anywhere: ' |
| + "it always raised an exception.</span>" |
| ), |
| ) |
| contains( |
| "out/b_branch/index.html", |
| '<a href="b_py.html">b.py</a>', |
| '<span class="pc_cov">70%</span>', |
| '<td class="right" data-ratio="16 23">70%</td>', |
| ) |
| |
| def test_bom(self) -> None: |
| self.make_file( |
| "bom.py", |
| bytes=b"""\ |
| \xef\xbb\xbf# A Python source file in utf-8, with BOM. |
| math = "3\xc3\x974 = 12, \xc3\xb72 = 6\xc2\xb10" |
| |
| assert len(math) == 18 |
| assert len(math.encode('utf-8')) == 21 |
| """.replace(b"\n", b"\r\n"), |
| ) |
| |
| # It's important that the source file really have a BOM, which can |
| # get lost, so check that it's really there, and that we have \r\n |
| # line endings. |
| with open("bom.py", "rb") as f: |
| data = f.read() |
| assert data[:3] == b"\xef\xbb\xbf" |
| assert data.count(b"\r\n") == 5 |
| |
| cov = coverage.Coverage() |
| bom = self.start_import_stop(cov, "bom") |
| cov.html_report(bom, directory="out/bom") |
| |
| compare_html(gold_path("html/bom"), "out/bom") |
| contains( |
| "out/bom/bom_py.html", |
| '<span class="str">"3×4 = 12, ÷2 = 6±0"</span>', |
| ) |
| |
| def test_isolatin1(self) -> None: |
| self.make_file( |
| "isolatin1.py", |
| bytes=b"""\ |
| # -*- coding: iso8859-1 -*- |
| # A Python source file in another encoding. |
| |
| math = "3\xd74 = 12, \xf72 = 6\xb10" |
| assert len(math) == 18 |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| isolatin1 = self.start_import_stop(cov, "isolatin1") |
| cov.html_report(isolatin1, directory="out/isolatin1") |
| |
| compare_html(gold_path("html/isolatin1"), "out/isolatin1") |
| contains( |
| "out/isolatin1/isolatin1_py.html", |
| '<span class="str">"3×4 = 12, ÷2 = 6±0"</span>', |
| ) |
| |
| def make_main_etc(self) -> None: |
| """Make main.py and m1-m3.py for other tests.""" |
| self.make_file( |
| "main.py", |
| """\ |
| import m1 |
| import m2 |
| import m3 |
| |
| a = 5 |
| b = 6 |
| |
| assert m1.m1a == 1 |
| assert m2.m2a == 1 |
| assert m3.m3a == 1 |
| """, |
| ) |
| self.make_file( |
| "m1.py", |
| """\ |
| m1a = 1 |
| m1b = 2 |
| """, |
| ) |
| self.make_file( |
| "m2.py", |
| """\ |
| m2a = 1 |
| m2b = 2 |
| """, |
| ) |
| self.make_file( |
| "m3.py", |
| """\ |
| m3a = 1 |
| m3b = 2 |
| """, |
| ) |
| |
| def test_omit_1(self) -> None: |
| self.make_main_etc() |
| cov = coverage.Coverage(include=["./*"]) |
| self.start_import_stop(cov, "main") |
| cov.html_report(directory="out/omit_1") |
| compare_html(gold_path("html/omit_1"), "out/omit_1") |
| |
| def test_omit_2(self) -> None: |
| self.make_main_etc() |
| cov = coverage.Coverage(include=["./*"]) |
| self.start_import_stop(cov, "main") |
| cov.html_report(directory="out/omit_2", omit=["m1.py"]) |
| compare_html(gold_path("html/omit_2"), "out/omit_2") |
| |
| def test_omit_3(self) -> None: |
| self.make_main_etc() |
| cov = coverage.Coverage(include=["./*"]) |
| self.start_import_stop(cov, "main") |
| cov.html_report(directory="out/omit_3", omit=["m1.py", "m2.py"]) |
| compare_html(gold_path("html/omit_3"), "out/omit_3") |
| |
| def test_omit_4(self) -> None: |
| self.make_main_etc() |
| self.make_file( |
| "omit4.ini", |
| """\ |
| [report] |
| omit = m2.py |
| """, |
| ) |
| |
| cov = coverage.Coverage(config_file="omit4.ini", include=["./*"]) |
| self.start_import_stop(cov, "main") |
| cov.html_report(directory="out/omit_4") |
| compare_html(gold_path("html/omit_4"), "out/omit_4") |
| |
| def test_omit_5(self) -> None: |
| self.make_main_etc() |
| self.make_file( |
| "omit5.ini", |
| """\ |
| [report] |
| omit = |
| fooey |
| gooey, m[23]*, kablooey |
| helloworld |
| |
| [html] |
| directory = out/omit_5 |
| """, |
| ) |
| |
| cov = coverage.Coverage(config_file="omit5.ini", include=["./*"]) |
| self.start_import_stop(cov, "main") |
| cov.html_report() |
| compare_html(gold_path("html/omit_5"), "out/omit_5") |
| |
| def test_other(self) -> None: |
| self.make_file( |
| "src/here.py", |
| """\ |
| import other |
| |
| if 1 < 2: |
| h = 3 |
| else: |
| h = 4 |
| """, |
| ) |
| self.make_file( |
| "othersrc/other.py", |
| """\ |
| # A file in another directory. We're checking that it ends up in the |
| # HTML report. |
| |
| print("This is the other src!") |
| """, |
| ) |
| |
| with change_dir("src"): |
| sys.path.insert(0, "../othersrc") |
| cov = coverage.Coverage(include=["./*", "../othersrc/*"]) |
| self.start_import_stop(cov, "here") |
| cov.html_report(directory="../out/other") |
| |
| # Different platforms will name the "other" file differently. Rename it |
| actual_file = list(glob.glob("out/other/*_other_py.html")) |
| assert len(actual_file) == 1 |
| os.rename(actual_file[0], "out/other/blah_blah_other_py.html") |
| |
| compare_html( |
| gold_path("html/other"), |
| "out/other", |
| extra_scrubs=[ |
| (r'href="z_[0-9a-z]{16}_other_', 'href="_TEST_TMPDIR_other_othersrc_'), |
| (r"TEST_TMPDIR\\othersrc\\other.py", "TEST_TMPDIR/othersrc/other.py"), |
| ], |
| ) |
| contains( |
| "out/other/index.html", |
| '<a href="here_py.html">here.py</a>', |
| 'other_py.html">', |
| "other.py</a>", |
| ) |
| |
| def test_partial(self) -> None: |
| self.make_file( |
| "partial.py", |
| """\ |
| # partial branches and excluded lines |
| a = 2 |
| |
| while "no peephole".upper(): # t4 |
| break |
| |
| while a: # pragma: no branch |
| break |
| |
| if 0: |
| never_happen() |
| |
| if 13: |
| a = 14 |
| |
| if a == 16: |
| raise ZeroDivisionError("17") |
| """, |
| ) |
| self.make_file( |
| "partial.ini", |
| """\ |
| [run] |
| branch = True |
| |
| [report] |
| exclude_lines = |
| raise ZeroDivisionError |
| """, |
| ) |
| |
| cov = coverage.Coverage(config_file="partial.ini") |
| partial = self.start_import_stop(cov, "partial") |
| |
| cov.html_report(partial, directory="out/partial") |
| compare_html(gold_path("html/partial"), "out/partial") |
| contains_rx( |
| "out/partial/partial_py.html", |
| r'<p class="par run show_par">.* id="t4"', |
| r'<p class="run">.* id="t7"', |
| # The "if 0" and "if 1" statements are marked as run. |
| r'<p class="run">.* id="t10"', |
| # The "raise ZeroDivisionError" is excluded by regex in the .ini. |
| r'<p class="exc show_exc">.* id="t17"', |
| ) |
| contains( |
| "out/partial/index.html", |
| '<a href="partial_py.html">partial.py</a>', |
| '<span class="pc_cov">92%</span>', |
| ) |
| |
| def test_styled(self) -> None: |
| self.make_file( |
| "a.py", |
| """\ |
| if 1 < 2: |
| # Needed a < to look at HTML entities. |
| a = 3 |
| else: |
| a = 4 |
| """, |
| ) |
| |
| self.make_file("myfile/myextra.css", "/* Doesn't matter what's here, it gets copied. */\n") |
| |
| cov = coverage.Coverage() |
| a = self.start_import_stop(cov, "a") |
| cov.html_report(a, directory="out/styled", extra_css="myfile/myextra.css") |
| self.assert_valid_hrefs("out/styled") |
| compare_html(gold_path("html/styled"), "out/styled") |
| unbust("out/styled") |
| compare(gold_path("html/styled"), "out/styled", file_pattern="*.css") |
| contains_rx( |
| "out/styled/a_py.html", |
| r'<link rel="stylesheet" href="myextra_cb_\w{8}.css" type="text/css">', |
| ( |
| r'<span class="key">if</span> <span class="num">1</span> ' |
| + r'<span class="op"><</span> <span class="num">2</span>' |
| ), |
| ( |
| r' <span class="nam">a</span> <span class="op">=</span> ' |
| + r'<span class="num">3</span>' |
| ), |
| r'<span class="pc_cov">67%</span>', |
| ) |
| contains_rx( |
| "out/styled/index.html", |
| r'<link rel="stylesheet" href="myextra_cb_\w{8}.css" type="text/css">', |
| r'<a href="a_py.html">a.py</a>', |
| r'<span class="pc_cov">67%</span>', |
| ) |
| |
| def test_multiline(self) -> None: |
| self.make_file( |
| "multiline.py", |
| """\ |
| x = 0 |
| if ( |
| x or x |
| ): |
| print( |
| # wut |
| "hello", |
| ) |
| else: |
| print( |
| "bye", |
| # wut |
| ) |
| |
| if 0: # pragma: no cover |
| print( |
| "never" |
| ) |
| """, |
| ) |
| cov = coverage.Coverage(branch=True) |
| multiline = self.start_import_stop(cov, "multiline") |
| cov.html_report(multiline, directory="out/multiline") |
| compare_html(gold_path("html/multiline"), "out/multiline") |
| contains( |
| "out/multiline/multiline_py.html", |
| '<p class="mis mis2 show_mis"><span class="n"><a id="t6" href="#t6">6</a></span>', |
| '<p class="exc exc2 show_exc"><span class="n"><a id="t17" href="#t17">17</a>', |
| ) |
| |
| def test_tabbed(self) -> None: |
| # The file contents would look like this with 8-space tabs: |
| # x = 1 |
| # if x: |
| # a = "tabbed" # aligned comments |
| # if x: # look nice |
| # b = "no spaces" # when they |
| # c = "done" # line up. |
| self.make_file( |
| "tabbed.py", |
| """\ |
| x = 1 |
| if x: |
| \ta = "Tabbed"\t\t\t\t# Aligned comments |
| \tif x:\t\t\t\t\t# look nice |
| \t\tb = "No spaces"\t\t\t# when they |
| \tc = "Done"\t\t\t\t# line up. |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| tabbed = self.start_import_stop(cov, "tabbed") |
| cov.html_report(tabbed, directory="out") |
| |
| # Editors like to change things, make sure our source file still has tabs. |
| contains("tabbed.py", "\tif x:\t\t\t\t\t# look nice") |
| |
| contains( |
| "out/tabbed_py.html", |
| '> <span class="key">if</span> ' |
| + '<span class="nam">x</span><span class="op">:</span>' |
| + " " |
| + '<span class="com"># look nice</span>', |
| ) |
| |
| doesnt_contain("out/tabbed_py.html", "\t") |
| |
| def test_bug_1828(self) -> None: |
| # https://github.com/nedbat/coveragepy/pull/1828 |
| self.make_file( |
| "backslashes.py", |
| """\ |
| a = ["aaa",\\ |
| "bbb \\ |
| ccc"] |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| backslashes = self.start_import_stop(cov, "backslashes") |
| cov.html_report(backslashes) |
| |
| contains( |
| "htmlcov/backslashes_py.html", |
| # line 2 is `"bbb \` |
| r'<a id="t2" href="#t2">2</a></span>' |
| + r'<span class="t"> <span class="str">"bbb \</span>', |
| # line 3 is `ccc"]` |
| r'<a id="t3" href="#t3">3</a></span>' |
| + r'<span class="t"><span class="str"> ccc"</span><span class="op">]</span>', |
| ) |
| |
| assert self.get_html_report_text_lines("backslashes.py") == [ |
| '1a = ["aaa",\\', |
| '2 "bbb \\', |
| '3 ccc"]', |
| ] |
| |
| @pytest.mark.parametrize( |
| "leader", |
| ["", "f", "r", "fr", "rf"], |
| ids=["string", "f-string", "raw_string", "f-raw_string", "raw_f-string"], |
| ) |
| def test_bug_1836(self, leader: str) -> None: |
| # https://github.com/nedbat/coveragepy/issues/1836 |
| self.make_file( |
| "py312_fstrings.py", |
| f"""\ |
| prog_name = 'bug.py' |
| err_msg = {leader}'''\\ |
| {{prog_name}}: ERROR: This is the first line of the error. |
| {{prog_name}}: ERROR: This is the second line of the error. |
| \\ |
| {{prog_name}}: ERROR: This is the third line of the error. |
| ''' |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| py312_fstrings = self.start_import_stop(cov, "py312_fstrings") |
| cov.html_report(py312_fstrings) |
| |
| assert self.get_html_report_text_lines("py312_fstrings.py") == [ |
| "1" + "prog_name = 'bug.py'", |
| "2" + f"err_msg = {leader}'''\\", |
| "3" + "{prog_name}: ERROR: This is the first line of the error.", |
| "4" + "{prog_name}: ERROR: This is the second line of the error.", |
| "5" + "\\", |
| "6" + "{prog_name}: ERROR: This is the third line of the error.", |
| "7" + "'''", |
| ] |
| |
| def test_bug_1980(self) -> None: |
| self.make_file( |
| "fstring_middle.py", |
| """\ |
| x = 1 |
| f'Look: {x} {{x}}!' |
| """, |
| ) |
| |
| cov = coverage.Coverage() |
| the_mod = self.start_import_stop(cov, "fstring_middle") |
| cov.html_report(the_mod) |
| |
| assert self.get_html_report_text_lines("fstring_middle.py") == [ |
| "1" + "x = 1", |
| "2" + "f'Look: {x} {{x}}!'", |
| ] |
| |
| def test_unicode(self) -> None: |
| surrogate = "\U000e0100" |
| |
| self.make_file( |
| "unicode.py", |
| """\ |
| # -*- coding: utf-8 -*- |
| # A Python source file with exotic characters. |
| |
| upside_down = "ʎd˙ǝbɐɹǝʌoɔ" |
| surrogate = "db40,dd00: x@" |
| """.replace("@", surrogate), |
| ) |
| |
| cov = coverage.Coverage() |
| unimod = self.start_import_stop(cov, "unicode") |
| cov.html_report(unimod, directory="out/unicode") |
| |
| compare_html(gold_path("html/unicode"), "out/unicode") |
| contains( |
| "out/unicode/unicode_py.html", |
| '<span class="str">"ʎd˙ǝbɐɹǝʌoɔ"</span>', |
| ) |
| |
| contains_any( |
| "out/unicode/unicode_py.html", |
| '<span class="str">"db40,dd00: x��"</span>', |
| '<span class="str">"db40,dd00: x󠄀"</span>', |
| ) |
| |
| def test_accented_dot_py(self) -> None: |
| # Make a file with a non-ascii character in the filename. |
| self.make_file("h\xe2t.py", "print('accented')") |
| self.make_data_file(lines={abs_file("h\xe2t.py"): [1]}) |
| cov = coverage.Coverage() |
| cov.load() |
| cov.html_report() |
| self.assert_exists("htmlcov/h\xe2t_py.html") |
| with open("htmlcov/index.html", encoding="utf-8") as indexf: |
| index = indexf.read() |
| assert '<a href="hât_py.html">hât.py</a>' in index |
| |
| def test_accented_directory(self) -> None: |
| # Make a file with a non-ascii character in the directory name. |
| self.make_file("\xe2/accented.py", "print('accented')") |
| self.make_data_file(lines={abs_file("\xe2/accented.py"): [1]}) |
| |
| # The HTML report uses ascii-encoded HTML entities. |
| cov = coverage.Coverage() |
| cov.load() |
| cov.html_report() |
| self.assert_exists("htmlcov/z_5786906b6f0ffeb4_accented_py.html") |
| with open("htmlcov/index.html", encoding="utf-8") as indexf: |
| index = indexf.read() |
| expected = '<a href="z_5786906b6f0ffeb4_accented_py.html">â%saccented.py</a>' |
| assert expected % os.sep in index |
| |
| |
| @pytest.mark.skipif(not testenv.DYN_CONTEXTS, reason="No dynamic contexts with this core.") |
| class HtmlWithContextsTest(HtmlTestHelpers, CoverageTest): |
| """Tests of the HTML reports with shown contexts.""" |
| |
| EMPTY = coverage.html.HtmlDataGeneration.EMPTY |
| |
| def html_data_from_cov(self, cov: Coverage, morf: TMorf) -> coverage.html.FileData: |
| """Get HTML report data from a `Coverage` object for a morf.""" |
| with self.assert_warnings(cov, []): |
| datagen = coverage.html.HtmlDataGeneration(cov) |
| fr, analysis = next(iter(get_analysis_to_report(cov, [morf]))) |
| file_data = datagen.data_for_file(fr, analysis) |
| return file_data |
| |
| SOURCE = """\ |
| def helper(lineno): |
| x = 2 |
| |
| def test_one(): |
| a = 5 |
| helper(6) |
| |
| def test_two(): |
| a = 9 |
| b = 10 |
| if a > 11: |
| b = 12 |
| assert a == (13-4) |
| assert b == (14-4) |
| helper( |
| 16 |
| ) |
| |
| test_one() |
| x = 20 |
| helper(21) |
| test_two() |
| """ |
| |
| OUTER_LINES = [1, 4, 8, 19, 20, 21, 2, 22] |
| TEST_ONE_LINES = [5, 6, 2] |
| TEST_TWO_LINES = [9, 10, 11, 13, 14, 15, 2] |
| |
| def test_dynamic_contexts(self) -> None: |
| self.make_file("two_tests.py", self.SOURCE) |
| cov = coverage.Coverage(source=["."]) |
| cov.set_option("run:dynamic_context", "test_function") |
| cov.set_option("html:show_contexts", True) |
| mod = self.start_import_stop(cov, "two_tests") |
| d = self.html_data_from_cov(cov, mod) |
| context_labels = [self.EMPTY, "two_tests.test_one", "two_tests.test_two"] |
| expected_lines = [self.OUTER_LINES, self.TEST_ONE_LINES, self.TEST_TWO_LINES] |
| for label, expected in zip(context_labels, expected_lines): |
| actual = [ |
| ld.number |
| for ld in d.lines |
| if label == ld.contexts_label or label in (ld.contexts or ()) |
| ] |
| assert sorted(expected) == sorted(actual) |
| |
| cov.html_report(mod, directory="out/contexts") |
| compare_html(gold_path("html/contexts"), "out/contexts") |
| |
| def test_filtered_dynamic_contexts(self) -> None: |
| self.make_file("two_tests.py", self.SOURCE) |
| cov = coverage.Coverage(source=["."]) |
| cov.set_option("run:dynamic_context", "test_function") |
| cov.set_option("html:show_contexts", True) |
| cov.set_option("report:contexts", ["test_one"]) |
| mod = self.start_import_stop(cov, "two_tests") |
| d = self.html_data_from_cov(cov, mod) |
| |
| context_labels = [self.EMPTY, "two_tests.test_one", "two_tests.test_two"] |
| expected_lines: list[list[TLineNo]] = [[], self.TEST_ONE_LINES, []] |
| for label, expected in zip(context_labels, expected_lines): |
| actual = [ld.number for ld in d.lines if label in (ld.contexts or ())] |
| assert sorted(expected) == sorted(actual) |
| |
| def test_no_contexts_warns_no_contexts(self) -> None: |
| # If no contexts were collected, then show_contexts emits a warning. |
| self.make_file("two_tests.py", self.SOURCE) |
| cov = coverage.Coverage(source=["."]) |
| cov.set_option("html:show_contexts", True) |
| self.start_import_stop(cov, "two_tests") |
| with self.assert_warnings(cov, ["No contexts were measured"]): |
| cov.html_report() |
| |
| def test_dynamic_contexts_relative_files(self) -> None: |
| self.make_file("two_tests.py", self.SOURCE) |
| self.make_file("config", "[run]\nrelative_files = True") |
| cov = coverage.Coverage(source=["."], config_file="config") |
| cov.set_option("run:dynamic_context", "test_function") |
| cov.set_option("html:show_contexts", True) |
| mod = self.start_import_stop(cov, "two_tests") |
| d = self.html_data_from_cov(cov, mod) |
| context_labels = [self.EMPTY, "two_tests.test_one", "two_tests.test_two"] |
| expected_lines = [self.OUTER_LINES, self.TEST_ONE_LINES, self.TEST_TWO_LINES] |
| for label, expected in zip(context_labels, expected_lines): |
| actual = [ |
| ld.number |
| for ld in d.lines |
| if label == ld.contexts_label or label in (ld.contexts or ()) |
| ] |
| assert sorted(expected) == sorted(actual) |
| |
| |
| class HtmlHelpersTest(HtmlTestHelpers, CoverageTest): |
| """Tests of the helpers in HtmlTestHelpers.""" |
| |
| def test_bad_link(self) -> None: |
| # Does assert_valid_hrefs detect links to non-existent files? |
| self.make_file("htmlcov/index.html", "<a href='nothing.html'>Nothing</a>") |
| msg = "These files link to 'nothing.html', which doesn't exist: htmlcov.index.html" |
| with pytest.raises(AssertionError, match=msg): |
| self.assert_valid_hrefs() |
| |
| def test_bad_anchor(self) -> None: |
| # Does assert_valid_hrefs detect fragments that go nowhere? |
| self.make_file("htmlcov/index.html", "<a href='#nothing'>Nothing</a>") |
| msg = "Fragment '#nothing' in htmlcov.index.html has no anchor" |
| with pytest.raises(AssertionError, match=msg): |
| self.assert_valid_hrefs() |
| |
| |
| @pytest.mark.parametrize( |
| "n, key", |
| [ |
| (0, "a"), |
| (1, "b"), |
| (999999999, "e9S_p"), |
| ], |
| ) |
| def test_encode_int(n: int, key: str) -> None: |
| assert coverage.html.encode_int(n) == key |