Coverage for tests / unit / tools / tsc / test_tsc_plugin.py: 100%

192 statements  

« prev     ^ index     » next       coverage.py v7.13.0, created at 2026-04-03 18:53 +0000

1"""Unit tests for TscPlugin temp tsconfig functionality.""" 

2 

3from __future__ import annotations 

4 

5import json 

6from pathlib import Path 

7 

8import pytest 

9from assertpy import assert_that 

10 

11from lintro.tools.definitions.tsc import TscPlugin 

12 

13# ============================================================================= 

14# Tests for TscPlugin._find_tsconfig method 

15# ============================================================================= 

16 

17 

18def test_find_tsconfig_finds_tsconfig_in_cwd( 

19 tsc_plugin: TscPlugin, 

20 tmp_path: Path, 

21) -> None: 

22 """Verify _find_tsconfig finds tsconfig.json in working directory. 

23 

24 Args: 

25 tsc_plugin: Plugin instance fixture. 

26 tmp_path: Pytest temporary directory. 

27 """ 

28 tsconfig = tmp_path / "tsconfig.json" 

29 tsconfig.write_text("{}") 

30 

31 result = tsc_plugin._find_tsconfig(tmp_path) 

32 

33 assert_that(result).is_equal_to(tsconfig) 

34 

35 

36def test_find_tsconfig_returns_none_when_no_tsconfig( 

37 tsc_plugin: TscPlugin, 

38 tmp_path: Path, 

39) -> None: 

40 """Verify _find_tsconfig returns None when no tsconfig.json exists. 

41 

42 Args: 

43 tsc_plugin: Plugin instance fixture. 

44 tmp_path: Pytest temporary directory. 

45 """ 

46 result = tsc_plugin._find_tsconfig(tmp_path) 

47 

48 assert_that(result).is_none() 

49 

50 

51def test_find_tsconfig_uses_explicit_project_option( 

52 tsc_plugin: TscPlugin, 

53 tmp_path: Path, 

54) -> None: 

55 """Verify _find_tsconfig uses explicit project option over auto-discovery. 

56 

57 Args: 

58 tsc_plugin: Plugin instance fixture. 

59 tmp_path: Pytest temporary directory. 

60 """ 

61 # Create both default and custom tsconfig 

62 default_tsconfig = tmp_path / "tsconfig.json" 

63 default_tsconfig.write_text("{}") 

64 

65 custom_tsconfig = tmp_path / "tsconfig.build.json" 

66 custom_tsconfig.write_text("{}") 

67 

68 tsc_plugin.set_options(project="tsconfig.build.json") 

69 result = tsc_plugin._find_tsconfig(tmp_path) 

70 

71 assert_that(result).is_equal_to(custom_tsconfig) 

72 

73 

74# ============================================================================= 

75# Tests for TscPlugin._create_temp_tsconfig method 

76# ============================================================================= 

77 

78 

79def test_create_temp_tsconfig_creates_file_with_extends( 

80 tsc_plugin: TscPlugin, 

81 tmp_path: Path, 

82) -> None: 

83 """Verify temp tsconfig extends the base config. 

84 

85 Args: 

86 tsc_plugin: Plugin instance fixture. 

87 tmp_path: Pytest temporary directory. 

88 """ 

89 base_tsconfig = tmp_path / "tsconfig.json" 

90 base_tsconfig.write_text('{"compilerOptions": {"strict": true}}') 

91 

92 temp_path = tsc_plugin._create_temp_tsconfig( 

93 base_tsconfig=base_tsconfig, 

94 files=["src/file.ts"], 

95 cwd=tmp_path, 

96 ) 

97 

98 try: 

99 assert_that(temp_path.exists()).is_true() 

100 

101 content = json.loads(temp_path.read_text()) 

102 assert_that(content["extends"]).is_equal_to(str(base_tsconfig.resolve())) 

103 finally: 

104 temp_path.unlink(missing_ok=True) 

105 

106 

107def test_create_temp_tsconfig_includes_specified_files( 

108 tsc_plugin: TscPlugin, 

109 tmp_path: Path, 

110) -> None: 

111 """Verify temp tsconfig includes only specified files. 

112 

113 Args: 

114 tsc_plugin: Plugin instance fixture. 

115 tmp_path: Pytest temporary directory. 

116 """ 

117 base_tsconfig = tmp_path / "tsconfig.json" 

118 base_tsconfig.write_text("{}") 

119 

120 files = ["src/a.ts", "src/b.ts", "lib/c.ts"] 

121 temp_path = tsc_plugin._create_temp_tsconfig( 

122 base_tsconfig=base_tsconfig, 

123 files=files, 

124 cwd=tmp_path, 

125 ) 

126 

127 try: 

128 content = json.loads(temp_path.read_text()) 

129 expected_files = [str((tmp_path / f).resolve()) for f in files] 

130 assert_that(content["include"]).is_equal_to(expected_files) 

131 assert_that(content["exclude"]).is_equal_to([]) 

132 finally: 

133 temp_path.unlink(missing_ok=True) 

134 

135 

136def test_create_temp_tsconfig_sets_no_emit( 

137 tsc_plugin: TscPlugin, 

138 tmp_path: Path, 

139) -> None: 

140 """Verify temp tsconfig sets noEmit compiler option. 

141 

142 Args: 

143 tsc_plugin: Plugin instance fixture. 

144 tmp_path: Pytest temporary directory. 

145 """ 

146 base_tsconfig = tmp_path / "tsconfig.json" 

147 base_tsconfig.write_text("{}") 

148 

149 temp_path = tsc_plugin._create_temp_tsconfig( 

150 base_tsconfig=base_tsconfig, 

151 files=["file.ts"], 

152 cwd=tmp_path, 

153 ) 

154 

155 try: 

156 content = json.loads(temp_path.read_text()) 

157 assert_that(content["compilerOptions"]["noEmit"]).is_true() 

158 finally: 

159 temp_path.unlink(missing_ok=True) 

160 

161 

162def test_create_temp_tsconfig_file_created_next_to_base( 

163 tsc_plugin: TscPlugin, 

164 tmp_path: Path, 

165) -> None: 

166 """Verify temp tsconfig is created next to the base tsconfig. 

167 

168 Placing the temp file in the project tree allows TypeScript to resolve 

169 types entries by walking up from the tsconfig location to node_modules. 

170 

171 Args: 

172 tsc_plugin: Plugin instance fixture. 

173 tmp_path: Pytest temporary directory. 

174 """ 

175 base_tsconfig = tmp_path / "tsconfig.json" 

176 base_tsconfig.write_text("{}") 

177 

178 temp_path = tsc_plugin._create_temp_tsconfig( 

179 base_tsconfig=base_tsconfig, 

180 files=["file.ts"], 

181 cwd=tmp_path, 

182 ) 

183 

184 try: 

185 assert_that(temp_path.parent).is_equal_to(tmp_path) 

186 assert_that(temp_path.name).starts_with(".lintro-tsc-") 

187 assert_that(temp_path.name).ends_with(".json") 

188 finally: 

189 temp_path.unlink(missing_ok=True) 

190 

191 

192def test_create_temp_tsconfig_falls_back_to_system_temp_with_typeroots( 

193 tsc_plugin: TscPlugin, 

194 tmp_path: Path, 

195) -> None: 

196 """Verify fallback to system temp dir injects typeRoots. 

197 

198 When the project directory is read-only (e.g. Docker volume mount), 

199 the temp file falls back to the system temp dir and injects explicit 

200 typeRoots so TypeScript can still resolve type packages. 

201 

202 Args: 

203 tsc_plugin: Plugin instance fixture. 

204 tmp_path: Pytest temporary directory. 

205 """ 

206 import tempfile 

207 from typing import Any 

208 from unittest.mock import patch 

209 

210 base_tsconfig = tmp_path / "tsconfig.json" 

211 base_tsconfig.write_text("{}") 

212 

213 original_mkstemp = tempfile.mkstemp 

214 call_count = 0 

215 

216 def mock_mkstemp(**kwargs: Any) -> tuple[int, str]: 

217 nonlocal call_count 

218 call_count += 1 

219 if call_count == 1: 

220 # First call (project dir) fails 

221 raise OSError("Read-only filesystem") 

222 # Second call (system temp dir) succeeds 

223 return original_mkstemp(**kwargs) 

224 

225 with patch("tempfile.mkstemp", side_effect=mock_mkstemp): 

226 temp_path = tsc_plugin._create_temp_tsconfig( 

227 base_tsconfig=base_tsconfig, 

228 files=["file.ts"], 

229 cwd=tmp_path, 

230 ) 

231 

232 try: 

233 system_temp = Path(tempfile.gettempdir()) 

234 assert_that(temp_path.parent).is_equal_to(system_temp) 

235 

236 content = json.loads(temp_path.read_text()) 

237 expected_type_roots = [str(tmp_path / "node_modules" / "@types")] 

238 assert_that(content["compilerOptions"]["typeRoots"]).is_equal_to( 

239 expected_type_roots, 

240 ) 

241 finally: 

242 temp_path.unlink(missing_ok=True) 

243 

244 

245def test_create_temp_tsconfig_fallback_preserves_custom_typeroots( 

246 tsc_plugin: TscPlugin, 

247 tmp_path: Path, 

248) -> None: 

249 """Verify fallback merges existing typeRoots from base tsconfig. 

250 

251 When the base tsconfig has custom typeRoots, the fallback should 

252 resolve them relative to the base tsconfig directory and include 

253 the default node_modules/@types path. 

254 

255 Args: 

256 tsc_plugin: Plugin instance fixture. 

257 tmp_path: Pytest temporary directory. 

258 """ 

259 import tempfile 

260 from typing import Any 

261 from unittest.mock import patch 

262 

263 base_tsconfig = tmp_path / "tsconfig.json" 

264 base_tsconfig.write_text( 

265 json.dumps( 

266 { 

267 "compilerOptions": { 

268 "typeRoots": ["./custom-types", "./other-types"], 

269 }, 

270 }, 

271 ), 

272 ) 

273 

274 original_mkstemp = tempfile.mkstemp 

275 call_count = 0 

276 

277 def mock_mkstemp(**kwargs: Any) -> tuple[int, str]: 

278 nonlocal call_count 

279 call_count += 1 

280 if call_count == 1: 

281 raise OSError("Read-only filesystem") 

282 return original_mkstemp(**kwargs) 

283 

284 with patch("tempfile.mkstemp", side_effect=mock_mkstemp): 

285 temp_path = tsc_plugin._create_temp_tsconfig( 

286 base_tsconfig=base_tsconfig, 

287 files=["file.ts"], 

288 cwd=tmp_path, 

289 ) 

290 

291 try: 

292 content = json.loads(temp_path.read_text()) 

293 type_roots = content["compilerOptions"]["typeRoots"] 

294 # Custom roots resolved to absolute paths 

295 assert_that(type_roots).contains( 

296 str((tmp_path / "custom-types").resolve()), 

297 ) 

298 assert_that(type_roots).contains( 

299 str((tmp_path / "other-types").resolve()), 

300 ) 

301 # Default root also included 

302 assert_that(type_roots).contains( 

303 str(tmp_path / "node_modules" / "@types"), 

304 ) 

305 finally: 

306 temp_path.unlink(missing_ok=True) 

307 

308 

309def test_create_temp_tsconfig_fallback_honours_empty_typeroots( 

310 tsc_plugin: TscPlugin, 

311 tmp_path: Path, 

312) -> None: 

313 """Verify fallback preserves explicit empty typeRoots. 

314 

315 When the base tsconfig explicitly sets ``typeRoots: []`` to disable 

316 automatic global type discovery, the fallback path must honour that 

317 intent and NOT inject the default ``node_modules/@types`` root. 

318 

319 Args: 

320 tsc_plugin: Plugin instance fixture. 

321 tmp_path: Pytest temporary directory. 

322 """ 

323 import tempfile 

324 from typing import Any 

325 from unittest.mock import patch 

326 

327 base_tsconfig = tmp_path / "tsconfig.json" 

328 base_tsconfig.write_text( 

329 json.dumps( 

330 { 

331 "compilerOptions": { 

332 "typeRoots": [], 

333 }, 

334 }, 

335 ), 

336 ) 

337 

338 original_mkstemp = tempfile.mkstemp 

339 call_count = 0 

340 

341 def mock_mkstemp(**kwargs: Any) -> tuple[int, str]: 

342 nonlocal call_count 

343 call_count += 1 

344 if call_count == 1: 

345 raise OSError("Read-only filesystem") 

346 return original_mkstemp(**kwargs) 

347 

348 with patch("tempfile.mkstemp", side_effect=mock_mkstemp): 

349 temp_path = tsc_plugin._create_temp_tsconfig( 

350 base_tsconfig=base_tsconfig, 

351 files=["file.ts"], 

352 cwd=tmp_path, 

353 ) 

354 

355 try: 

356 content = json.loads(temp_path.read_text()) 

357 type_roots = content["compilerOptions"]["typeRoots"] 

358 assert_that(type_roots).is_empty() 

359 finally: 

360 temp_path.unlink(missing_ok=True) 

361 

362 

363# ============================================================================= 

364# Tests for TscPlugin.set_options validation 

365# ============================================================================= 

366 

367 

368def test_set_options_validates_use_project_files_type( 

369 tsc_plugin: TscPlugin, 

370) -> None: 

371 """Verify set_options rejects non-boolean use_project_files. 

372 

373 Args: 

374 tsc_plugin: Plugin instance fixture. 

375 """ 

376 with pytest.raises(ValueError, match="use_project_files must be a boolean"): 

377 tsc_plugin.set_options( 

378 use_project_files="true", # type: ignore[arg-type] # Intentional wrong type 

379 ) 

380 

381 

382def test_set_options_accepts_valid_use_project_files( 

383 tsc_plugin: TscPlugin, 

384) -> None: 

385 """Verify set_options accepts boolean use_project_files. 

386 

387 Args: 

388 tsc_plugin: Plugin instance fixture. 

389 """ 

390 tsc_plugin.set_options(use_project_files=True) 

391 assert_that(tsc_plugin.options.get("use_project_files")).is_true() 

392 

393 tsc_plugin.set_options(use_project_files=False) 

394 assert_that(tsc_plugin.options.get("use_project_files")).is_false() 

395 

396 

397# ============================================================================= 

398# Tests for TscPlugin default option values 

399# ============================================================================= 

400 

401 

402def test_default_options_use_project_files_defaults_to_false( 

403 tsc_plugin: TscPlugin, 

404) -> None: 

405 """Verify use_project_files defaults to False for lintro-style targeting. 

406 

407 Args: 

408 tsc_plugin: Plugin instance fixture. 

409 """ 

410 default_options = tsc_plugin.definition.default_options 

411 assert_that(default_options.get("use_project_files")).is_false() 

412 

413 

414# ============================================================================= 

415# Tests for TscPlugin._detect_framework_project method 

416# ============================================================================= 

417 

418 

419@pytest.mark.parametrize( 

420 ("config_file", "expected_framework", "expected_tool"), 

421 [ 

422 pytest.param( 

423 "astro.config.mjs", 

424 "Astro", 

425 "astro-check", 

426 id="astro_mjs", 

427 ), 

428 pytest.param( 

429 "astro.config.ts", 

430 "Astro", 

431 "astro-check", 

432 id="astro_ts", 

433 ), 

434 pytest.param( 

435 "astro.config.js", 

436 "Astro", 

437 "astro-check", 

438 id="astro_js", 

439 ), 

440 pytest.param( 

441 "svelte.config.js", 

442 "Svelte", 

443 "svelte-check", 

444 id="svelte_js", 

445 ), 

446 pytest.param( 

447 "svelte.config.ts", 

448 "Svelte", 

449 "svelte-check", 

450 id="svelte_ts", 

451 ), 

452 pytest.param( 

453 "vue.config.js", 

454 "Vue", 

455 "vue-tsc", 

456 id="vue_js", 

457 ), 

458 pytest.param( 

459 "vue.config.ts", 

460 "Vue", 

461 "vue-tsc", 

462 id="vue_ts", 

463 ), 

464 ], 

465) 

466def test_detect_framework_project( 

467 tsc_plugin: TscPlugin, 

468 tmp_path: Path, 

469 config_file: str, 

470 expected_framework: str, 

471 expected_tool: str, 

472) -> None: 

473 """Verify framework detection identifies projects by config file. 

474 

475 Args: 

476 tsc_plugin: Plugin instance fixture. 

477 tmp_path: Pytest temporary directory. 

478 config_file: Name of the framework config file to create. 

479 expected_framework: Expected framework name. 

480 expected_tool: Expected recommended tool name. 

481 """ 

482 (tmp_path / config_file).write_text("export default {};") 

483 

484 result = tsc_plugin._detect_framework_project(tmp_path) 

485 

486 assert_that(result).is_not_none() 

487 assert result is not None 

488 framework_name, tool_name = result 

489 assert_that(framework_name).is_equal_to(expected_framework) 

490 assert_that(tool_name).is_equal_to(expected_tool) 

491 

492 

493def test_detect_framework_project_returns_none_for_plain_ts( 

494 tsc_plugin: TscPlugin, 

495 tmp_path: Path, 

496) -> None: 

497 """Verify framework detection returns None for plain TypeScript projects. 

498 

499 Args: 

500 tsc_plugin: Plugin instance fixture. 

501 tmp_path: Pytest temporary directory. 

502 """ 

503 (tmp_path / "tsconfig.json").write_text("{}") 

504 

505 result = tsc_plugin._detect_framework_project(tmp_path) 

506 

507 assert_that(result).is_none() 

508 

509 

510# ============================================================================= 

511# Tests for JSONC tsconfig parsing (issue #570) 

512# ============================================================================= 

513 

514 

515def test_create_temp_tsconfig_preserves_type_roots_from_jsonc_base( 

516 tsc_plugin: TscPlugin, 

517 tmp_path: Path, 

518) -> None: 

519 """Verify typeRoots are preserved when base tsconfig uses JSONC features. 

520 

521 This is the primary scenario from issue #570: a tsconfig.json with 

522 comments and trailing commas should still have its typeRoots read 

523 and propagated into the temporary tsconfig. 

524 

525 Args: 

526 tsc_plugin: Plugin instance fixture. 

527 tmp_path: Pytest temporary directory. 

528 """ 

529 base_tsconfig = tmp_path / "tsconfig.json" 

530 base_tsconfig.write_text( 

531 """{ 

532 // TypeScript config with JSONC features 

533 "compilerOptions": { 

534 "strict": true, 

535 /* Custom type roots for this project */ 

536 "typeRoots": [ 

537 "./custom-types", 

538 "./node_modules/@types", 

539 ], 

540 }, 

541}""", 

542 ) 

543 

544 temp_path = tsc_plugin._create_temp_tsconfig( 

545 base_tsconfig=base_tsconfig, 

546 files=["src/file.ts"], 

547 cwd=tmp_path, 

548 ) 

549 

550 try: 

551 content = json.loads(temp_path.read_text()) 

552 # typeRoots should be resolved to absolute paths 

553 type_roots = content["compilerOptions"]["typeRoots"] 

554 assert_that(type_roots).is_length(2) 

555 assert_that(type_roots[0]).ends_with("custom-types") 

556 assert_that(type_roots[1]).ends_with("node_modules/@types") 

557 finally: 

558 temp_path.unlink(missing_ok=True) 

559 

560 

561def test_create_temp_tsconfig_no_type_roots_when_base_has_none( 

562 tsc_plugin: TscPlugin, 

563 tmp_path: Path, 

564) -> None: 

565 """Verify no typeRoots are added when the base config has none. 

566 

567 Args: 

568 tsc_plugin: Plugin instance fixture. 

569 tmp_path: Pytest temporary directory. 

570 """ 

571 base_tsconfig = tmp_path / "tsconfig.json" 

572 base_tsconfig.write_text('{"compilerOptions": {"strict": true}}') 

573 

574 temp_path = tsc_plugin._create_temp_tsconfig( 

575 base_tsconfig=base_tsconfig, 

576 files=["src/file.ts"], 

577 cwd=tmp_path, 

578 ) 

579 

580 try: 

581 content = json.loads(temp_path.read_text()) 

582 assert_that(content["compilerOptions"]).does_not_contain_key("typeRoots") 

583 finally: 

584 temp_path.unlink(missing_ok=True) 

585 

586 

587def test_create_temp_tsconfig_ignores_non_list_type_roots( 

588 tsc_plugin: TscPlugin, 

589 tmp_path: Path, 

590) -> None: 

591 """Verify malformed typeRoots (non-list) are safely ignored. 

592 

593 Args: 

594 tsc_plugin: Plugin instance fixture. 

595 tmp_path: Pytest temporary directory. 

596 """ 

597 base_tsconfig = tmp_path / "tsconfig.json" 

598 base_tsconfig.write_text( 

599 '{"compilerOptions": {"typeRoots": "not-a-list"}}', 

600 ) 

601 

602 temp_path = tsc_plugin._create_temp_tsconfig( 

603 base_tsconfig=base_tsconfig, 

604 files=["src/file.ts"], 

605 cwd=tmp_path, 

606 ) 

607 

608 try: 

609 content = json.loads(temp_path.read_text()) 

610 assert_that(content["compilerOptions"]).does_not_contain_key("typeRoots") 

611 finally: 

612 temp_path.unlink(missing_ok=True) 

613 

614 

615def test_create_temp_tsconfig_filters_non_string_type_roots( 

616 tsc_plugin: TscPlugin, 

617 tmp_path: Path, 

618) -> None: 

619 """Verify non-string entries in typeRoots are filtered out. 

620 

621 Args: 

622 tsc_plugin: Plugin instance fixture. 

623 tmp_path: Pytest temporary directory. 

624 """ 

625 base_tsconfig = tmp_path / "tsconfig.json" 

626 base_tsconfig.write_text( 

627 '{"compilerOptions": {"typeRoots": ["./valid-types", 123, null, true]}}', 

628 ) 

629 

630 temp_path = tsc_plugin._create_temp_tsconfig( 

631 base_tsconfig=base_tsconfig, 

632 files=["src/file.ts"], 

633 cwd=tmp_path, 

634 ) 

635 

636 try: 

637 content = json.loads(temp_path.read_text()) 

638 type_roots = content["compilerOptions"]["typeRoots"] 

639 assert_that(type_roots).is_length(1) 

640 assert_that(type_roots[0]).ends_with("valid-types") 

641 finally: 

642 temp_path.unlink(missing_ok=True) 

643 

644 

645def test_create_temp_tsconfig_ignores_non_dict_compiler_options( 

646 tsc_plugin: TscPlugin, 

647 tmp_path: Path, 

648) -> None: 

649 """Verify malformed compilerOptions (non-dict) are safely ignored. 

650 

651 Args: 

652 tsc_plugin: Plugin instance fixture. 

653 tmp_path: Pytest temporary directory. 

654 """ 

655 base_tsconfig = tmp_path / "tsconfig.json" 

656 base_tsconfig.write_text('{"compilerOptions": "not-a-dict"}') 

657 

658 temp_path = tsc_plugin._create_temp_tsconfig( 

659 base_tsconfig=base_tsconfig, 

660 files=["src/file.ts"], 

661 cwd=tmp_path, 

662 ) 

663 

664 try: 

665 content = json.loads(temp_path.read_text()) 

666 assert_that(content["compilerOptions"]).does_not_contain_key("typeRoots") 

667 finally: 

668 temp_path.unlink(missing_ok=True)