"""Tests for follow-up fixes to the LSP integration (PR after #24168). Covers: 1. ``typescript-language-server`` install recipe pulls in ``typescript`` alongside the server, so the npm install command targets both. 2. ``hermes lsp status`` surfaces a ``Backend warnings`` section when bash-language-server is installed but ``shellcheck`` is missing. 3. ``_check_lint`` returns ``skipped`` (not ``error``) when the linter command exists on PATH but couldn't actually run — e.g. ``npx tsc`` without the typescript SDK installed. This is what unblocks the LSP semantic tier on TypeScript files when the user doesn't also have a project-level ``tsc``. """ from __future__ import annotations import io from contextlib import redirect_stdout from unittest.mock import MagicMock, patch import pytest from agent.lsp.install import INSTALL_RECIPES # --------------------------------------------------------------------------- # Fix 1: typescript install recipe carries the typescript SDK # --------------------------------------------------------------------------- def test_typescript_recipe_includes_typescript_sdk(): recipe = INSTALL_RECIPES["typescript-language-server"] extras = recipe.get("extra_pkgs") or [] assert "typescript" in extras, ( "typescript-language-server requires the `typescript` SDK as a " "sibling install — without it `initialize` fails with " "'Could not find a valid TypeScript installation'." ) def test_install_npm_passes_extras_to_npm_command(tmp_path, monkeypatch): """Verify the npm subprocess is invoked with both pkg AND extras.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) captured = {} def fake_run(cmd, **kwargs): captured["cmd"] = cmd # Pretend npm succeeded but binary doesn't exist — install code # will return None, which is fine for this test. return MagicMock(returncode=0, stderr="") from agent.lsp import install as install_mod monkeypatch.setattr(install_mod.subprocess, "run", fake_run) monkeypatch.setattr(install_mod.shutil, "which", lambda c: "/usr/bin/npm" if c == "npm" else None) install_mod._install_npm("typescript-language-server", "typescript-language-server", extra_pkgs=["typescript"]) cmd = captured["cmd"] assert "typescript-language-server" in cmd assert "typescript" in cmd # Both must come AFTER the npm flags, in install-target position install_idx = cmd.index("install") assert cmd.index("typescript-language-server") > install_idx assert cmd.index("typescript") > install_idx def test_install_npm_works_without_extras(tmp_path, monkeypatch): """Backwards compat: pyright-style recipes (no extras) still install.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) captured = {} def fake_run(cmd, **kwargs): captured["cmd"] = cmd return MagicMock(returncode=0, stderr="") from agent.lsp import install as install_mod monkeypatch.setattr(install_mod.subprocess, "run", fake_run) monkeypatch.setattr(install_mod.shutil, "which", lambda c: "/usr/bin/npm" if c == "npm" else None) install_mod._install_npm("pyright", "pyright-langserver") cmd = captured["cmd"] assert "pyright" in cmd # Should not blow up when extra_pkgs is omitted/None install_targets = [c for c in cmd if not c.startswith("-") and c not in ( "install", "--prefix", str(install_mod.hermes_lsp_bin_dir().parent), "/usr/bin/npm", )] assert install_targets == ["pyright"] # --------------------------------------------------------------------------- # Fix 2: ``hermes lsp status`` surfaces shellcheck-missing for bash # --------------------------------------------------------------------------- def test_backend_warnings_quiet_when_bash_not_installed(tmp_path, monkeypatch): """No bash → no warning.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) from agent.lsp import cli as lsp_cli with patch("shutil.which", return_value=None): notes = lsp_cli._backend_warnings() assert notes == [] def test_backend_warnings_quiet_when_bash_and_shellcheck_both_present(tmp_path, monkeypatch): """Both installed → no warning.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) from agent.lsp import cli as lsp_cli def which(name): return f"/usr/bin/{name}" # both found with patch("shutil.which", side_effect=which): notes = lsp_cli._backend_warnings() assert notes == [] def test_backend_warnings_fires_when_bash_installed_but_shellcheck_missing(tmp_path, monkeypatch): """The exact scenario from the bug report.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) from agent.lsp import cli as lsp_cli def which(name): if name == "bash-language-server": return "/fake/bin/bash-language-server" return None # shellcheck missing with patch("shutil.which", side_effect=which): notes = lsp_cli._backend_warnings() assert len(notes) == 1 assert "shellcheck" in notes[0].lower() assert "bash-language-server" in notes[0].lower() def test_status_output_includes_backend_warnings_section(tmp_path, monkeypatch): """End-to-end: status command output includes the warning section.""" monkeypatch.setenv("HERMES_HOME", str(tmp_path)) # Pretend bash-language-server is installed but shellcheck is missing def which(name): if name == "bash-language-server": return "/fake/bin/bash-language-server" return None from agent.lsp import cli as lsp_cli buf = io.StringIO() with patch("shutil.which", side_effect=which), redirect_stdout(buf): lsp_cli._cmd_status(emit_json=False) output = buf.getvalue() assert "Backend warnings" in output assert "shellcheck" in output # --------------------------------------------------------------------------- # Fix 3: tier-1 lint treats unusable linters as ``skipped``, not ``error`` # --------------------------------------------------------------------------- def test_npx_tsc_missing_treated_as_skipped(): """The original bug: ``npx tsc`` errors when tsc isn't installed. Without this fix, the lint result is ``error``, which means the LSP semantic tier (gated on ``success or skipped``) is skipped — the user gets a useless tooling-error message instead of real diagnostics. """ from tools.file_operations import _looks_like_linter_unusable npx_failure_output = ( " \n" " This is not the tsc command you are looking for \n" " \n" "\n" "To get access to the TypeScript compiler, tsc, from the command line either:\n" "- Use npm install typescript to first add TypeScript to your project before using npx\n" ) assert _looks_like_linter_unusable("npx", npx_failure_output) is True def test_real_lint_error_not_classified_as_unusable(): """A genuine TypeScript type error must NOT be misclassified.""" from tools.file_operations import _looks_like_linter_unusable real_error = ( "bad.ts:5:1 - error TS2322: Type 'number' is not assignable to type 'string'.\n" "5 const x: string = greet(42);\n" " ~~~~~~~~~~~~~~~\n" ) assert _looks_like_linter_unusable("npx", real_error) is False def test_unknown_base_cmd_returns_false(): """Unfamiliar linters fall through and use the normal error path.""" from tools.file_operations import _looks_like_linter_unusable assert _looks_like_linter_unusable("eslint", "any output") is False assert _looks_like_linter_unusable("", "anything") is False def test_check_lint_returns_skipped_when_npx_tsc_unusable(tmp_path): """Integration: _check_lint sees npx exit non-zero with the npx banner and returns a ``skipped`` LintResult so LSP can still run.""" from tools.environments.local import LocalEnvironment from tools.file_operations import ShellFileOperations ts_file = tmp_path / "bad.ts" ts_file.write_text("const x: string = 42;\n") env = LocalEnvironment() fops = ShellFileOperations(env) # Patch _exec to simulate ``npx tsc`` failing because tsc is missing. npx_banner = ( " \n" " This is not the tsc command you are looking for \n" ) def fake_exec(cmd, **kwargs): result = MagicMock() result.exit_code = 1 result.stdout = npx_banner return result with patch.object(fops, "_exec", side_effect=fake_exec), \ patch.object(fops, "_has_command", return_value=True): lint = fops._check_lint(str(ts_file)) assert lint.skipped is True, ( f"expected skipped (so LSP runs); got success={lint.success}, " f"output={lint.output!r}" ) assert "not usable" in (lint.message or "") def test_check_lint_returns_error_for_real_ts_type_errors(tmp_path): """Sanity: real TypeScript errors still go through the error path.""" from tools.environments.local import LocalEnvironment from tools.file_operations import ShellFileOperations ts_file = tmp_path / "bad.ts" ts_file.write_text("const x: string = 42;\n") env = LocalEnvironment() fops = ShellFileOperations(env) real_tsc_error = ( "bad.ts:1:7 - error TS2322: Type 'number' is not assignable to type 'string'.\n" "1 const x: string = 42;\n" " ~\n" "Found 1 error.\n" ) def fake_exec(cmd, **kwargs): result = MagicMock() result.exit_code = 1 result.stdout = real_tsc_error return result with patch.object(fops, "_exec", side_effect=fake_exec), \ patch.object(fops, "_has_command", return_value=True): lint = fops._check_lint(str(ts_file)) assert lint.skipped is False assert lint.success is False assert "TS2322" in lint.output if __name__ == "__main__": # pragma: no cover pytest.main([__file__, "-v"])