""" Error / edge-case tests for the Angular language server. The Angular LS routes by file extension across three child processes: * ``ngserver`` for ``.html`` definition / references / hover * companion ``typescript-language-server`` (with @angular/language-service loaded as a tsserver plugin) for ``.ts`` operations * companion ``vscode-html-language-server`` for ``.html`` documentSymbol The behaviour locked in below is **observed** behaviour on Linux with the versions pinned in ``angular_language_server.py`` (Angular LS 21.2.10, typescript-language-server 5.1.3, TypeScript 5.9.3). It is platform-aware where upstream LSPs are known to differ (Windows TS server tends to swallow malformed positions instead of raising). """ import os import sys import time import pytest from solidlsp import SolidLanguageServer from solidlsp.ls_config import Language from solidlsp.ls_exceptions import SolidLSPException from test.conftest import _create_ls pytestmark = pytest.mark.angular IS_WINDOWS = sys.platform == "win32" TS_FILE = os.path.join("src", "app", "app.component.ts") SERVICE_FILE = os.path.join("src", "app", "greeting.service.ts") TEMPLATE_FILE = os.path.join("src", "app", "app.component.html") class TestAngularInvalidPositionsOnTs: """Negative / out-of-bounds line and column on .ts files (TS-companion route).""" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_negative_line_number_containing_symbol(self, language_server: SolidLanguageServer) -> None: """``request_containing_symbol`` short-circuits before reaching the LS: a negative line returns None. """ result = language_server.request_containing_symbol(TS_FILE, -1, 0) assert result is None, f"Expected None for negative line, got: {result!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_negative_character_number_containing_symbol(self, language_server: SolidLanguageServer) -> None: result = language_server.request_containing_symbol(TS_FILE, 5, -1) assert result is None, f"Expected None for negative character, got: {result!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_line_number_beyond_file_length(self, language_server: SolidLanguageServer) -> None: """The wrapper code raises ``IndexError`` before reaching the LS.""" with pytest.raises(IndexError) as exc_info: language_server.request_containing_symbol(TS_FILE, 99999, 0) assert "list index out of range" in str(exc_info.value), f"Expected 'list index out of range' error, got: {exc_info.value}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_character_beyond_line_length_returns_enclosing_class(self, language_server: SolidLanguageServer) -> None: """Character far beyond end-of-line is clamped by the LSP. Line 5 of app.component.ts is inside ``export class AppComponent { ... }``, so the TS companion (with the Angular plugin) returns the AppComponent class as the containing symbol — not None — which is the documented LSP behaviour. """ result = language_server.request_containing_symbol(TS_FILE, 5, 99999) assert isinstance(result, dict), f"Expected dict (containing class), got: {result!r}" assert result.get("name") == "AppComponent", f"Expected containing symbol 'AppComponent', got: {result.get('name')!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_references_at_negative_line_returns_empty(self, language_server: SolidLanguageServer) -> None: """The Angular LS's TS companion returns ``[]`` for negative-line ``request_references`` — it does **not** raise. (Plain Vue tests observe a ``Bad line number`` raise on Linux/macOS; the Angular plugin layered on tsserver suppresses this.) """ result = language_server.request_references(TS_FILE, -1, 0) assert result == [], f"Expected [], got: {result!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_definition_at_negative_position_returns_empty(self, language_server: SolidLanguageServer) -> None: """Same as references: TS companion returns ``[]``, does not raise.""" result = language_server.request_definition(TS_FILE, -1, 0) assert result == [], f"Expected [], got: {result!r}" @pytest.mark.skipif( IS_WINDOWS, reason="Windows ngserver swallows malformed positions instead of raising — same divergence as the TS companion (see test_referencing_symbols_at_invalid_position_raises).", ) class TestAngularInvalidPositionsOnTemplate: """Negative / out-of-bounds positions on .html files (ngserver route).""" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_negative_line_definition_raises(self, language_server: SolidLanguageServer) -> None: """Ngserver does **not** swallow malformed positions: it surfaces a ``Debug Failure. False expression.`` error from its underlying compiler. Lock that behaviour to catch any future change. """ with pytest.raises(SolidLSPException) as exc_info: language_server.request_definition(TEMPLATE_FILE, -1, 0) assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), ( f"Unexpected exception message: {exc_info.value}" ) @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_character_beyond_line_definition_raises(self, language_server: SolidLanguageServer) -> None: """Same path as negative line — ngserver raises Debug Failure.""" with pytest.raises(SolidLSPException) as exc_info: language_server.request_definition(TEMPLATE_FILE, 0, 99999) assert "Debug Failure" in str(exc_info.value) or "Bad line number" in str(exc_info.value), ( f"Unexpected exception message: {exc_info.value}" ) class TestAngularNonExistentFiles: """Requests against files that don't exist must raise FileNotFoundError consistently.""" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_nonexistent_ts_file_raises(self, language_server: SolidLanguageServer) -> None: nonexistent = os.path.join("src", "app", "does-not-exist.component.ts") with pytest.raises(FileNotFoundError): language_server.request_references(nonexistent, 0, 0) with pytest.raises(FileNotFoundError): language_server.request_definition(nonexistent, 0, 0) with pytest.raises(FileNotFoundError): language_server.request_document_symbols(nonexistent) @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_nonexistent_html_file_raises(self, language_server: SolidLanguageServer) -> None: nonexistent = os.path.join("src", "app", "does-not-exist.component.html") with pytest.raises(FileNotFoundError): language_server.request_definition(nonexistent, 0, 0) with pytest.raises(FileNotFoundError): language_server.request_document_symbols(nonexistent) class TestAngularUndefinedSymbols: """Symbols that have no callers / definitions / referencing positions.""" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_definition_at_keyword_position_returns_empty(self, language_server: SolidLanguageServer) -> None: """Cursor on the ``import`` keyword (line 0, col 0 of app.component.ts) has no definition target — the TS companion returns ``[]``. """ result = language_server.request_definition(TS_FILE, 0, 0) assert result == [], f"Expected [] for keyword position, got: {result!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_references_for_local_const_have_few_callers(self, language_server: SolidLanguageServer) -> None: """A locally-scoped private field has at most its declaration plus its in-method use as references. ``defaultName`` on GreetingService is used only inside ``greet()``. """ all_symbols, _ = language_server.request_document_symbols(SERVICE_FILE).get_all_symbols_and_roots() sym = next((s for s in all_symbols if s.get("name") == "defaultName"), None) assert sym is not None, "defaultName symbol missing in fixture" sel = sym["selectionRange"]["start"] refs = language_server.request_references(SERVICE_FILE, sel["line"], sel["character"]) assert isinstance(refs, list), f"Expected list, got {type(refs)}" # 1 declaration + 1 internal read = 2 expected; allow 1-3 to absorb # whether the LSP includes the declaration. assert 1 <= len(refs) <= 3, f"defaultName should have 1-3 references, got {len(refs)}: {refs}" class TestAngularEdgeCasePositions: """Position (0,0), whitespace lines, and other boundary conditions.""" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_containing_symbol_at_file_start_is_none(self, language_server: SolidLanguageServer) -> None: """Line 0 of app.component.ts is an ``import`` statement, outside any class or function — the TS companion returns ``None`` for containing symbol. """ result = language_server.request_containing_symbol(TS_FILE, 0, 0) assert result is None, f"Expected None at (0,0), got: {result!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_references_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None: """Position (0, 0) is on the ``import`` keyword — no references.""" result = language_server.request_references(TS_FILE, 0, 0) assert result == [], f"Expected [] at (0,0), got: {result!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_definition_at_file_start_returns_empty(self, language_server: SolidLanguageServer) -> None: """Position (0, 0) is on the ``import`` keyword — no definition.""" result = language_server.request_definition(TS_FILE, 0, 0) assert result == [], f"Expected [] at (0,0), got: {result!r}" @pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True) def test_template_position_no_containing_symbol(self, language_server: SolidLanguageServer) -> None: """An Angular template has no class/function containers; the HTML companion's documentSymbol provides element symbols only. """ # (1, 4) lands inside