feat: Add Angular, HTML, and SCSS / Sass language servers (#1439)

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-authored-by: Pavlo Basanets <Pavlo_Basanets@epam.com>
This commit is contained in:
authored and GitHub committed 2026-05-05 14:33:51 +02:00
1 parent e643e91cd7
commit 3122384822
53 files changed
+3280 -22

No files matched your search

+88
View File
@@ -0,0 +1,88 @@
"""
Pytest fixtures for Angular language server tests.
This conftest is NOT installing the language server itself — that is fully handled
by ``RuntimeDependencyCollection`` inside ``AngularLanguageServer`` and lands in
Serena's managed ``ls_resources_dir`` like every other LS download.
What we install here is the *test fixture project's* npm dependencies (notably
``@angular/core``) into the fixture's own ``node_modules``. ngserver requires
``@angular/core`` to be resolvable from the workspace root or it silently treats
every file as "not in an Angular project", at which point all template-aware
features (definition / hover / references on .html, cross-file template→component
navigation) return empty results — tests would pass vacuously while exercising
nothing. Vendoring ``node_modules`` in-repo is impractical (~500MB), so we run
``npm install`` once per checkout, cached across sessions, and serialised across
xdist workers via ``filelock``.
"""
from __future__ import annotations
import logging
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from filelock import FileLock
log = logging.getLogger(__name__)
REPO_ROOT = Path(__file__).resolve().parents[2] / "resources" / "repos" / "angular" / "test_repo"
NODE_MODULES = REPO_ROOT / "node_modules"
ANGULAR_CORE_MARKER = NODE_MODULES / "@angular" / "core" / "package.json"
# Lock file lives inside the repo (covered by its .gitignore) so xdist workers
# can serialise on the install. Putting it elsewhere risks placing it on a
# different filesystem from the install dir, which makes flock semantics fuzzy.
INSTALL_LOCK = REPO_ROOT / ".angular-install.lock"
@pytest.fixture(scope="session", autouse=True)
def _install_angular_test_repo_node_modules() -> None:
"""
Populate the *test fixture project's* ``node_modules`` (NOT the language server's
install; see module docstring for the distinction).
Cached across sessions: once ``node_modules/@angular/core/package.json`` exists,
this is a no-op. Skipped (with the test session marked) if npm is unavailable.
Under pytest-xdist multiple workers race on the install. ``npm install`` does
not internally serialize concurrent runs against the same install dir, so we
take an exclusive ``filelock`` around the whole install/check sequence — the
second worker into the lock will then short-circuit on the marker check.
"""
if ANGULAR_CORE_MARKER.exists():
log.info("Angular test repo node_modules already populated; skipping npm install")
return
if shutil.which("npm") is None:
pytest.skip("npm is not available; cannot install Angular test repo dependencies")
with FileLock(str(INSTALL_LOCK)):
# Re-check inside the lock: a sibling worker may have just installed.
if ANGULAR_CORE_MARKER.exists():
log.info("Angular test repo node_modules populated by another worker; skipping npm install")
return
log.warning(
"Installing npm dependencies into the Angular test repo at %s. This is a one-time cost per checkout and may take ~30s.",
REPO_ROOT,
)
proc = subprocess.run(
["npm", "install", "--no-audit", "--no-fund", "--loglevel=warn"],
cwd=str(REPO_ROOT),
capture_output=True,
text=True,
check=False,
env=os.environ.copy(),
)
if proc.returncode != 0:
log.error("npm install failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr)
pytest.skip(f"npm install failed in {REPO_ROOT} (rc={proc.returncode}); see logs for details")
if not ANGULAR_CORE_MARKER.exists():
pytest.skip(f"npm install completed but {ANGULAR_CORE_MARKER} is missing; cannot run Angular tests")
log.info("Angular test repo node_modules installed successfully")
+344
View File
@@ -0,0 +1,344 @@
"""
Basic integration tests for the Angular language server.
The Angular LS understands Angular template syntax (*ngIf, [prop], (event),
{{ interpolation }}, @if/@for blocks) and provides type-aware navigation
between templates and component classes.
Workspace: a minimal standalone-component Angular app with a service that the
component injects, plus a template (.html) that interpolates component methods.
"""
import os
import re
from pathlib import Path
import pytest
from serena.util.text_utils import find_text_coordinates
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.conftest import read_repo_file, request_all_symbols
@pytest.mark.angular
class TestAngularLanguageServerBasics:
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
@pytest.mark.parametrize("repo_path", [Language.ANGULAR], indirect=True)
def test_ls_is_running(self, language_server: SolidLanguageServer, repo_path: Path) -> None:
assert language_server.is_running()
assert Path(language_server.language_server.repository_root_path).resolve() == repo_path.resolve()
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_symbols(self, language_server: SolidLanguageServer) -> None:
"""The Angular LS exposes the component class methods/fields via tsserver."""
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("AppComponent", "title", "userName", "items", "greeting", "setName"):
assert expected in names, f"Expected '{expected}' in component symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_service_class_symbols(self, language_server: SolidLanguageServer) -> None:
all_symbols, _ = language_server.request_document_symbols("src/app/greeting.service.ts").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("GreetingService", "greet", "defaultName"):
assert expected in names, f"Expected '{expected}' in service symbols: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_full_symbol_tree_includes_all_files(self, language_server: SolidLanguageServer) -> None:
all_symbols = request_all_symbols(language_server)
relative_paths = {s.get("location", {}).get("relativePath") for s in all_symbols}
# The HTML companion is unconditionally started, so .html documentSymbol
# contributes structural element entries to the workspace tree alongside
# the two TS files.
# relativePath uses OS-native separators (Path.relative_to), so build expectations the same way.
for f in (
os.path.join("src", "app", "app.component.ts"),
os.path.join("src", "app", "greeting.service.ts"),
os.path.join("src", "app", "app.component.html"),
):
assert f in relative_paths, f"Expected {f} to appear in symbol tree, got {relative_paths}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_definition_to_component_method(self, language_server: SolidLanguageServer) -> None:
"""Resolve `greeting()` interpolation in the template to its component method.
Cursor lands inside the ``greeting`` identifier on the ``{{ greeting() }}``
line of ``app.component.html``.
"""
path = "src/app/app.component.html"
needle = "greeting()"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# +1 puts the cursor inside the identifier rather than on its leading boundary.
definitions = language_server.request_definition(path, line, col + 1)
assert definitions, f"Expected non-empty cross-file definition for template->component method, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_plain_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""DocumentSymbol on plain index.html should come back from the HTML companion.
ngserver returns -32601 for documentSymbol on every .html file (plain or
Angular template). The AngularLanguageServer routes .html documentSymbol
to a tertiary vscode-html-language-server companion so users get a
structural element outline instead of an empty list.
"""
all_symbols, _ = language_server.request_document_symbols("src/index.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
# index.html contains <html>, <head>, <meta>, <title>, <body>, <app-root>
for expected in ("html", "head", "body", "app-root"):
assert expected in names, f"Expected '{expected}' in plain-HTML symbol list, got: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_html_document_symbols(self, language_server: SolidLanguageServer) -> None:
"""DocumentSymbol on an Angular template must return the HTML element tree.
Angular template syntax (``@if``/``@for``/``{{ }}``) is ignored by the
vscode-html-language-server parser but the surrounding element structure
(``<section>``, ``<h1>``, ``<p>``, ``<input>``, ``<ul>``, ``<app-item-card>``)
is still reported, which is the intended outline.
"""
all_symbols, _ = language_server.request_document_symbols("src/app/app.component.html").get_all_symbols_and_roots()
names = [s["name"] for s in all_symbols]
for expected in ("section", "h1", "p", "input", "ul", "app-item-card"):
assert expected in names, f"Expected '{expected}' in template HTML symbol list, got: {names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_ts_method_references_include_template_usage(self, language_server: SolidLanguageServer) -> None:
"""References on a .ts component method must include its template callers.
``setName`` is defined in app.component.ts and bound in the template via
``(input)="setName(...)"``. On real Angular projects the typescript-language-server
alone misses the template callers, so the Angular LS routes .ts references
through ngserver which aggregates both .ts and .html usages.
"""
src_path = "src/app/app.component.ts"
# Probe at the declaration site of setName, not a usage; the regex anchors on the
# parameter list signature so we always land on the method header even if calls move.
coords = find_text_coordinates(read_repo_file(language_server, src_path), r"(setName)\(name:")
assert coords is not None, "Could not locate setName declaration in app.component.ts"
# +1 puts the cursor inside the identifier rather than on its leading boundary.
refs = language_server.request_references(src_path, coords.line, coords.col + 1)
ref_paths = {r.get("relativePath", "") for r in refs}
assert any(p.endswith("app.component.html") for p in ref_paths), (
f"Expected references for setName to include its template callsite in app.component.html, got: {ref_paths}"
)
@pytest.mark.angular
class TestAngularHover:
"""Hover routing — .ts goes to the companion tsserver, .html goes to ngserver."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_hover_on_ts_method(self, language_server: SolidLanguageServer) -> None:
"""Hover on a .ts method declaration is routed through the companion TS server
and must yield a non-empty MarkupContent describing the method signature.
"""
path = "src/app/app.component.ts"
needle = "setName("
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past the leading 's') so the LSP
# treats the position as the symbol rather than a token boundary.
hover = language_server.request_hover(path, line, col + 1)
assert hover is not None, f"Expected hover info for setName in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
text = contents["value"] if isinstance(contents, dict) else str(contents)
assert "setName" in text, f"Expected setName in hover text, got: {text}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_hover_on_template_method_call(self, language_server: SolidLanguageServer) -> None:
"""Hover on a method call inside an Angular template ({{ greeting() }}) goes
through ngserver and must yield Angular-aware type info.
"""
path = "src/app/app.component.html"
needle = "greeting()"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past the leading 'g').
hover = language_server.request_hover(path, line, col + 1)
assert hover is not None, f"Expected hover info for greeting() in {path}, got None"
contents = hover.get("contents")
assert contents, f"Expected non-empty hover contents, got: {hover}"
@pytest.mark.angular
class TestAngularDefinitionRouting:
"""Cross-file definition for the binding flavours not covered by basic tests."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_property_binding(self, language_server: SolidLanguageServer) -> None:
"""Property binding ``[value]="userName()"`` must resolve to the component's
``userName`` signal field declaration in app.component.ts.
"""
path = "src/app/app.component.html"
needle = '[value]="userName()"'
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor on the 'u' of userName inside the binding expression
col = col + len('[value]="')
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected non-empty definition for userName binding, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_event_binding(self, language_server: SolidLanguageServer) -> None:
"""Event binding ``(input)="setName(...)"`` must resolve to the component's
``setName`` method declaration in app.component.ts.
"""
path = "src/app/app.component.html"
needle = '(input)="setName('
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + len('(input)="') # cursor on 's' of setName
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected non-empty definition for setName binding, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("app.component.ts") for uri in target_uris), (
f"Expected definition to resolve into app.component.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_service_import_in_component(self, language_server: SolidLanguageServer) -> None:
"""The ``GreetingService`` symbol used in the constructor parameter list
of AppComponent must resolve to greeting.service.ts via the companion TS server.
"""
path = "src/app/app.component.ts"
# Probe at the constructor signature, not the import line, to make sure
# we exercise the type-resolution path rather than the module-resolution path.
needle = "private readonly greetings: GreetingService"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + len("private readonly greetings: ") # cursor on 'G' of GreetingService
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected definition for GreetingService, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
f"Expected definition to resolve into greeting.service.ts, got URIs: {target_uris}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_definition_from_child_component_selector(self, language_server: SolidLanguageServer) -> None:
"""The ``<app-item-card>`` element in the parent template must resolve to the
ItemCardComponent class declaration in item-card.component.ts.
This is an Angular-specific feature: the @angular/language-service tsserver
plugin makes the selector string a navigable type-reference back to its
component class.
"""
path = "src/app/app.component.html"
needle = "app-item-card"
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
col = col + 1 # cursor inside 'app-item-card'
definitions = language_server.request_definition(path, line, col)
assert definitions, f"Expected definition for <app-item-card>, got {definitions}"
target_uris = [d["uri"] for d in definitions]
assert any(uri.endswith("item-card.component.ts") for uri in target_uris), (
f"Expected definition to resolve into item-card.component.ts, got URIs: {target_uris}"
)
@pytest.mark.angular
class TestAngularRename:
"""Rename routing returns a WorkspaceEdit without applying it (safe for fixtures)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_rename_method_returns_edits_for_ts_and_template(self, language_server: SolidLanguageServer) -> None:
"""Renaming the ``setName`` method from its .ts declaration must return a
WorkspaceEdit that touches both app.component.ts (declaration + any TS calls)
and app.component.html (the ``(input)="setName(...)"`` binding).
"""
path = "src/app/app.component.ts"
# Anchor on the declaration site (signature with the parameter list), not a usage.
coords = find_text_coordinates(read_repo_file(language_server, path), r"(setName)\(name:")
assert coords is not None, "Could not locate setName declaration in app.component.ts"
# +1 puts the cursor inside the identifier rather than on its leading boundary.
edit = language_server.request_rename_symbol_edit(path, coords.line, coords.col + 1, "updateName")
assert edit is not None, "Expected WorkspaceEdit, got None"
changes = edit.get("changes") or {}
# Some servers return ``documentChanges`` instead of (or in addition to) ``changes``.
document_changes = edit.get("documentChanges") or []
all_uris: set[str] = set(changes.keys())
for dc in document_changes:
uri = (dc.get("textDocument") or {}).get("uri") or dc.get("uri")
if uri:
all_uris.add(uri)
assert any(uri.endswith("app.component.ts") for uri in all_uris), f"Expected rename edits in app.component.ts, got URIs: {all_uris}"
assert any(uri.endswith("app.component.html") for uri in all_uris), (
f"Expected rename edits in app.component.html (template binding), got URIs: {all_uris}"
)
@pytest.mark.angular
class TestAngularSymbolStructure:
"""Hierarchical symbol structure — class symbols must contain method/field children."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_has_methods_and_fields_as_children(self, language_server: SolidLanguageServer) -> None:
"""``request_document_symbols`` should return AppComponent as a class symbol
whose children include its methods (``greeting``, ``setName``) and signal
fields (``title``, ``userName``, ``items``). Flat-name checks pass even when
the hierarchy is broken; this test asserts the parent-child relationship.
"""
all_symbols, root_symbols = language_server.request_document_symbols("src/app/app.component.ts").get_all_symbols_and_roots()
component = next((s for s in all_symbols if s.get("name") == "AppComponent"), None)
assert component is not None, "AppComponent class symbol not found"
children = component.get("children") or []
child_names = {c.get("name") for c in children}
for expected in ("greeting", "setName", "title", "userName", "items"):
assert expected in child_names, f"Expected '{expected}' as child of AppComponent, got: {child_names}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_pipe_class_in_symbol_tree(self, language_server: SolidLanguageServer) -> None:
"""A custom pipe (``ExclaimPipe``) declared in exclaim.pipe.ts must appear in
the workspace symbol tree alongside the components and the service.
"""
all_symbols = request_all_symbols(language_server)
names = {s.get("name") for s in all_symbols}
for expected in ("ExclaimPipe", "ItemCardComponent", "AppComponent", "GreetingService"):
assert expected in names, f"Expected '{expected}' in full symbol tree, got: {sorted(names)}"
@pytest.mark.angular
class TestAngularImplementations:
"""``textDocument/implementation`` is routed through tsserver via the Angular plugin."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_find_implementations_of_interface_method(self, language_server: SolidLanguageServer) -> None:
"""``Greeter.greet`` is implemented by ``GreetingService``; ``request_implementation``
invoked at the interface declaration must point at the service.
Routed through the Angular LS' companion typescript-language-server (with the
@angular/language-service plugin loaded as a tsserver plugin) — bare ngserver
does not own ``textDocument/implementation``.
"""
path = "src/app/greeter.interface.ts"
needle = "greet("
coords = find_text_coordinates(read_repo_file(language_server, path), f"({re.escape(needle)})")
assert coords is not None, f"Could not find {needle!r} in {path}"
line, col = coords.line, coords.col
# Cursor inside the identifier (one past 'g').
implementations = language_server.request_implementation(path, line, col + 1)
assert implementations, f"Expected at least one implementation of Greeter.greet, got {implementations}"
target_uris = [impl["uri"] for impl in implementations]
assert any(uri.endswith("greeting.service.ts") for uri in target_uris), (
f"Expected implementation to resolve into greeting.service.ts, got URIs: {target_uris}"
)
@@ -0,0 +1,44 @@
"""
Diagnostics tests for the Angular language server.
Two paths are exercised:
* .ts component — tsserver (via the @angular/language-service plugin) reports
the type-mismatch on a class field initializer.
* .html template — ngserver reports the unresolved identifier in a template
interpolation, but only because the template is attached to a @Component via
``templateUrl``. Bare .html files are not type-checked by ngserver.
"""
import pytest
from solidlsp import SolidLanguageServer
from solidlsp.ls_config import Language
from test.solidlsp.util.diagnostics import assert_file_diagnostics
@pytest.mark.angular
class TestAngularDiagnostics:
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_component_class_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""The component's ``count: number = 'not-a-number'`` must be flagged by tsserver."""
assert_file_diagnostics(
language_server,
"src/app/diagnostics_sample.ts",
(),
min_count=1,
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_template_diagnostics(self, language_server: SolidLanguageServer) -> None:
"""The template's ``{{ undefinedSignal() }}`` must be flagged by ngserver.
Routed through the Angular template compiler, which only checks templates
attached to a @Component — see the companion ``diagnostics_sample.ts`` that
wires this file via ``templateUrl``.
"""
assert_file_diagnostics(
language_server,
"src/app/diagnostics_sample.html",
(),
min_count=1,
)
@@ -0,0 +1,369 @@
"""
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 <h1>{{ title() | exclaim }}</h1> on line 1.
result = language_server.request_containing_symbol(TEMPLATE_FILE, 1, 4)
assert result is None, f"Expected None inside template, got: {result!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_inside_class_body_returns_class(self, language_server: SolidLanguageServer) -> None:
"""Lines 5 and 10 of app.component.ts are inside the AppComponent class
body. The TS companion correctly reports AppComponent as the
containing symbol.
"""
for line in (5, 10):
result = language_server.request_containing_symbol(TS_FILE, line, 0)
assert isinstance(result, dict), f"Line {line}: expected dict, got {result!r}"
assert result.get("name") == "AppComponent", f"Line {line}: expected 'AppComponent', got {result.get('name')!r}"
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_containing_symbol_outside_class_body_is_none(self, language_server: SolidLanguageServer) -> None:
"""Line 0 (import) and line 15 (blank/whitespace inside file but
outside any symbol's range, depending on file structure) report no
containing symbol.
"""
for line in (0,):
result = language_server.request_containing_symbol(TS_FILE, line, 0)
assert result is None, f"Line {line}: expected None, got {result!r}"
class TestAngularReferenceEdgeCases:
"""Edge cases for the SolidLanguageServer reference helpers (built on top of LSP)."""
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_referencing_symbols_at_invalid_position_raises(self, language_server: SolidLanguageServer) -> None:
"""Unlike ``request_references`` (which returns ``[]``),
``request_referencing_symbols`` validates more strictly and surfaces
the underlying TS server error as a ``SolidLSPException``.
"""
if IS_WINDOWS:
# Windows TS server is known to swallow these — keep platform-specific
# contract minimal here since we can't run Windows in dev.
result = list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
assert result == [], f"Expected [] on Windows, got: {result!r}"
return
with pytest.raises(SolidLSPException) as exc_info:
list(language_server.request_referencing_symbols(SERVICE_FILE, -1, -1, include_self=False))
assert "Bad line number" in str(exc_info.value) or "Debug Failure" in str(exc_info.value), (
f"Unexpected exception message: {exc_info.value}"
)
@pytest.mark.parametrize("language_server", [Language.ANGULAR], indirect=True)
def test_defining_symbol_at_invalid_position_returns_none(self, language_server: SolidLanguageServer) -> None:
"""``request_defining_symbol`` short-circuits when no definition is
found and returns None — no exception, even for negative positions.
"""
result = language_server.request_defining_symbol(TS_FILE, -1, -1)
assert result is None, f"Expected None, got: {result!r}"
class TestAngularCoreProbe:
"""Unit tests for the monorepo-aware ``_find_angular_core_install`` walk.
These don't spin up the Angular LS — they exercise the pure-Python
filesystem walker against synthetic node_modules layouts.
"""
@staticmethod
def _walk(repo_path: str) -> str | None:
"""Invoke the bound method against a minimal stub carrying only
``repository_root_path``.
"""
from solidlsp.language_servers.angular_language_server import AngularLanguageServer
stub = type("Stub", (), {"repository_root_path": repo_path})()
return AngularLanguageServer._find_angular_core_install(stub) # type: ignore[arg-type]
def test_finds_core_at_project_root(self, tmp_path) -> None:
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
core_pkg.parent.mkdir(parents=True)
core_pkg.write_text("{}")
assert self._walk(str(tmp_path)) == str(core_pkg)
def test_finds_hoisted_core_in_workspace_parent(self, tmp_path) -> None:
"""Nx / yarn-workspaces layout: ``node_modules`` is hoisted to the
workspace root and the activated sub-package has none of its own.
"""
sub_pkg = tmp_path / "packages" / "app"
sub_pkg.mkdir(parents=True)
core_pkg = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
core_pkg.parent.mkdir(parents=True)
core_pkg.write_text("{}")
# Workspace root marker — the walker stops here.
(tmp_path / "package.json").write_text('{"workspaces": ["packages/*"]}')
assert self._walk(str(sub_pkg)) == str(core_pkg)
def test_returns_none_when_not_installed(self, tmp_path) -> None:
"""No node_modules anywhere — walker exhausts and returns None."""
sub_pkg = tmp_path / "packages" / "app"
sub_pkg.mkdir(parents=True)
assert self._walk(str(sub_pkg)) is None
def test_workspace_root_stops_walk(self, tmp_path) -> None:
"""If a ``package.json`` declares ``workspaces`` we should not walk past
it — the workspace root is the canonical install location even when no
``@angular/core`` is present there.
"""
# Plant a misleading sibling install ABOVE the workspace root that the
# walker must NOT find.
sibling_core = tmp_path / "node_modules" / "@angular" / "core" / "package.json"
sibling_core.parent.mkdir(parents=True)
sibling_core.write_text("{}")
workspace = tmp_path / "ws"
workspace.mkdir()
(workspace / "package.json").write_text('{"workspaces": ["packages/*"]}')
sub_pkg = workspace / "packages" / "app"
sub_pkg.mkdir(parents=True)
assert self._walk(str(sub_pkg)) is None
class TestAngularStartupCleanup:
"""Regression: companion processes (TS + HTML) must not leak when ngserver
fails partway through ``_start_server``.
Until the cleanup wrapper landed, an exception during ngserver init left
the two companion Node processes orphaned because the parent constructor
never returned a handle on which the caller could invoke ``stop()``.
"""
def test_companions_cleaned_up_on_initialize_failure(self, monkeypatch: pytest.MonkeyPatch) -> None:
if IS_WINDOWS:
pytest.skip("psutil child enumeration is flaky under Windows CI")
import psutil
ls = _create_ls(Language.ANGULAR)
my_proc = psutil.Process(os.getpid())
children_before = {p.pid for p in my_proc.children(recursive=True)}
def boom(*_args: object, **_kwargs: object) -> dict:
raise RuntimeError("simulated ngserver init failure")
# _get_initialize_params runs after both companions and ngserver have
# been spawned — exactly the failure window the cleanup wrapper
# protects against.
monkeypatch.setattr(ls, "_get_initialize_params", boom)
try:
with pytest.raises(RuntimeError, match="simulated ngserver init"):
ls.start()
assert ls._ts_server is None, "TS companion was not cleared after startup failure"
assert ls._html_server is None, "HTML companion was not cleared after startup failure"
# Allow a brief grace period for the OS to reap the spawned Node
# processes (and grandchildren — typescript-language-server forks
# tsserver).
deadline = time.monotonic() + 5.0
new_children: set[int] = set()
while time.monotonic() < deadline:
children_after = {p.pid for p in my_proc.children(recursive=True)}
new_children = children_after - children_before
if not new_children:
break
time.sleep(0.2)
assert not new_children, f"Companion processes leaked after startup failure: {sorted(new_children)}"
finally:
# Best-effort: if anything is still around (e.g. ngserver itself,
# which lives outside the cleanup contract), shut it down so the
# test session doesn't leave orphans.
try:
ls.stop(shutdown_timeout=2.0)
except Exception:
pass