Coverage for tests/test_plugin.py: 100%
176 statements
« prev ^ index » next coverage.py v7.4.0, created at 2024-02-03 22:34 +0100
« prev ^ index » next coverage.py v7.4.0, created at 2024-02-03 22:34 +0100
1# SPDX-License-Identifier: MIT
2# Copyright (c) 2023-2024 Kilian Lackhove
3from __future__ import annotations
5import json
6import re
7import subprocess
8import sys
9import threading
10from collections import defaultdict
11from pathlib import Path
12from socket import gethostname
13from time import sleep
15import coverage
16import pytest
18from coverage_sh import ShellPlugin
19from coverage_sh.plugin import (
20 CoverageParserThread,
21 CoverageWriter,
22 CovLineParser,
23 LineData,
24 MonitorThread,
25 PatchedPopen,
26 ShellFileReporter,
27 filename_suffix,
28)
30SYNTAX_EXAMPLE_EXECUTABLE_LINES = {
31 12,
32 15,
33 18,
34 19,
35 21,
36 25,
37 26,
38 31,
39 34,
40 37,
41 38,
42 41,
43 42,
44 45,
45 46,
46 47,
47 48,
48 51,
49 52,
50 54,
51 57,
52 60,
53 63,
54}
56SYNTAX_EXAMPLE_STDOUT = (
57 "Hello, World!\n"
58 "Variable is set to 'Hello, World!'\n"
59 "Iteration 1\n"
60 "Iteration 2\n"
61 "Iteration 3\n"
62 "Iteration 4\n"
63 "Iteration 5\n"
64 "Hello from a function!\n"
65 "Current OS is: Linux\n"
66 "5 + 3 = 8\n"
67 "This is a sample file.\n"
68 "You selected a banana.\n"
69)
70SYNTAX_EXAMPLE_COVERED_LINES = [
71 12,
72 15,
73 18,
74 19,
75 25,
76 26,
77 31,
78 34,
79 37,
80 38,
81 41,
82 42,
83 45,
84 46,
85 47,
86 48,
87 51,
88 52,
89 57,
90]
91SYNTAX_EXAMPLE_MISSING_LINES = [
92 21,
93 54,
94 60,
95 63,
96]
97COVERAGE_LINE_CHUNKS = (
98 b"""\
99CCOV:::/home/dummy_user/dummy_dir_a:::1:::a normal line
100COV:::/home/dummy_user/dummy_dir_b:::10:::a line
101with a line fragment
103COV:::/home/dummy_user/dummy_dir_a:::2:::a line with ::: triple columns
104COV:::/home/dummy_user/dummy_dir_a:::3:::a line """,
105 b"that spans multiple chunks\n",
106 b"C",
107 b"O",
108 b"V",
109 b":",
110 b":",
111 b":",
112 b"/",
113 b"ho",
114 b"m",
115 b"e",
116 b"/dummy_user/dummy_dir_a:::4:::a chunked line",
117)
118COVERAGE_LINES = [
119 "CCOV:::/home/dummy_user/dummy_dir_a:::1:::a normal line",
120 "COV:::/home/dummy_user/dummy_dir_b:::10:::a line",
121 "with a line fragment",
122 "COV:::/home/dummy_user/dummy_dir_a:::2:::a line with ::: triple columns",
123 "COV:::/home/dummy_user/dummy_dir_a:::3:::a line that spans multiple chunks",
124 "COV:::/home/dummy_user/dummy_dir_a:::4:::a chunked line",
125]
126COVERAGE_LINE_COVERAGE = {
127 "/home/dummy_user/dummy_dir_a": {1, 2, 3, 4},
128 "/home/dummy_user/dummy_dir_b": {10},
129}
132@pytest.fixture()
133def examples_dir(resources_dir):
134 return resources_dir / "examples"
137@pytest.fixture()
138def syntax_example_path(resources_dir, tmp_path):
139 original_path = resources_dir / "syntax_example.sh"
140 working_copy_path = tmp_path / "syntax_example.sh"
141 working_copy_path.write_bytes(original_path.read_bytes())
142 return working_copy_path
145@pytest.mark.parametrize("cover_always", [(True), (False)])
146def test_end2end(dummy_project_dir, monkeypatch, cover_always: bool):
147 monkeypatch.chdir(dummy_project_dir)
149 coverage_file_path = dummy_project_dir.joinpath(".coverage")
150 assert not coverage_file_path.is_file()
152 if cover_always:
153 pyproject_file = dummy_project_dir.joinpath("pyproject.toml")
154 with pyproject_file.open("a") as fd:
155 fd.write("\n[tool.coverage.coverage_sh]\ncover_always = true")
157 try:
158 proc = subprocess.run(
159 [sys.executable, "-m", "coverage", "run", "main.py"],
160 cwd=dummy_project_dir,
161 capture_output=True,
162 text=True,
163 check=False,
164 timeout=2,
165 )
166 except subprocess.TimeoutExpired as e: # pragma: no cover
167 assert e.stdout == "failed stdout" # noqa: PT017
168 assert e.stderr == "failed stderr" # noqa: PT017
169 assert False
170 assert proc.stderr == ""
171 assert proc.stdout == SYNTAX_EXAMPLE_STDOUT
172 assert proc.returncode == 0
174 assert dummy_project_dir.joinpath(".coverage").is_file()
175 assert len(list(dummy_project_dir.glob(f".coverage.sh.{gethostname()}.*"))) == 1
177 proc = subprocess.run(
178 [sys.executable, "-m", "coverage", "combine"],
179 cwd=dummy_project_dir,
180 check=False,
181 )
182 print("recombined")
183 assert proc.returncode == 0
185 proc = subprocess.run(
186 [sys.executable, "-m", "coverage", "html"], cwd=dummy_project_dir, check=False
187 )
188 assert proc.returncode == 0
190 proc = subprocess.run(
191 [sys.executable, "-m", "coverage", "json"], cwd=dummy_project_dir, check=False
192 )
193 assert proc.returncode == 0
195 coverage_json = json.loads(dummy_project_dir.joinpath("coverage.json").read_text())
196 assert coverage_json["files"]["test.sh"]["executed_lines"] == [8, 9]
197 assert coverage_json["files"]["syntax_example.sh"]["excluded_lines"] == []
198 assert (
199 coverage_json["files"]["syntax_example.sh"]["executed_lines"]
200 == SYNTAX_EXAMPLE_COVERED_LINES
201 )
202 assert (
203 coverage_json["files"]["syntax_example.sh"]["missing_lines"]
204 == SYNTAX_EXAMPLE_MISSING_LINES
205 )
208class TestShellFileReporter:
209 @pytest.fixture()
210 def reporter(self, syntax_example_path):
211 return ShellFileReporter(str(syntax_example_path))
213 def test_source_should_be_cached(self, syntax_example_path, reporter):
214 reference = Path(reporter.path).read_text()
216 assert reporter.source() == reference
217 syntax_example_path.unlink()
218 assert reporter.source() == reference
220 def test_lines_should_match_reference(self, reporter):
221 assert reporter.lines() == SYNTAX_EXAMPLE_EXECUTABLE_LINES
224def test_filename_suffix_should_match_pattern():
225 suffix = filename_suffix()
226 assert re.match(r".+?\.\d+\.[a-zA-Z]+", suffix)
229class TestCovLineParser:
230 def test_parse_result_matches_reference(self):
231 parser = CovLineParser()
232 for chunk in COVERAGE_LINE_CHUNKS:
233 parser.parse(chunk)
234 parser.flush()
236 assert parser.line_data == COVERAGE_LINE_COVERAGE
238 def test_parse_should_raise_for_incomplete_line(self):
239 parser = CovLineParser()
240 with pytest.raises(ValueError, match="could not parse line"):
241 parser.parse(
242 b"COV:::/home/dummy_user/dummy_dir_b:::a line with missing line number\n"
243 )
246class TestCoverageParserThread:
247 class WriterThread(threading.Thread):
248 def __init__(self, fifo_path: Path):
249 super().__init__()
250 self._fifo_path = fifo_path
252 def run(self):
253 print("writer start")
254 with self._fifo_path.open("wb") as fd:
255 for c in COVERAGE_LINE_CHUNKS[0:2]:
256 fd.write(c)
257 sleep(0.1)
259 sleep(0.1)
260 with self._fifo_path.open("wb") as fd:
261 for c in COVERAGE_LINE_CHUNKS[2:]:
262 fd.write(c)
263 sleep(0.1)
265 print("writer done")
267 class CovLineParserSpy(CovLineParser):
268 def __init__(self):
269 super().__init__()
270 self.recorded_lines = []
272 def _report_lines(self, lines: list[str]) -> None:
273 self.recorded_lines.extend(lines)
274 super()._report_lines(lines)
276 class CovWriterSpy:
277 def __init__(self):
278 self.line_data: LineData = defaultdict(set)
280 def write(self, line_data: LineData) -> None:
281 self.line_data.update(line_data)
283 def test_lines_should_match_reference(self):
284 parser = self.CovLineParserSpy()
285 writer = self.CovWriterSpy()
286 parser_thread = CoverageParserThread(
287 coverage_writer=writer,
288 name="CoverageParserThread",
289 parser=parser,
290 )
291 parser_thread.start()
293 writer_thread = self.WriterThread(fifo_path=parser_thread.fifo_path)
294 writer_thread.start()
295 writer_thread.join()
297 parser_thread.stop()
298 parser_thread.join()
300 assert parser.recorded_lines == COVERAGE_LINES
302 for filename, lines in COVERAGE_LINE_COVERAGE.items():
303 assert writer.line_data[filename] == lines
306class TestCoverageWriter:
307 def test_write_should_produce_readable_file(self, dummy_project_dir):
308 data_file_path = dummy_project_dir.joinpath("coverage-data.db")
309 writer = CoverageWriter(data_file_path)
310 writer.write(COVERAGE_LINE_COVERAGE)
312 concrete_data_file_path = next(
313 data_file_path.parent.glob(data_file_path.stem + "*")
314 )
315 cov_db = coverage.CoverageData(
316 basename=str(concrete_data_file_path), suffix=False
317 )
318 cov_db.read()
320 assert cov_db.measured_files() == set(COVERAGE_LINE_COVERAGE.keys())
321 for filename, lines in COVERAGE_LINE_COVERAGE.items():
322 assert cov_db.lines(filename) == sorted(lines)
325class TestPatchedPopen:
326 @pytest.mark.parametrize("is_recording", [(True), (False)])
327 def test_call_should_execute_example(
328 self,
329 is_recording,
330 resources_dir,
331 dummy_project_dir,
332 monkeypatch,
333 ):
334 monkeypatch.chdir(dummy_project_dir)
336 cov = None
337 if is_recording:
338 cov = coverage.Coverage.current()
339 if cov is None: # pragma: no cover
340 # start coverage in case pytest was not executed with the coverage module. Otherwise, we just recod to
341 # the parent coverage
342 cov = coverage.Coverage()
343 cov.start()
344 else:
345 monkeypatch.setattr(coverage.Coverage, "current", lambda: None)
347 test_sh_path = resources_dir / "testproject" / "test.sh"
348 proc = PatchedPopen(
349 ["/bin/bash", test_sh_path],
350 stdout=subprocess.PIPE,
351 stderr=subprocess.PIPE,
352 encoding="utf8",
353 )
354 proc.wait()
356 if cov is not None: # pragma: no cover
357 cov.stop()
359 assert proc.stderr.read() == ""
360 assert proc.stdout.read() == SYNTAX_EXAMPLE_STDOUT
363class TestMonitorThread:
364 class MainThreadStub:
365 def join(self):
366 return
368 def test_run_should_wait_for_main_thread_join(self, dummy_project_dir):
369 data_file_path = dummy_project_dir.joinpath("coverage-data.db")
371 parser_thread = CoverageParserThread(
372 coverage_writer=CoverageWriter(data_file_path),
373 )
374 parser_thread.start()
376 monitor_thread = MonitorThread(
377 parser_thread=parser_thread, main_thread=self.MainThreadStub()
378 )
379 monitor_thread.start()
382class TestShellPlugin:
383 def test_init_cover_always(self):
384 plugin = ShellPlugin({"cover_always": True})
385 del plugin
387 def test_file_tracer_should_return_None(self):
388 plugin = ShellPlugin({})
389 assert plugin.file_tracer("foobar") is None
391 def test_file_reporter_should_return_instance(self):
392 plugin = ShellPlugin({})
393 reporter = plugin.file_reporter("foobar")
394 assert isinstance(reporter, ShellFileReporter)
395 assert reporter.path == Path("foobar")
397 def test_find_executable_files_should_find_shell_files(self, examples_dir):
398 plugin = ShellPlugin({})
400 executable_files = plugin.find_executable_files(str(examples_dir))
402 assert [Path(f) for f in sorted(executable_files)] == [
403 examples_dir / "shell-file.weird.suffix",
404 ]