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
« prev ^ index » next coverage.py v7.13.0, created at 2026-04-03 18:53 +0000
1"""Unit tests for TscPlugin temp tsconfig functionality."""
3from __future__ import annotations
5import json
6from pathlib import Path
8import pytest
9from assertpy import assert_that
11from lintro.tools.definitions.tsc import TscPlugin
13# =============================================================================
14# Tests for TscPlugin._find_tsconfig method
15# =============================================================================
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.
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("{}")
31 result = tsc_plugin._find_tsconfig(tmp_path)
33 assert_that(result).is_equal_to(tsconfig)
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.
42 Args:
43 tsc_plugin: Plugin instance fixture.
44 tmp_path: Pytest temporary directory.
45 """
46 result = tsc_plugin._find_tsconfig(tmp_path)
48 assert_that(result).is_none()
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.
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("{}")
65 custom_tsconfig = tmp_path / "tsconfig.build.json"
66 custom_tsconfig.write_text("{}")
68 tsc_plugin.set_options(project="tsconfig.build.json")
69 result = tsc_plugin._find_tsconfig(tmp_path)
71 assert_that(result).is_equal_to(custom_tsconfig)
74# =============================================================================
75# Tests for TscPlugin._create_temp_tsconfig method
76# =============================================================================
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.
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}}')
92 temp_path = tsc_plugin._create_temp_tsconfig(
93 base_tsconfig=base_tsconfig,
94 files=["src/file.ts"],
95 cwd=tmp_path,
96 )
98 try:
99 assert_that(temp_path.exists()).is_true()
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)
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.
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("{}")
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 )
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)
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.
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("{}")
149 temp_path = tsc_plugin._create_temp_tsconfig(
150 base_tsconfig=base_tsconfig,
151 files=["file.ts"],
152 cwd=tmp_path,
153 )
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)
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.
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.
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("{}")
178 temp_path = tsc_plugin._create_temp_tsconfig(
179 base_tsconfig=base_tsconfig,
180 files=["file.ts"],
181 cwd=tmp_path,
182 )
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)
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.
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.
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
210 base_tsconfig = tmp_path / "tsconfig.json"
211 base_tsconfig.write_text("{}")
213 original_mkstemp = tempfile.mkstemp
214 call_count = 0
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)
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 )
232 try:
233 system_temp = Path(tempfile.gettempdir())
234 assert_that(temp_path.parent).is_equal_to(system_temp)
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)
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.
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.
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
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 )
274 original_mkstemp = tempfile.mkstemp
275 call_count = 0
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)
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 )
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)
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.
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.
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
327 base_tsconfig = tmp_path / "tsconfig.json"
328 base_tsconfig.write_text(
329 json.dumps(
330 {
331 "compilerOptions": {
332 "typeRoots": [],
333 },
334 },
335 ),
336 )
338 original_mkstemp = tempfile.mkstemp
339 call_count = 0
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)
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 )
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)
363# =============================================================================
364# Tests for TscPlugin.set_options validation
365# =============================================================================
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.
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 )
382def test_set_options_accepts_valid_use_project_files(
383 tsc_plugin: TscPlugin,
384) -> None:
385 """Verify set_options accepts boolean use_project_files.
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()
393 tsc_plugin.set_options(use_project_files=False)
394 assert_that(tsc_plugin.options.get("use_project_files")).is_false()
397# =============================================================================
398# Tests for TscPlugin default option values
399# =============================================================================
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.
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()
414# =============================================================================
415# Tests for TscPlugin._detect_framework_project method
416# =============================================================================
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.
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 {};")
484 result = tsc_plugin._detect_framework_project(tmp_path)
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)
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.
499 Args:
500 tsc_plugin: Plugin instance fixture.
501 tmp_path: Pytest temporary directory.
502 """
503 (tmp_path / "tsconfig.json").write_text("{}")
505 result = tsc_plugin._detect_framework_project(tmp_path)
507 assert_that(result).is_none()
510# =============================================================================
511# Tests for JSONC tsconfig parsing (issue #570)
512# =============================================================================
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.
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.
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 )
544 temp_path = tsc_plugin._create_temp_tsconfig(
545 base_tsconfig=base_tsconfig,
546 files=["src/file.ts"],
547 cwd=tmp_path,
548 )
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)
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.
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}}')
574 temp_path = tsc_plugin._create_temp_tsconfig(
575 base_tsconfig=base_tsconfig,
576 files=["src/file.ts"],
577 cwd=tmp_path,
578 )
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)
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.
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 )
602 temp_path = tsc_plugin._create_temp_tsconfig(
603 base_tsconfig=base_tsconfig,
604 files=["src/file.ts"],
605 cwd=tmp_path,
606 )
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)
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.
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 )
630 temp_path = tsc_plugin._create_temp_tsconfig(
631 base_tsconfig=base_tsconfig,
632 files=["src/file.ts"],
633 cwd=tmp_path,
634 )
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)
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.
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"}')
658 temp_path = tsc_plugin._create_temp_tsconfig(
659 base_tsconfig=base_tsconfig,
660 files=["src/file.ts"],
661 cwd=tmp_path,
662 )
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)