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

1# SPDX-License-Identifier: MIT 

2# Copyright (c) 2023-2024 Kilian Lackhove 

3from __future__ import annotations 

4 

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 

14 

15import coverage 

16import pytest 

17 

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) 

29 

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} 

55 

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 

102 

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} 

130 

131 

132@pytest.fixture() 

133def examples_dir(resources_dir): 

134 return resources_dir / "examples" 

135 

136 

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 

143 

144 

145@pytest.mark.parametrize("cover_always", [(True), (False)]) 

146def test_end2end(dummy_project_dir, monkeypatch, cover_always: bool): 

147 monkeypatch.chdir(dummy_project_dir) 

148 

149 coverage_file_path = dummy_project_dir.joinpath(".coverage") 

150 assert not coverage_file_path.is_file() 

151 

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") 

156 

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 

173 

174 assert dummy_project_dir.joinpath(".coverage").is_file() 

175 assert len(list(dummy_project_dir.glob(f".coverage.sh.{gethostname()}.*"))) == 1 

176 

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 

184 

185 proc = subprocess.run( 

186 [sys.executable, "-m", "coverage", "html"], cwd=dummy_project_dir, check=False 

187 ) 

188 assert proc.returncode == 0 

189 

190 proc = subprocess.run( 

191 [sys.executable, "-m", "coverage", "json"], cwd=dummy_project_dir, check=False 

192 ) 

193 assert proc.returncode == 0 

194 

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 ) 

206 

207 

208class TestShellFileReporter: 

209 @pytest.fixture() 

210 def reporter(self, syntax_example_path): 

211 return ShellFileReporter(str(syntax_example_path)) 

212 

213 def test_source_should_be_cached(self, syntax_example_path, reporter): 

214 reference = Path(reporter.path).read_text() 

215 

216 assert reporter.source() == reference 

217 syntax_example_path.unlink() 

218 assert reporter.source() == reference 

219 

220 def test_lines_should_match_reference(self, reporter): 

221 assert reporter.lines() == SYNTAX_EXAMPLE_EXECUTABLE_LINES 

222 

223 

224def test_filename_suffix_should_match_pattern(): 

225 suffix = filename_suffix() 

226 assert re.match(r".+?\.\d+\.[a-zA-Z]+", suffix) 

227 

228 

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() 

235 

236 assert parser.line_data == COVERAGE_LINE_COVERAGE 

237 

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 ) 

244 

245 

246class TestCoverageParserThread: 

247 class WriterThread(threading.Thread): 

248 def __init__(self, fifo_path: Path): 

249 super().__init__() 

250 self._fifo_path = fifo_path 

251 

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) 

258 

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) 

264 

265 print("writer done") 

266 

267 class CovLineParserSpy(CovLineParser): 

268 def __init__(self): 

269 super().__init__() 

270 self.recorded_lines = [] 

271 

272 def _report_lines(self, lines: list[str]) -> None: 

273 self.recorded_lines.extend(lines) 

274 super()._report_lines(lines) 

275 

276 class CovWriterSpy: 

277 def __init__(self): 

278 self.line_data: LineData = defaultdict(set) 

279 

280 def write(self, line_data: LineData) -> None: 

281 self.line_data.update(line_data) 

282 

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() 

292 

293 writer_thread = self.WriterThread(fifo_path=parser_thread.fifo_path) 

294 writer_thread.start() 

295 writer_thread.join() 

296 

297 parser_thread.stop() 

298 parser_thread.join() 

299 

300 assert parser.recorded_lines == COVERAGE_LINES 

301 

302 for filename, lines in COVERAGE_LINE_COVERAGE.items(): 

303 assert writer.line_data[filename] == lines 

304 

305 

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) 

311 

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() 

319 

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) 

323 

324 

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) 

335 

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) 

346 

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() 

355 

356 if cov is not None: # pragma: no cover 

357 cov.stop() 

358 

359 assert proc.stderr.read() == "" 

360 assert proc.stdout.read() == SYNTAX_EXAMPLE_STDOUT 

361 

362 

363class TestMonitorThread: 

364 class MainThreadStub: 

365 def join(self): 

366 return 

367 

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") 

370 

371 parser_thread = CoverageParserThread( 

372 coverage_writer=CoverageWriter(data_file_path), 

373 ) 

374 parser_thread.start() 

375 

376 monitor_thread = MonitorThread( 

377 parser_thread=parser_thread, main_thread=self.MainThreadStub() 

378 ) 

379 monitor_thread.start() 

380 

381 

382class TestShellPlugin: 

383 def test_init_cover_always(self): 

384 plugin = ShellPlugin({"cover_always": True}) 

385 del plugin 

386 

387 def test_file_tracer_should_return_None(self): 

388 plugin = ShellPlugin({}) 

389 assert plugin.file_tracer("foobar") is None 

390 

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") 

396 

397 def test_find_executable_files_should_find_shell_files(self, examples_dir): 

398 plugin = ShellPlugin({}) 

399 

400 executable_files = plugin.find_executable_files(str(examples_dir)) 

401 

402 assert [Path(f) for f in sorted(executable_files)] == [ 

403 examples_dir / "shell-file.weird.suffix", 

404 ]