mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-13 18:16:10 +00:00
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:
53 files changed
+3280
-22
No files matched your search
@@ -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")
|
||||
@@ -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
|
||||
Reference in new issue
Block a user