import argparse
import bisect
import fnmatch
import hashlib
import json
import os
import sys
import time
from array import array
from collections import defaultdict
from collections.abc import Callable, Iterable, Sequence
from pathlib import Path
from typing import TypedDict
import pytest
from _pytest.terminal import TerminalReporter
from yarl import URL
def find_line_starts(text: str) -> Sequence[int]:
result: list[int] = []
try:
line_start = 0
while True:
result.append(line_start)
line_start = text.index('\n', line_start) + 1
except ValueError:
if not text.endswith('\n'):
result.append(len(text) + 1)
return result
SOURCEMAP_CHARS = b'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/,;'
SOURCEMAP_TABLE = bytes(SOURCEMAP_CHARS.find(i) & 0xff for i in range(256))
def parse_mappings(bundle: str, mappings: str) -> tuple[Sequence[int], Sequence[tuple[int, int, int]]]:
assert mappings.endswith((';', ','))
line_starts = find_line_starts(bundle)
bundle_offsets, source_offsets = array('i'), list[tuple[int, int, int]]()
bundle_line = 0
token, token_shift = [0, 0, 0, 0, 0], 0
bits, bits_shift = 0, 0
for v in mappings.encode().translate(SOURCEMAP_TABLE):
if not v:
assert not bits_shift
token_shift += 1
elif v < 32:
bits += v << bits_shift
token[token_shift] += (-(bits >> 1)) if bits & 1 else (bits >> 1)
token_shift += 1
bits_shift = 0
bits = 0
elif v < 64:
bits += (v & 31) << bits_shift
bits_shift += 5
else:
if token_shift:
bundle_offsets.append(token[0])
source_offsets.append((token[1], token[2], token[3]))
token_shift = 0
if v == 65:
bundle_line += 1
token[0] = line_starts[bundle_line]
bundle_offsets.append(len(bundle))
source_offsets.append((token[1], token[2], token[3]))
return bundle_offsets, source_offsets
class SourceMap(TypedDict):
sources: str
sourcesContent: str
mappings: str
class BrowserCoverageRange(TypedDict):
startOffset: int
endOffset: int
count: int
class BrowserCoverageFunction(TypedDict):
functionName: str
ranges: Sequence[BrowserCoverageRange]
class BrowserCoverageUrl(TypedDict):
url: str
functions: Sequence[BrowserCoverageFunction]
class BrowserCoverageResult(TypedDict):
result: Sequence[BrowserCoverageUrl]
class FileDescription(TypedDict):
filename: str
sha256: str
class CoverageFileEntry(TypedDict):
bundle: FileDescription
map: FileDescription
functions: Sequence[BrowserCoverageFunction]
class CoverageFile(TypedDict):
coverage: Sequence[CoverageFileEntry]
class File:
def __init__(self, description: FileDescription) -> None:
content = Path(description['filename']).read_bytes()
self.sha256 = hashlib.sha256(content).hexdigest()
self.content = content.decode()
def verify(self, description: FileDescription) -> None:
if self.sha256 != description['sha256']:
raise RuntimeError(f'{description['filename']} has incorrect checksum')
class SourceCoverageData:
def __init__(self, path: str, origin: str, content: str) -> None:
self.path = path
self.content = content
self.coverage = defaultdict[tuple[str, int, int, int, int], int](lambda: 0)
self.origin = origin
self.line_starts = find_line_starts(content)
def add_coverage(
self, function: str, start_line: int, start_col: int, end_line: int, end_col: int, count: int
) -> None:
self.coverage[function, start_line, start_col, end_line, end_col] += count
def print_block(self, start_line: int, start_col: int, end_line: int, end_col: int, name: str) -> None:
start = self.line_starts[start_line] + start_col
end = self.line_starts[end_line] + end_col
print(f'\033[1m{self.path}\033[0m:\033[1;36m{start_line + 1}:{start_col + 1}-{end_line + 1}:{end_col + 1} \033[34m{name}\033[0m')
print(' ' * 6, ' ' * start_col + '▼ from here')
for line in range(start_line, end_line + 1):
line_start = self.line_starts[line]
line_end = self.line_starts[line + 1] - 1
before = self.content[line_start:start]
highlight = self.content[max(line_start, start):min(end, line_end)]
after = self.content[end:line_end]
print(f'\033[1;36m{line + 1:6}\033[0m', f'{before}\033[1;31m{highlight}\033[0m{after}')
print(' ' * 6, ' ' * (end_col - 1) + '▲ to here')
print()
class BundleCoverageData:
def __init__(self, filename: str, entry: CoverageFileEntry) -> None:
self.coverage = defaultdict[tuple[str, int, int], int](lambda: 0)
self.filename = filename
self.bundle = File(entry['bundle'])
self.sourcemap = File(entry['map'])
self.functions = list[BrowserCoverageFunction]()
try:
map_data: SourceMap = json.loads(self.sourcemap.content)
except json.JSONDecodeError as exc:
raise RuntimeError(f'{entry['map']['filename']}: {exc}') from exc
self.src_names = map_data['sources']
self.src_contents = map_data['sourcesContent']
self.map_points, self.map_data = parse_mappings(self.bundle.content, map_data['mappings'])
def add_coverage_file_entry(self, entry: CoverageFileEntry) -> None:
assert entry['bundle']['filename'] == self.filename
self.bundle.verify(entry['bundle'])
self.sourcemap.verify(entry['map'])
for f in entry['functions']:
for r in f['ranges']:
self.coverage[f['functionName'], r['startOffset'], r['endOffset']] += r['count']
def map(self, start: int, end: int) -> tuple[str, str, int, int, int, int]:
start_index = bisect.bisect_right(self.map_points, start - 1)
start_file, start_line, start_col = self.map_data[start_index]
end_index = bisect.bisect_left(self.map_points, end - 1)
end_file, end_line, end_col = self.map_data[min(end_index, len(self.map_data) - 1)]
if start_file != end_file:
raise ValueError
return self.src_names[start_file], self.src_contents[start_file], start_line, start_col, end_line, end_col + 1
class Progress:
def report_step(self, step: str) -> None:
raise NotImplementedError
def report_detail(self, detail: str) -> None:
raise NotImplementedError
def done(self) -> None:
raise NotImplementedError
class FancyProgress(Progress):
def report_step(self, step: str) -> None:
self.step = step
def report_detail(self, detail: str) -> None:
THEME = '⠁⠁⠉⠙⠚⠒⠂⠂⠒⠲⠴⠤⠄⠄⠤⠠⠠⠤⠦⠖⠒⠐⠐⠒⠓⠋⠉⠈⠈'
frame = int(time.monotonic() * 10) % len(THEME)
sys.stdout.write(f'\r\033[2K\r{THEME[frame]} {self.step} : {detail} ')
def done(self) -> None:
sys.stdout.write('\r\033[2K\r')
class BoringProgress(Progress):
step = None
def report_step(self, step: str) -> None:
self.done()
print(f'{step}:', file=sys.stderr)
self.step = step
def report_detail(self, detail: str) -> None:
print(f' {detail}', file=sys.stderr)
def done(self) -> None:
if self.step is not None:
print('\n')
def report_coverage(paths: Sequence[Path], matches: Sequence[str]) -> None:
progress = FancyProgress() if os.isatty(2) else BoringProgress()
progress.report_step('Parsing coverage data from test runs')
bundles = dict[str, BundleCoverageData]()
for path in paths:
progress.report_detail(path.name)
try:
file: CoverageFile = json.loads(path.read_text())
except (json.JSONDecodeError, OSError) as exc:
sys.exit(f'{path}: {exc}')
try:
for entry in file['coverage']:
filename = entry['bundle']['filename']
if filename not in bundles:
bundles[filename] = BundleCoverageData(filename, entry)
bundles[filename].add_coverage_file_entry(entry)
except (OSError, RuntimeError) as exc:
sys.exit(f'{path}: {exc}')
progress.report_step('Collecting coverage data for bundles')
sources = dict[str, SourceCoverageData]()
for bundle in bundles.values():
progress.report_detail(bundle.filename)
for (function, start, end), count in bundle.coverage.items():
try:
source_path, source_content, start_line, start_col, end_line, end_col = bundle.map(start, end)
except ValueError:
continue
source_path = source_path.lstrip('./')
if source_path.startswith('node_modules/'):
continue
if source_path not in sources:
sources[source_path] = SourceCoverageData(source_path, bundle.filename, source_content)
else:
assert sources[source_path].content == source_content
sources[source_path].add_coverage(function, start_line, start_col, end_line, end_col, count)
progress.done()
any_output = False
n_files = 0
if matches:
for source in sources.values():
if not any(fnmatch.fnmatch(source.path, pat) for pat in matches):
continue
for (function, start_line, start_col, end_line, end_col), count in source.coverage.items():
if count == 0:
source.print_block(start_line, start_col, end_line, end_col, function)
any_output = True
n_files += 1
else:
print(f'{"Path":50} {"%":>7} (bytes covered) ~ uncovered lines')
for filename in sorted(sources):
source = sources[filename]
total, covered = 0, 0
uncovered_ranges = set[tuple[int, int]]()
for (_function, start_line, start_col, end_line, end_col), count in source.coverage.items():
start_offset = source.line_starts[start_line] + start_col
end_offset = source.line_starts[end_line] + end_col
block_size = end_offset - start_offset
total += block_size
if count:
covered += block_size
else:
uncovered_ranges.add((start_line + 1, end_line + 1))
if uncovered_ranges:
ranges = ', '.join(f'{s}-{e}' if s != e else f'{s}' for s, e in sorted(uncovered_ranges))
print(f'{source.path:50} {100 * covered / total:>6.1f}% ({covered} / {total}) ~ {ranges}')
any_output = True
n_files += 1
if not n_files:
print('No files considered for coverage')
elif not any_output:
print(f'Coverage is complete for {n_files} file{'s' if n_files != 1 else ''}')
CoverageReport = Callable[[BrowserCoverageResult], None]
@pytest.fixture
def coverage_report(
pytestconfig: pytest.Config,
request: pytest.FixtureRequest,
tmp_path_factory: pytest.TempPathFactory,
) -> Iterable[CoverageReport]:
def with_hash(filename: str) -> FileDescription:
path = pytestconfig.rootpath / filename
return {
'filename': filename,
'sha256': hashlib.sha256(path.read_bytes()).hexdigest()
}
output: list[CoverageFileEntry] = []
def append_to_report(coverage_data: BrowserCoverageResult) -> None:
for file in coverage_data['result']:
filename = URL(file['url']).path[1:]
if not filename.endswith('.js'):
continue
try:
output.append({
'bundle': with_hash(filename),
'map': with_hash(f'{filename}.map'),
'functions': file['functions']
})
except FileNotFoundError:
continue
yield append_to_report
if output:
basetempdir = tmp_path_factory.getbasetemp()
if os.getenv('PYTEST_XDIST_WORKER'):
basetempdir = basetempdir.parent
assert isinstance(request.node, pytest.Item)
item = request.node
name = item.name.replace('/', ':') + '.js-coverage.json'
coverage_file = basetempdir / 'js-coverage' / name
coverage_file.parent.mkdir(exist_ok=True)
coverage_file.write_text(json.dumps({
'coverage': output
}, indent=2))
def pytest_terminal_summary(config: pytest.Config, terminalreporter: TerminalReporter) -> None:
show_cov: bool = config.getoption('jscov')
matches: Sequence[str] = config.getoption('jscovfiles')
if not show_cov and not matches:
return
basetempdir = config._tmp_path_factory.getbasetemp()
assert isinstance(basetempdir, Path)
coverage_dir = basetempdir / 'js-coverage'
coverage_files = tuple(coverage_dir.glob('*.js-coverage.json'))
terminalreporter.ensure_newline()
terminalreporter.section('JavaScript coverage summary', sep='=', purple=True, bold=True)
report_coverage(coverage_files, matches)
def pytest_addoption(parser: pytest.Parser) -> None:
parser.addoption('--js-cov', dest='jscov', action='store_true',
help="Show JS coverage summary")
parser.addoption('--js-cov-files', dest='jscovfiles', action='append',
help="Show detailed coverage for given JS file glob")
def main() -> None:
parser = argparse.ArgumentParser(description='Coverage reporting tool')
parser.add_argument('-d', '--debug', action='store_true', help="Print debugging output")
parser.add_argument('-m', '--match', action='append', help="Filename glob pattern for source files")
parser.add_argument('files', type=Path, nargs='+')
args = parser.parse_args()
report_coverage(args.files, args.match)
if __name__ == '__main__':
main()