mirror of
https://github.com/tiennm99/serena.git
synced 2026-10-11 03:13:51 +00:00
Svelte + TypeScript: fail loudly on companion TS timeouts; deterministic test fixture
Make the Svelte companion TypeScript server raise on readiness and
indexing timeouts instead of silently proceeding with a partial index,
turning flaky/wrong cross-file results into clear failures:
- TypeScriptLanguageServer: timeout handling factored into overridable
hooks (_handle_server_ready_timeout, _handle_project_indexing_timeout);
the base server keeps its historical permissive behavior, so plain
TypeScript setups are unchanged.
- SvelteTypeScriptServer: both overrides raise TimeoutError; only the
indexing-timeout error includes describe_indexing_state(), instead of
serving requests from a cold or partially indexed program.
- Configurable server_ready_timeout / indexing_timeout via
ls_specific_settings.
- SvelteLanguageServer: file-open failures during companion preparation
raise SvelteCompanionPreparationError with a count-first file listing
(capped at 10), chained to the first underlying error so the cause is
visible without DEBUG logging.
Apply the same fail-loudly principle to the test fixture bootstrap:
- Svelte test repo: npm ci with a committed package-lock.json, removing
dependency-resolution non-determinism from CI runs.
- Run svelte-kit sync explicitly during fixture setup and fail loudly if
.svelte-kit/tsconfig.json cannot be generated. The fixture's "prepare"
script masks sync failures ("svelte-kit sync || echo ''"), and a known
npm bug (npm/cli#4828) can silently skip platform-specific optional
dependencies; the result is unresolvable $lib path aliases and
cross-file tests failing with partial results instead of a clear,
actionable error.
- codespell: skip third-party fixture repositories, using the anchored
pattern ./test/resources/repos (codespell matches skip globs against
the ./-prefixed paths it walks, so an anchor-less pattern never
matches).
Also add direct regression tests for the timeout-policy split
(test/solidlsp/test_typescript_timeout_policy.py: base stays
permissive, companion raises, settings plumbing and precedence, and
start-or-completion indexing-wait semantics). The tests use no mocking or
patching of any kind; a hand-written call-recording fake drives the companion
guard against real file discovery under a pytest tmp_path. They spawn no LS
process and avoid external readiness races; one wait test uses a real Timer
with a 30s ceiling.
The configuration docs state that the TypeScript table lists both knobs; the
Svelte table lists indexing_timeout, with readiness inheritance documented in
prose.
This commit is contained in:
1 parent
e08e964d0c
commit
9580a6aec7
11 files changed
+2358
-60
No files matched your search
@@ -63,6 +63,11 @@ Status of the `main` branch. Changes prior to the next official version change w
|
||||
processed by the Svelte LS instead of the TypeScript LS.
|
||||
- `SvelteLanguageServer`: Fix document-symbol requests for TypeScript/JavaScript files returning empty
|
||||
results in svelte-only mode (`languages: [svelte]`. #1552
|
||||
- Svelte + TypeScript: make companion TypeScript server raise on readiness and indexing timeouts (instead
|
||||
of silent "proceeding anyway"), so that partial indexing surfaces as a clear failure instead of flaky/wrong
|
||||
cross-file results; add configurable timeouts (`server_ready_timeout`, `indexing_timeout`)
|
||||
and overridable timeout hooks (base TS stays permissive). Svelte test fixture now uses `npm ci` +
|
||||
committed `package-lock.json`.
|
||||
- `JuliaLanguageServer`: Fix the stdio MCP server exiting right after `initialize` ("tools fetch failed")
|
||||
when `julia` is enabled. #1577
|
||||
- `Java`: invalidate JDTLS workspace cache when Java import settings change #1576
|
||||
|
||||
@@ -1031,6 +1031,7 @@ Supported settings:
|
||||
| `typescript_language_server_version` | `5.1.3` | Override the bundled `typescript-language-server` npm package version Serena installs when `ls_path` is not set. |
|
||||
| `npm_registry` | `null` | Override the npm registry Serena uses for the managed install. |
|
||||
| `indexing_timeout` | `30.0` | Timeout in seconds for waiting on tsserver's `$/progress` project-indexing signal (both at startup and before the first cross-file reference query). If indexing does not complete within this window, Serena logs a warning and proceeds anyway. Increase it for very large projects. |
|
||||
| `server_ready_timeout` | `10.0` | Timeout in seconds for waiting on the server-ready signal after initialization. If the signal does not arrive within this window, Serena logs a message and proceeds anyway. |
|
||||
|
||||
#### Svelte
|
||||
|
||||
@@ -1048,8 +1049,15 @@ Supported settings:
|
||||
| `typescript_language_server_version` | `5.1.3` (falls back to `ls_specific_settings.typescript.typescript_language_server_version`) | Override the `typescript-language-server` npm package version for the companion server. |
|
||||
| `typescript_svelte_plugin_version` | `0.3.52` | Override the `typescript-svelte-plugin` npm package version used for `.svelte`-aware TS resolution. |
|
||||
| `npm_registry` | `null` | Override the npm registry Serena uses for all managed installs. |
|
||||
| `indexing_timeout` | `120.0` (falls back to `ls_specific_settings.typescript.indexing_timeout`) | Timeout in seconds for the companion TS server to finish indexing `.svelte` files. On timeout, startup fails with a diagnostic indexing-state summary instead of serving cross-file results from a partially indexed program. |
|
||||
| `initialization_options_configuration` | `{}` | Deep-merge overrides for any of the ten plugin configuration sections (`svelte`, `prettier`, `emmet`, `typescript`, `javascript`, `js/ts`, `css`, `less`, `scss`, `html`). |
|
||||
|
||||
Unlike the plain TypeScript server, the companion is strict about readiness: it raises on server-ready and
|
||||
indexing timeouts instead of proceeding with a cold or partially indexed program (which would silently degrade
|
||||
cross-file renames and references). The companion reads `server_ready_timeout` and `indexing_timeout` from
|
||||
`ls_specific_settings.typescript` with raised defaults (30s and 120s respectively); `ls_specific_settings.svelte.indexing_timeout`
|
||||
takes precedence for the `.svelte`-file indexing wait.
|
||||
|
||||
All four packages are tracked via a version file; changing any version setting triggers a clean reinstall.
|
||||
|
||||
#### TypeScript via `vtsls`
|
||||
|
||||
+3
-1
@@ -382,7 +382,9 @@ markers = [
|
||||
|
||||
[tool.codespell]
|
||||
# Ref: https://github.com/codespell-project/codespell#using-a-config-file
|
||||
skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py'
|
||||
# We skip third-party test fixture repositories entirely (they contain
|
||||
# external package lockfiles, minified code, and arbitrary dependency strings).
|
||||
skip = '.git*,*.svg,*.lock,*.min.*,*test_memories_manager.py,./test/resources/repos'
|
||||
check-hidden = true
|
||||
ignore-regex = '\.\w+'
|
||||
ignore-words-list = 'paket,EDN,als,ue'
|
||||
@@ -34,6 +34,12 @@ log = logging.getLogger(__name__)
|
||||
TS_EXT = frozenset({".ts", ".tsx", ".mts", ".cts"})
|
||||
JS_EXT = frozenset({".js", ".jsx", ".mjs", ".cjs"})
|
||||
SVELTE_EXT = frozenset({".svelte"})
|
||||
# cap the file listing in SvelteCompanionPreparationError so a large repo cannot bloat the message
|
||||
_MAX_FAILED_FILES_IN_ERROR = 10
|
||||
|
||||
|
||||
class SvelteCompanionPreparationError(RuntimeError):
|
||||
"""Raised when the companion TypeScript server cannot be prepared deterministically."""
|
||||
|
||||
|
||||
def _is_ts_file(uri: str) -> bool:
|
||||
@@ -54,6 +60,9 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer):
|
||||
Spawned and owned by :class:`SvelteLanguageServer`; not instantiated directly.
|
||||
"""
|
||||
|
||||
INDEXING_PROGRESS_TIMEOUT = 120.0
|
||||
SERVER_READY_TIMEOUT = 30.0
|
||||
|
||||
class DependencyProvider(TypeScriptLanguageServer.DependencyProvider):
|
||||
"""Returns the pre-installed typescript-language-server binary.
|
||||
|
||||
@@ -142,6 +151,16 @@ class SvelteTypeScriptServer(TypeScriptLanguageServer):
|
||||
self.server.on_request("workspace/configuration", workspace_configuration_handler)
|
||||
super()._start_server()
|
||||
|
||||
@override
|
||||
def _handle_server_ready_timeout(self, timeout: float) -> None:
|
||||
raise TimeoutError(f"Svelte companion TypeScript server did not become ready within {timeout:.0f}s")
|
||||
|
||||
@override
|
||||
def _handle_project_indexing_timeout(self, timeout: float) -> None:
|
||||
raise TimeoutError(
|
||||
f"Svelte companion TypeScript server project indexing did not complete within {timeout:.0f}s ({self.describe_indexing_state()})"
|
||||
)
|
||||
|
||||
|
||||
class SvelteLanguageServer(SolidLanguageServer):
|
||||
"""
|
||||
@@ -151,6 +170,9 @@ class SvelteLanguageServer(SolidLanguageServer):
|
||||
* ``svelte_language_server_version``: version of ``svelte-language-server``
|
||||
to install (default: ``0.18.0``).
|
||||
* ``npm_registry``: optional alternative npm-compatible registry URL.
|
||||
* ``indexing_timeout``: optional timeout in seconds for companion TS indexing of
|
||||
Svelte files. Falls back to ``ls_specific_settings["typescript"].indexing_timeout``
|
||||
or the Svelte companion default.
|
||||
* ``initialization_options_configuration``: optional dict merged into
|
||||
``initializeParams.initializationOptions.configuration`` (same top-level keys as in
|
||||
Svelte Language Tools: ``svelte``, ``prettier``, ``typescript``, …).
|
||||
@@ -313,6 +335,15 @@ class SvelteLanguageServer(SolidLanguageServer):
|
||||
log.debug("Error processing svelte file %s: %s", svelte_file, exc)
|
||||
return svelte_files
|
||||
|
||||
def _get_companion_indexing_timeout(self) -> float:
|
||||
""":return: maximum seconds to wait for companion TS indexing after opening Svelte files."""
|
||||
ts_settings = self._solidlsp_settings.get_ls_specific_settings(Language.TYPESCRIPT)
|
||||
timeout = self._custom_settings.get(
|
||||
"indexing_timeout",
|
||||
ts_settings.get("indexing_timeout", SvelteTypeScriptServer.INDEXING_PROGRESS_TIMEOUT),
|
||||
)
|
||||
return float(timeout)
|
||||
|
||||
def _ensure_svelte_files_indexed_on_ts_server(self) -> None:
|
||||
"""Open all .svelte files on the companion TS server so the plugin includes them in the TS program.
|
||||
|
||||
@@ -332,6 +363,8 @@ class SvelteLanguageServer(SolidLanguageServer):
|
||||
# prepare progress tracking BEFORE opening files to avoid a race
|
||||
self._ts_server.expect_indexing()
|
||||
|
||||
failed_svelte_files = []
|
||||
first_open_error: Exception | None = None
|
||||
for svelte_file in svelte_files:
|
||||
try:
|
||||
with self._ts_server.open_file(svelte_file) as file_buffer:
|
||||
@@ -339,15 +372,29 @@ class SvelteLanguageServer(SolidLanguageServer):
|
||||
self._indexed_svelte_file_uris.append(file_buffer.uri)
|
||||
except Exception as exc:
|
||||
log.debug("Failed to open %s on companion TS server: %s", svelte_file, exc)
|
||||
if first_open_error is None:
|
||||
first_open_error = exc
|
||||
failed_svelte_files.append(svelte_file)
|
||||
|
||||
if failed_svelte_files:
|
||||
shown_files = sorted(failed_svelte_files)[:_MAX_FAILED_FILES_IN_ERROR]
|
||||
remainder = len(failed_svelte_files) - len(shown_files)
|
||||
listing = ", ".join(shown_files) + (f" and {remainder} more" if remainder else "")
|
||||
raise SvelteCompanionPreparationError(
|
||||
f"Failed to open {len(failed_svelte_files)} Svelte file(s) on companion TypeScript server: {listing}"
|
||||
) from first_open_error
|
||||
|
||||
self._svelte_files_indexed = True
|
||||
log.info("Svelte file indexing complete; waiting for companion TS server to finish processing")
|
||||
|
||||
timeout = TypeScriptLanguageServer.INDEXING_PROGRESS_TIMEOUT
|
||||
if self._ts_server.wait_for_indexing(timeout=timeout):
|
||||
timeout = self._get_companion_indexing_timeout()
|
||||
if self._ts_server._wait_for_indexing_start_or_completion(timeout=timeout):
|
||||
log.info("Companion TypeScript server finished indexing .svelte files")
|
||||
else:
|
||||
log.warning("Timeout (%ss) waiting for companion TS server to index .svelte files; proceeding anyway", timeout)
|
||||
raise TimeoutError(
|
||||
f"Companion TypeScript server did not finish indexing {len(svelte_files)} .svelte files within {timeout:.0f}s "
|
||||
f"({self._ts_server.describe_indexing_state()})"
|
||||
)
|
||||
|
||||
def _cleanup_indexed_svelte_files(self) -> None:
|
||||
"""Decrement ref-counts for all .svelte files opened during indexing."""
|
||||
@@ -384,13 +431,13 @@ class SvelteLanguageServer(SolidLanguageServer):
|
||||
)
|
||||
log.info("Starting companion SvelteTypeScriptServer")
|
||||
self._ts_server.start()
|
||||
log.info("Waiting for companion SvelteTypeScriptServer to be ready ...")
|
||||
if not self._ts_server.server_ready.wait(timeout=30.0):
|
||||
log.warning("Timeout waiting for companion SvelteTypeScriptServer; proceeding anyway")
|
||||
self._ts_server.server_ready.set()
|
||||
self._ts_server_started = True
|
||||
log.info("Companion SvelteTypeScriptServer ready")
|
||||
self._ensure_svelte_files_indexed_on_ts_server()
|
||||
except (TimeoutError, SvelteCompanionPreparationError):
|
||||
log.exception("Failed to prepare companion SvelteTypeScriptServer; aborting Svelte server startup")
|
||||
self._stop_typescript_server()
|
||||
raise
|
||||
except Exception:
|
||||
log.exception("Error starting companion SvelteTypeScriptServer; TS-side operations degrade to svelte LS")
|
||||
self._ts_server = None
|
||||
|
||||
@@ -73,6 +73,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
|
||||
- typescript_version: Version of TypeScript to install (default: "5.9.3")
|
||||
- typescript_language_server_version: Version of typescript-language-server to install (default: "5.1.3")
|
||||
- indexing_timeout: float, timeout in seconds for project indexing (default: 30.0)
|
||||
- server_ready_timeout: float, timeout in seconds for the server-ready signal (default: 10.0)
|
||||
"""
|
||||
|
||||
@classmethod
|
||||
@@ -82,6 +83,7 @@ class TypeScriptLanguageServer(SolidLanguageServer):
|
||||
# Safety timeout for $/progress-based indexing wait. Normally the event fires
|
||||
# well within this window; the timeout is only hit if the server never sends progress.
|
||||
INDEXING_PROGRESS_TIMEOUT = 30.0
|
||||
SERVER_READY_TIMEOUT = 10.0
|
||||
|
||||
def __init__(self, config: LanguageServerConfig, repository_root_path: str, solidlsp_settings: SolidLSPSettings):
|
||||
"""
|
||||
@@ -114,6 +116,35 @@ class TypeScriptLanguageServer(SolidLanguageServer):
|
||||
"""
|
||||
return self._indexing_complete.wait(timeout=timeout)
|
||||
|
||||
def _wait_for_indexing_start_or_completion(self, timeout: float, start_grace: float | None = None) -> bool:
|
||||
"""Wait until TypeScript indexing has started and drained, or provably never started.
|
||||
|
||||
:param timeout: Maximum seconds to wait once active indexing progress is observed.
|
||||
:param start_grace: Maximum seconds to wait for progress to begin after opening files.
|
||||
:return: True if indexing completed or no progress began within the grace period, False on timeout.
|
||||
"""
|
||||
grace = self._INDEXING_START_GRACE_S if start_grace is None else start_grace
|
||||
|
||||
# wait for progress to begin
|
||||
progress_deadline = time.monotonic() + grace
|
||||
while time.monotonic() < progress_deadline:
|
||||
with self._progress_lock:
|
||||
if self._active_progress_tokens:
|
||||
break
|
||||
if self._indexing_complete.is_set():
|
||||
return True
|
||||
time.sleep(0.05)
|
||||
|
||||
# treat absent progress as ready
|
||||
with self._progress_lock:
|
||||
has_active_progress = bool(self._active_progress_tokens)
|
||||
if not has_active_progress:
|
||||
self._indexing_complete.set()
|
||||
return True
|
||||
|
||||
# wait for active progress to drain
|
||||
return self.wait_for_indexing(timeout=timeout)
|
||||
|
||||
def expect_indexing(self) -> None:
|
||||
"""Signal that new files are about to be opened and async indexing should be awaited.
|
||||
|
||||
@@ -123,6 +154,44 @@ class TypeScriptLanguageServer(SolidLanguageServer):
|
||||
"""
|
||||
self._indexing_complete.clear()
|
||||
|
||||
def describe_indexing_state(self) -> str:
|
||||
""":return: compact diagnostic state for TypeScript indexing progress."""
|
||||
with self._progress_lock:
|
||||
active_tokens = sorted(token for token in self._active_progress_tokens if token)
|
||||
complete = self._indexing_complete.is_set()
|
||||
|
||||
token_text = ", ".join(active_tokens) if active_tokens else "<none>"
|
||||
return f"complete={complete}, active_progress_tokens={token_text}"
|
||||
|
||||
def _get_server_ready_timeout(self) -> float:
|
||||
""":return: maximum seconds to wait for the TypeScript server-ready signal."""
|
||||
return float(self._custom_settings.get("server_ready_timeout", self.SERVER_READY_TIMEOUT))
|
||||
|
||||
def _get_indexing_timeout(self) -> float:
|
||||
""":return: maximum seconds to wait for TypeScript project indexing."""
|
||||
return float(self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT))
|
||||
|
||||
def _handle_server_ready_timeout(self, timeout: float) -> None:
|
||||
"""Handle a TypeScript server-ready timeout.
|
||||
|
||||
The base TypeScript server keeps the historical permissive behavior. Strict companion
|
||||
servers override this hook to fail before serving requests from a cold server.
|
||||
"""
|
||||
log.info("Timeout waiting for TypeScript server to become ready after %.0fs, proceeding anyway", timeout)
|
||||
self.server_ready.set()
|
||||
|
||||
def _handle_project_indexing_timeout(self, timeout: float) -> None:
|
||||
"""Handle a TypeScript project-indexing timeout.
|
||||
|
||||
The base TypeScript server keeps the historical permissive behavior. Strict companion
|
||||
servers override this hook to fail before serving requests from a partially indexed program.
|
||||
"""
|
||||
log.warning(
|
||||
"TypeScript project indexing did not complete within %.0fs; proceeding anyway (%s)",
|
||||
timeout,
|
||||
self.describe_indexing_state(),
|
||||
)
|
||||
|
||||
def _create_dependency_provider(self) -> LanguageServerDependencyProvider:
|
||||
return self.DependencyProvider(self._custom_settings, self._ls_resources_dir)
|
||||
|
||||
@@ -383,26 +452,22 @@ class TypeScriptLanguageServer(SolidLanguageServer):
|
||||
}
|
||||
|
||||
self.server.notify.initialized({})
|
||||
if self.server_ready.wait(timeout=10.0):
|
||||
server_ready_timeout = self._get_server_ready_timeout()
|
||||
if self.server_ready.wait(timeout=server_ready_timeout):
|
||||
log.info("TypeScript server is ready")
|
||||
else:
|
||||
log.info("Timeout waiting for TypeScript server to become ready, proceeding anyway")
|
||||
# Fallback: assume server is ready after timeout
|
||||
self.server_ready.set()
|
||||
self._handle_server_ready_timeout(server_ready_timeout)
|
||||
|
||||
# Wait for any async project loading to complete.
|
||||
# typescript-language-server may send $/progress for "Initializing JS/TS
|
||||
# language features…" after initialized. If no progress is sent,
|
||||
# _indexing_complete stays SET and wait() returns immediately.
|
||||
indexing_timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT)
|
||||
indexing_timeout = self._get_indexing_timeout()
|
||||
log.info("Waiting for TypeScript project indexing to complete (if async)...")
|
||||
if self.wait_for_indexing(timeout=indexing_timeout):
|
||||
log.info("TypeScript project indexing complete")
|
||||
else:
|
||||
log.warning(
|
||||
"TypeScript project indexing did not complete within %.0fs; proceeding anyway",
|
||||
indexing_timeout,
|
||||
)
|
||||
self._handle_project_indexing_timeout(indexing_timeout)
|
||||
|
||||
self._activate_additional_workspaces()
|
||||
|
||||
@@ -438,10 +503,15 @@ class TypeScriptLanguageServer(SolidLanguageServer):
|
||||
|
||||
@override
|
||||
def _wait_for_additional_workspace_indexing(self) -> None:
|
||||
if self.wait_for_indexing(timeout=self.INDEXING_PROGRESS_TIMEOUT):
|
||||
timeout = self._get_indexing_timeout()
|
||||
if self.wait_for_indexing(timeout=timeout):
|
||||
log.info("Additional workspace indexing complete")
|
||||
else:
|
||||
log.warning("Additional workspace indexing did not complete within timeout; proceeding anyway")
|
||||
log.warning(
|
||||
"Additional workspace indexing did not complete within %.0fs; proceeding anyway (%s)",
|
||||
timeout,
|
||||
self.describe_indexing_state(),
|
||||
)
|
||||
|
||||
@override
|
||||
def _get_published_diagnostics_uri(self, request_uri: str) -> str:
|
||||
@@ -470,19 +540,14 @@ class TypeScriptLanguageServer(SolidLanguageServer):
|
||||
def _wait_for_cross_file_references_if_needed(self) -> None:
|
||||
if self._has_waited_for_cross_file_references:
|
||||
return
|
||||
# Give tsserver a short grace period to start reporting $/progress after the file
|
||||
# was opened. If progress starts, wait until it drains (bounded by indexing_timeout);
|
||||
# if it never starts, tsserver considers the project loaded and we proceed after the
|
||||
# grace period — which is no worse than the previous fixed 2-second wait.
|
||||
grace_deadline = time.monotonic() + self._INDEXING_START_GRACE_S
|
||||
while time.monotonic() < grace_deadline and not self._indexing_complete.is_set() and not self._active_progress_tokens:
|
||||
time.sleep(0.05)
|
||||
if self._active_progress_tokens:
|
||||
timeout = self._custom_settings.get("indexing_timeout", self.INDEXING_PROGRESS_TIMEOUT)
|
||||
if self.wait_for_indexing(timeout=timeout):
|
||||
log.info("TypeScript cross-file indexing complete")
|
||||
else:
|
||||
log.warning("TypeScript cross-file indexing did not complete within %.0fs; proceeding", timeout)
|
||||
|
||||
timeout = self._get_indexing_timeout()
|
||||
if self._wait_for_indexing_start_or_completion(timeout=timeout):
|
||||
log.info("TypeScript cross-file indexing complete")
|
||||
else:
|
||||
log.warning(
|
||||
"TypeScript cross-file indexing did not complete within %.0fs; proceeding (%s)", timeout, self.describe_indexing_state()
|
||||
)
|
||||
self._has_waited_for_cross_file_references = True
|
||||
|
||||
@override
|
||||
|
||||
@@ -23,6 +23,5 @@ vite.config.js.timestamp-*
|
||||
vite.config.ts.timestamp-*
|
||||
|
||||
# Lock files
|
||||
package-lock.json
|
||||
pnpm-lock.yaml
|
||||
yarn.lock
|
||||
yarn.lock
|
||||
+1864
File diff suppressed because it is too large.
Load diff
@@ -13,41 +13,79 @@ log = logging.getLogger(__name__)
|
||||
|
||||
repo_path = Path(__file__).resolve().parents[2] / "resources" / "repos" / "svelte" / "test_repo"
|
||||
NODE_MODULES = repo_path / "node_modules"
|
||||
PACKAGE_LOCK = repo_path / "package-lock.json"
|
||||
SVELTE_MARKER = NODE_MODULES / "svelte" / "package.json"
|
||||
SVELTE_KIT_ADAPTER_MARKER = NODE_MODULES / "@sveltejs" / "adapter-auto" / "package.json"
|
||||
SVELTE_KIT_TSCONFIG = repo_path / ".svelte-kit" / "tsconfig.json"
|
||||
INSTALL_LOCK = repo_path / ".svelte-install.lock"
|
||||
|
||||
|
||||
def _fixture_ready() -> bool:
|
||||
return SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists() and SVELTE_KIT_TSCONFIG.exists()
|
||||
|
||||
|
||||
def _run_svelte_kit_sync(npm_executable: str) -> None:
|
||||
"""Generate .svelte-kit (notably its tsconfig.json carrying the $lib path aliases), failing loudly on error.
|
||||
|
||||
The fixture's own ``prepare`` script masks sync failures (``svelte-kit sync || echo ''``); a missing
|
||||
``.svelte-kit/tsconfig.json`` leaves the fixture tsconfig's ``extends`` dangling, tsserver silently loses
|
||||
the ``$lib`` path aliases, and cross-file tests fail with partial results that look like LS flakes.
|
||||
"""
|
||||
sync = subprocess.run(
|
||||
[npm_executable, "exec", "--", "svelte-kit", "sync"],
|
||||
cwd=str(repo_path),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
if sync.returncode != 0 or not SVELTE_KIT_TSCONFIG.exists():
|
||||
pytest.fail(
|
||||
f"svelte-kit sync failed (rc={sync.returncode}) or did not produce {SVELTE_KIT_TSCONFIG}; "
|
||||
"without it the $lib path aliases do not resolve and cross-file svelte tests fail with partial results.\n"
|
||||
"Known cause: npm silently skipping platform-specific optional dependencies (npm/cli#4828), which "
|
||||
"leaves rolldown without its native binding; remedy: remove node_modules and reinstall.\n"
|
||||
f"stdout:\n{sync.stdout}\nstderr:\n{sync.stderr}"
|
||||
)
|
||||
log.info("svelte-kit sync succeeded; %s is present", SVELTE_KIT_TSCONFIG)
|
||||
|
||||
|
||||
@pytest.fixture(scope="session", autouse=True)
|
||||
def _install_svelte_test_repo_node_modules() -> None:
|
||||
"""Populate the Svelte fixture's project dependencies via npm."""
|
||||
if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists():
|
||||
log.info("Svelte test repo node_modules already populated; skipping npm install")
|
||||
"""Populate the Svelte fixture's project dependencies via npm and generate .svelte-kit."""
|
||||
if _fixture_ready():
|
||||
log.info("Svelte test repo node_modules and .svelte-kit already populated; skipping npm install")
|
||||
return
|
||||
|
||||
npm_executable = shutil.which("npm.cmd") or shutil.which("npm")
|
||||
if npm_executable is None:
|
||||
pytest.skip("npm is not available; cannot install Svelte test repo dependencies")
|
||||
|
||||
if not PACKAGE_LOCK.exists():
|
||||
pytest.fail(f"Svelte fixture lockfile is missing: {PACKAGE_LOCK}. Regenerate it before running npm ci.")
|
||||
|
||||
with FileLock(str(INSTALL_LOCK)):
|
||||
if SVELTE_MARKER.exists() and SVELTE_KIT_ADAPTER_MARKER.exists():
|
||||
log.info("Svelte test repo node_modules populated by another worker; skipping npm install")
|
||||
if _fixture_ready():
|
||||
log.info("Svelte test repo dependencies populated by another worker; skipping npm install")
|
||||
return
|
||||
|
||||
log.warning("Installing npm dependencies into the Svelte test repo at %s.", repo_path)
|
||||
proc = subprocess.run(
|
||||
[npm_executable, "install"],
|
||||
cwd=str(repo_path),
|
||||
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_path} (rc={proc.returncode}); see logs for details")
|
||||
|
||||
if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists():
|
||||
pytest.skip("npm install completed but required Svelte fixture packages are missing")
|
||||
log.warning("Installing npm dependencies into the Svelte test repo at %s with npm ci.", repo_path)
|
||||
proc = subprocess.run(
|
||||
[npm_executable, "ci"],
|
||||
cwd=str(repo_path),
|
||||
capture_output=True,
|
||||
text=True,
|
||||
check=False,
|
||||
env=os.environ.copy(),
|
||||
)
|
||||
if proc.returncode != 0:
|
||||
log.error("npm ci failed (rc=%s).\nstdout:\n%s\nstderr:\n%s", proc.returncode, proc.stdout, proc.stderr)
|
||||
pytest.skip(f"npm ci failed in {repo_path} (rc={proc.returncode}); see logs for details")
|
||||
|
||||
log.info("Svelte test repo node_modules installed successfully")
|
||||
if not SVELTE_MARKER.exists() or not SVELTE_KIT_ADAPTER_MARKER.exists():
|
||||
pytest.skip("npm ci completed but required Svelte fixture packages are missing")
|
||||
|
||||
log.info("Svelte test repo node_modules installed successfully")
|
||||
|
||||
_run_svelte_kit_sync(npm_executable)
|
||||
@@ -76,9 +76,10 @@ class TestSvelteLanguageServer:
|
||||
coords = find_text_coordinates(read_repo_file(language_server, file_path), r"(count)")
|
||||
|
||||
definitions = language_server.request_definition(file_path, coords.line, coords.col)
|
||||
definition_paths = sorted(definition["relativePath"].replace("\\", "/") for definition in definitions)
|
||||
|
||||
assert len(definitions) == 1, definitions
|
||||
assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte"
|
||||
assert len(definitions) == 1, definition_paths
|
||||
assert definitions[0]["relativePath"].replace("\\", "/") == "src/lib/components/Counter.svelte", definition_paths
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True)
|
||||
def test_diagnostics_in_typescript_file(self, language_server: SolidLanguageServer) -> None:
|
||||
|
||||
@@ -14,13 +14,13 @@ class TestSvelteReferences:
|
||||
refs = language_server.request_references(os.path.join("src", "lib", "components", "Words.svelte"), 1, 17)
|
||||
ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs}
|
||||
|
||||
assert "src/routes/(sverdle)/words.server.ts" in ref_paths
|
||||
assert "src/lib/game.ts" in ref_paths
|
||||
assert "src/routes/(sverdle)/+page.svelte" in ref_paths
|
||||
assert "src/routes/(sverdle)/words.server.ts" in ref_paths, sorted(ref_paths)
|
||||
assert "src/lib/game.ts" in ref_paths, sorted(ref_paths)
|
||||
assert "src/routes/(sverdle)/+page.svelte" in ref_paths, sorted(ref_paths)
|
||||
|
||||
@pytest.mark.parametrize("language_server", [Language.SVELTE], indirect=True)
|
||||
def test_references_from_typescript_file(self, language_server: SolidLanguageServer) -> None:
|
||||
refs = language_server.request_references(os.path.join("src", "lib", "game.ts"), 3, 13)
|
||||
ref_paths = {ref["relativePath"].replace("\\", "/") for ref in refs}
|
||||
|
||||
assert "src/routes/(sverdle)/+page.server.ts" in ref_paths
|
||||
assert "src/routes/(sverdle)/+page.server.ts" in ref_paths, sorted(ref_paths)
|
||||
@@ -0,0 +1,269 @@
|
||||
"""Regression tests for the TypeScript/Svelte timeout policy split.
|
||||
|
||||
The base TypeScript server keeps its historical permissive behavior on readiness and
|
||||
indexing timeouts (log and proceed), while the Svelte companion TS server is strict
|
||||
(raise instead of serving requests from a cold or partially indexed program). These
|
||||
tests pin that policy and the settings plumbing directly, without spawning language
|
||||
server processes, so a future refactor cannot silently revert either side.
|
||||
"""
|
||||
|
||||
import threading
|
||||
import time
|
||||
|
||||
import pytest
|
||||
|
||||
from solidlsp.language_servers.svelte_language_server import (
|
||||
SvelteCompanionPreparationError,
|
||||
SvelteLanguageServer,
|
||||
SvelteTypeScriptServer,
|
||||
)
|
||||
from solidlsp.language_servers.typescript_language_server import TypeScriptLanguageServer
|
||||
from solidlsp.settings import SolidLSPSettings
|
||||
|
||||
|
||||
def _bare_ts_server(cls: type[TypeScriptLanguageServer], custom_settings: dict | None = None) -> TypeScriptLanguageServer:
|
||||
"""Create an instance without running __init__ (no process, no repo scan), setting only the
|
||||
state the timeout machinery touches; same technique as test_rename_didopen.py.
|
||||
"""
|
||||
server = object.__new__(cls)
|
||||
server.server_ready = threading.Event()
|
||||
server._progress_lock = threading.Lock()
|
||||
server._active_progress_tokens = set()
|
||||
server._indexing_complete = threading.Event()
|
||||
server._indexing_complete.set() # mirrors __init__: initially no active work
|
||||
server._custom_settings = SolidLSPSettings.CustomLSSettings(custom_settings)
|
||||
return server
|
||||
|
||||
|
||||
class TestBaseTypeScriptTimeoutPolicy:
|
||||
"""The base server must stay permissive: plain TypeScript setups are unchanged."""
|
||||
|
||||
def test_server_ready_timeout_proceeds(self) -> None:
|
||||
server = _bare_ts_server(TypeScriptLanguageServer)
|
||||
assert not server.server_ready.is_set()
|
||||
|
||||
server._handle_server_ready_timeout(10.0) # must not raise
|
||||
|
||||
assert server.server_ready.is_set()
|
||||
|
||||
def test_indexing_timeout_proceeds(self) -> None:
|
||||
server = _bare_ts_server(TypeScriptLanguageServer)
|
||||
server.expect_indexing()
|
||||
server._active_progress_tokens.add("indexing-1")
|
||||
|
||||
server._handle_project_indexing_timeout(30.0) # must not raise
|
||||
|
||||
def test_timeout_defaults(self) -> None:
|
||||
server = _bare_ts_server(TypeScriptLanguageServer)
|
||||
assert server._get_server_ready_timeout() == 10.0
|
||||
assert server._get_indexing_timeout() == 30.0
|
||||
|
||||
def test_timeouts_configurable_via_ls_specific_settings(self) -> None:
|
||||
server = _bare_ts_server(TypeScriptLanguageServer, {"server_ready_timeout": 1.5, "indexing_timeout": 2.5})
|
||||
assert server._get_server_ready_timeout() == 1.5
|
||||
assert server._get_indexing_timeout() == 2.5
|
||||
|
||||
|
||||
class TestSvelteCompanionTimeoutPolicy:
|
||||
"""The Svelte companion must be strict: raise instead of serving from a cold/partial program."""
|
||||
|
||||
def test_server_ready_timeout_raises(self) -> None:
|
||||
server = _bare_ts_server(SvelteTypeScriptServer)
|
||||
|
||||
with pytest.raises(TimeoutError, match="did not become ready within 30s"):
|
||||
server._handle_server_ready_timeout(30.0)
|
||||
|
||||
assert not server.server_ready.is_set() # a strict server must not fake readiness
|
||||
|
||||
def test_indexing_timeout_raises_with_diagnostic_state(self) -> None:
|
||||
server = _bare_ts_server(SvelteTypeScriptServer)
|
||||
server.expect_indexing()
|
||||
server._active_progress_tokens.add("initializing-js-ts-features")
|
||||
|
||||
with pytest.raises(TimeoutError) as exc_info:
|
||||
server._handle_project_indexing_timeout(120.0)
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "did not complete within 120s" in message
|
||||
assert "complete=False" in message
|
||||
assert "initializing-js-ts-features" in message
|
||||
|
||||
def test_companion_timeout_defaults_are_raised(self) -> None:
|
||||
server = _bare_ts_server(SvelteTypeScriptServer)
|
||||
assert server._get_server_ready_timeout() == 30.0
|
||||
assert server._get_indexing_timeout() == 120.0
|
||||
|
||||
|
||||
class TestWaitForIndexingStartOrCompletion:
|
||||
def test_returns_false_when_active_progress_never_completes(self) -> None:
|
||||
server = _bare_ts_server(TypeScriptLanguageServer)
|
||||
server.expect_indexing()
|
||||
server._active_progress_tokens.add("stuck-progress")
|
||||
|
||||
assert server._wait_for_indexing_start_or_completion(timeout=0.1, start_grace=0.0) is False
|
||||
|
||||
def test_treats_absent_progress_as_ready(self) -> None:
|
||||
server = _bare_ts_server(TypeScriptLanguageServer)
|
||||
server.expect_indexing()
|
||||
|
||||
assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True
|
||||
assert server._indexing_complete.is_set()
|
||||
|
||||
def test_returns_immediately_when_no_indexing_expected(self) -> None:
|
||||
# _indexing_complete is initially set; a long grace must not block in that case
|
||||
server = _bare_ts_server(TypeScriptLanguageServer)
|
||||
|
||||
start = time.monotonic()
|
||||
assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=30.0) is True
|
||||
# must return via the early is_set() check, not sit out the 30s grace; the path is
|
||||
# pure in-memory, so a 10s bound cannot flake on a slow runner
|
||||
assert time.monotonic() - start < 10.0
|
||||
|
||||
def test_returns_true_when_progress_completes(self) -> None:
|
||||
server = _bare_ts_server(TypeScriptLanguageServer)
|
||||
server.expect_indexing()
|
||||
server._active_progress_tokens.add("indexing-1")
|
||||
|
||||
def finish_indexing() -> None:
|
||||
with server._progress_lock:
|
||||
server._active_progress_tokens.clear()
|
||||
server._indexing_complete.set()
|
||||
|
||||
timer = threading.Timer(0.05, finish_indexing)
|
||||
timer.start()
|
||||
try:
|
||||
# both timer orderings pass: an early fire takes the absent-progress branch,
|
||||
# which sets the event itself, while a late fire wakes Event.wait; ordering
|
||||
# cannot flake this test. It fails only if the spawned thread does not run
|
||||
# within the 30s ceiling, which means the runner is hung.
|
||||
assert server._wait_for_indexing_start_or_completion(timeout=30.0, start_grace=0.0) is True
|
||||
finally:
|
||||
timer.cancel()
|
||||
|
||||
|
||||
class _FakeSolidLSPSettings:
|
||||
"""Minimal settings stand-in: returns a fixed TypeScript indexing_timeout so the guard tests can
|
||||
prove the svelte-key value takes precedence over the typescript-key value.
|
||||
"""
|
||||
|
||||
def __init__(self, ts_indexing_timeout: float) -> None:
|
||||
self._ts_indexing_timeout = ts_indexing_timeout
|
||||
|
||||
def get_ls_specific_settings(self, language: object) -> "SolidLSPSettings.CustomLSSettings":
|
||||
return SolidLSPSettings.CustomLSSettings({"indexing_timeout": self._ts_indexing_timeout})
|
||||
|
||||
|
||||
class _FakeCompanionTSServer:
|
||||
"""Hand-written stand-in for the companion TS server that records calls, so the guard tests assert
|
||||
observable behavior (raised errors, message shape, which timeout is used) instead of mock interactions.
|
||||
"""
|
||||
|
||||
class _FileBuffer:
|
||||
def __init__(self, uri: str) -> None:
|
||||
self.uri = uri
|
||||
self.ref_count = 0
|
||||
|
||||
def __enter__(self) -> "_FakeCompanionTSServer._FileBuffer":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc: object) -> bool:
|
||||
return False
|
||||
|
||||
def __init__(self, *, wait_result: bool = True, open_error: Exception | None = None) -> None:
|
||||
self.calls: list[str] = []
|
||||
self.wait_timeout: float | None = None
|
||||
self._wait_result = wait_result
|
||||
self._open_error = open_error
|
||||
|
||||
def expect_indexing(self) -> None:
|
||||
self.calls.append("expect_indexing")
|
||||
|
||||
def open_file(self, relative_path: str) -> "_FakeCompanionTSServer._FileBuffer":
|
||||
self.calls.append("open_file")
|
||||
if self._open_error is not None:
|
||||
raise self._open_error
|
||||
return self._FileBuffer(uri=f"file:///{relative_path}")
|
||||
|
||||
def _wait_for_indexing_start_or_completion(self, timeout: float) -> bool:
|
||||
self.calls.append("wait")
|
||||
self.wait_timeout = timeout
|
||||
return self._wait_result
|
||||
|
||||
def describe_indexing_state(self) -> str:
|
||||
return "complete=False, active_progress_tokens=stuck-progress"
|
||||
|
||||
|
||||
class TestSvelteLanguageServerCompanionGuard:
|
||||
"""SvelteLanguageServer._ensure_svelte_files_indexed_on_ts_server must fail loudly, not degrade."""
|
||||
|
||||
def _bare_svelte_ls(self, ts_server: _FakeCompanionTSServer, repo_path: str) -> SvelteLanguageServer:
|
||||
ls = object.__new__(SvelteLanguageServer)
|
||||
ls.repo_path = repo_path
|
||||
ls._svelte_files_indexed = False
|
||||
ls._indexed_svelte_file_uris = []
|
||||
ls._custom_settings = SolidLSPSettings.CustomLSSettings({"indexing_timeout": 0.01})
|
||||
# conflicting typescript-key value: the timeout assertions below prove the svelte key wins
|
||||
ls._solidlsp_settings = _FakeSolidLSPSettings(ts_indexing_timeout=999.0)
|
||||
ls._ts_server = ts_server
|
||||
return ls
|
||||
|
||||
def test_raises_when_companion_indexing_times_out(self, tmp_path) -> None:
|
||||
(tmp_path / "App.svelte").touch()
|
||||
ts_server = _FakeCompanionTSServer(wait_result=False)
|
||||
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
|
||||
|
||||
with pytest.raises(TimeoutError, match="did not finish indexing 1 .svelte files"):
|
||||
ls._ensure_svelte_files_indexed_on_ts_server()
|
||||
|
||||
# the svelte-key indexing_timeout (0.01), not the typescript-key (999.0), must reach the wait
|
||||
assert ts_server.wait_timeout == 0.01
|
||||
|
||||
def test_succeeds_and_arms_progress_tracking_before_opening_files(self, tmp_path) -> None:
|
||||
(tmp_path / "App.svelte").touch()
|
||||
(tmp_path / "node_modules").mkdir()
|
||||
(tmp_path / "node_modules" / "skip.svelte").touch()
|
||||
(tmp_path / ".hidden").mkdir()
|
||||
(tmp_path / ".hidden" / "skip.svelte").touch()
|
||||
ts_server = _FakeCompanionTSServer(wait_result=True)
|
||||
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
|
||||
|
||||
ls._ensure_svelte_files_indexed_on_ts_server()
|
||||
|
||||
assert ts_server.calls.count("open_file") == 1
|
||||
# a second call is a no-op — the observable consequence of the indexed flag
|
||||
calls_after_indexing = list(ts_server.calls)
|
||||
ls._ensure_svelte_files_indexed_on_ts_server()
|
||||
assert ts_server.calls == calls_after_indexing
|
||||
# expect_indexing must be armed BEFORE any file is opened, or early progress is lost to a race
|
||||
assert ts_server.calls[0] == "expect_indexing"
|
||||
assert ts_server.calls.index("expect_indexing") < ts_server.calls.index("open_file")
|
||||
|
||||
def test_collects_failed_opens_and_raises_preparation_error(self, tmp_path) -> None:
|
||||
(tmp_path / "B.svelte").touch()
|
||||
(tmp_path / "A.svelte").touch()
|
||||
ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed"))
|
||||
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
|
||||
|
||||
with pytest.raises(SvelteCompanionPreparationError, match="2 Svelte file") as exc_info:
|
||||
ls._ensure_svelte_files_indexed_on_ts_server()
|
||||
|
||||
assert "A.svelte, B.svelte" in str(exc_info.value)
|
||||
# the first underlying error must be chained so the cause survives without DEBUG logs
|
||||
assert isinstance(exc_info.value.__cause__, RuntimeError)
|
||||
# a failed preparation must short-circuit before the indexing wait
|
||||
assert "wait" not in ts_server.calls
|
||||
|
||||
def test_preparation_error_caps_file_listing(self, tmp_path) -> None:
|
||||
for i in reversed(range(15)):
|
||||
(tmp_path / f"{i:02d}.svelte").touch()
|
||||
ts_server = _FakeCompanionTSServer(open_error=RuntimeError("didOpen failed"))
|
||||
ls = self._bare_svelte_ls(ts_server, str(tmp_path))
|
||||
|
||||
with pytest.raises(SvelteCompanionPreparationError) as exc_info:
|
||||
ls._ensure_svelte_files_indexed_on_ts_server()
|
||||
|
||||
message = str(exc_info.value)
|
||||
assert "15 Svelte file(s)" in message
|
||||
assert "09.svelte" in message # the 10th entry of the sorted listing is still shown
|
||||
assert "10.svelte" not in message # the 11th is capped
|
||||
assert "and 5 more" in message
|
||||
Reference in new issue
Block a user