diff --git a/.gitea/workflows/ci.yml b/.gitea/workflows/ci.yml index 9388ec4..27432c9 100644 --- a/.gitea/workflows/ci.yml +++ b/.gitea/workflows/ci.yml @@ -1,64 +1,75 @@ -name: CI +name: Build and Deploy iLSP on: push: branches: [main] - pull_request: - branches: [main] + workflow_dispatch: env: - REGISTRY: registry.i80.dk - IMAGE: registry.i80.dk/gitea/ilsp + SERVICE_NAME: ilsp jobs: - build-and-push: - runs-on: ubuntu-latest + test: + runs-on: debian-host + + env: + PATH: /usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/bin:/snap/bin steps: - - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Set image tag - id: tag - run: echo "tag=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" + - name: Install Python deps + run: pip3 install --break-system-packages "python-lsp-server[all]" lsprotocol pytest pytest-asyncio aiohttp 2>&1 | tail -5 + + - name: Run unit tests + run: python3 -m pytest tests/ -v + + build-and-deploy: + runs-on: debian-host + needs: test + + env: + PATH: /usr/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/sbin:/bin:/snap/bin + DOCKER_HOST: unix:///var/run/docker.sock + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: System info + run: uname -a && whoami && docker version --format '{{.Server.Version}}' - name: Log in to registry - uses: docker/login-action@v3 - with: - registry: ${{ env.REGISTRY }} - username: ${{ secrets.REGISTRY_USER }} - password: ${{ secrets.REGISTRY_TOKEN }} + run: | + echo "${{ secrets.HARBOR_ROBOT_TOKEN }}" | \ + docker login registry.i80.dk -u "robot\$gitserver" --password-stdin - - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: ${{ github.ref == 'refs/heads/main' }} - tags: | - ${{ env.IMAGE }}:${{ steps.tag.outputs.tag }} - ${{ env.IMAGE }}:latest - cache-from: type=registry,ref=${{ env.IMAGE }}:latest - cache-to: type=inline + - name: Build Docker image + run: | + SHA=$(git rev-parse --short HEAD) + docker build \ + -t registry.i80.dk/gitea/ilsp:latest \ + -t registry.i80.dk/gitea/ilsp:$SHA . - deploy: - runs-on: ubuntu-latest - needs: build-and-push - if: github.ref == 'refs/heads/main' - - steps: - - uses: actions/checkout@v4 - - - name: Set image tag - id: tag - run: echo "tag=${GITHUB_SHA::8}" >> "$GITHUB_OUTPUT" + - name: Push Docker image + run: | + SHA=$(git rev-parse --short HEAD) + docker push registry.i80.dk/gitea/ilsp:latest + docker push registry.i80.dk/gitea/ilsp:$SHA - name: Deploy to Nomad run: | - scp ilsp.nomad ${{ secrets.DEPLOY_HOST }}:/tmp/ilsp.nomad - ssh ${{ secrets.DEPLOY_HOST }} \ - "NOMAD_ADDR=https://nomad.i80.dk:4646 nomad job run \ - -var='image_tag=${{ steps.tag.outputs.tag }}' /tmp/ilsp.nomad" + nomad job validate ${SERVICE_NAME}.nomad + nomad job run ${SERVICE_NAME}.nomad + env: + NOMAD_ADDR: "https://nomad.i80.dk:4646" - - name: Health check + - name: Wait and verify run: | + echo "Waiting for service to come up..." sleep 20 - curl -sf https://ilsp.i80.dk/health | jq . + nomad job status ${SERVICE_NAME} + curl -sf http://autobox.i80.dk:2089/health | python3 -m json.tool || true + env: + NOMAD_ADDR: "https://nomad.i80.dk:4646" diff --git a/pyproject.toml b/pyproject.toml index 2c999ff..8e90874 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -23,3 +23,13 @@ ilsp = "ilsp.server:main" [tool.setuptools.packages.find] where = ["."] include = ["ilsp*"] + +[project.optional-dependencies] +dev = [ + "pytest>=8.0", + "pytest-asyncio>=0.23", +] + +[tool.pytest.ini_options] +asyncio_mode = "auto" +testpaths = ["tests"] diff --git a/scripts/smoke_test.sh b/scripts/smoke_test.sh new file mode 100755 index 0000000..391557d --- /dev/null +++ b/scripts/smoke_test.sh @@ -0,0 +1,80 @@ +#!/usr/bin/env bash +# End-to-end smoke test for a running iLSP instance. +# Usage: +# ./scripts/smoke_test.sh [host] # default: localhost +# ./scripts/smoke_test.sh autobox.i80.dk +set -euo pipefail + +HOST="${1:-localhost}" +PYTHON_PORT="${PYTHON_LSP_PORT:-2087}" +BICEP_PORT="${BICEP_LSP_PORT:-2088}" +HEALTH_PORT="${HEALTH_PORT:-2089}" + +PASS=0 +FAIL=0 + +ok() { echo " ✓ $*"; ((PASS++)); } +fail() { echo " ✗ $*"; ((FAIL++)); } + +# ── Helper: send LSP initialize and read response ───────────────────────────── + +send_lsp_init() { + local port="$1" + local body='{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"processId":null,"rootUri":null,"capabilities":{}}}' + local len=${#body} + printf "Content-Length: %d\r\n\r\n%s" "$len" "$body" +} + +lsp_check() { + local name="$1" + local port="$2" + local response + response=$(send_lsp_init "$port" | nc -w 3 "$HOST" "$port" 2>/dev/null || true) + if echo "$response" | grep -q '"result"'; then + ok "$name LSP responded to initialize (port $port)" + else + fail "$name LSP did not respond (port $port) — is it running?" + fi +} + +# ── Tests ───────────────────────────────────────────────────────────────────── + +echo "" +echo "iLSP smoke test — $HOST" +echo "════════════════════════════════" + +# 1. Health endpoint +echo "" +echo "Health check (HTTP :$HEALTH_PORT)" +health=$(curl -sf "http://$HOST:$HEALTH_PORT/health" 2>/dev/null || true) +if echo "$health" | grep -q '"status": *"ok"'; then + ok "Health endpoint returned ok" + pypi=$(echo "$health" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('pypi_packages',0))" 2>/dev/null || echo "?") + bicep=$(echo "$health" | python3 -c "import sys,json; d=json.load(sys.stdin); print(d.get('bicep_modules',0))" 2>/dev/null || echo "?") + ok "Catalogs loaded: $pypi pypi packages, $bicep bicep modules" +else + fail "Health endpoint not reachable at http://$HOST:$HEALTH_PORT/health" +fi + +# 2. Python LSP +echo "" +echo "Python LSP (TCP :$PYTHON_PORT)" +if command -v nc &>/dev/null; then + lsp_check "Python" "$PYTHON_PORT" +else + echo " ⚠ nc not found — skipping TCP tests" +fi + +# 3. Bicep LSP +echo "" +echo "Bicep LSP (TCP :$BICEP_PORT)" +if command -v nc &>/dev/null; then + lsp_check "Bicep" "$BICEP_PORT" +fi + +# ── Summary ─────────────────────────────────────────────────────────────────── + +echo "" +echo "════════════════════════════════" +echo "Results: $PASS passed, $FAIL failed" +[ "$FAIL" -eq 0 ] && exit 0 || exit 1 diff --git a/tests/conftest.py b/tests/conftest.py new file mode 100644 index 0000000..121ef87 --- /dev/null +++ b/tests/conftest.py @@ -0,0 +1,5 @@ +import pytest + +# Enable asyncio mode for all tests +def pytest_configure(config): + config.addinivalue_line("markers", "asyncio: mark test as async") diff --git a/tests/test_catalog.py b/tests/test_catalog.py new file mode 100644 index 0000000..c953cbb --- /dev/null +++ b/tests/test_catalog.py @@ -0,0 +1,105 @@ +"""Unit tests for the pypi-server.i80.dk catalog fetcher.""" + +import asyncio +from unittest.mock import patch + +import pytest + +from ilsp.python_lsp.catalog import PypiCatalog + + +MOCK_HTML = """ + +Simple Index + +azure-toolbox-database +devops-gitea-sdk +toolbox-tests-framework + +""" + + +@pytest.fixture(autouse=True) +def reset_catalog(): + PypiCatalog._packages = [] + PypiCatalog._last_refresh = 0 + yield + + +class _MockResponse: + def __init__(self, html: str): + self._html = html + self.status = 200 + + async def text(self): + return self._html + + def raise_for_status(self): + pass + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + +class _MockSession: + def __init__(self, html: str): + self._html = html + + def get(self, url): + return _MockResponse(self._html) + + async def __aenter__(self): + return self + + async def __aexit__(self, *_): + pass + + +@pytest.mark.asyncio +async def test_fetch_parses_packages(): + with patch("ilsp.python_lsp.catalog.aiohttp.ClientSession", return_value=_MockSession(MOCK_HTML)): + packages = await PypiCatalog._fetch() + + assert len(packages) == 3 + names = [p["name"] for p in packages] + assert "azure-toolbox-database" in names + assert "devops-gitea-sdk" in names + + +@pytest.mark.asyncio +async def test_packages_have_sort_prefix(): + with patch("ilsp.python_lsp.catalog.aiohttp.ClientSession", return_value=_MockSession(MOCK_HTML)): + packages = await PypiCatalog._fetch() + + for pkg in packages: + assert pkg["sort_prefix"] == "0_i80_" + + +@pytest.mark.asyncio +async def test_get_packages_triggers_refresh_when_empty(): + with patch("ilsp.python_lsp.catalog.aiohttp.ClientSession", return_value=_MockSession(MOCK_HTML)): + packages = await PypiCatalog.get_packages() + + assert len(packages) == 3 + + +@pytest.mark.asyncio +async def test_get_packages_uses_cache(): + PypiCatalog._packages = [{"name": "cached-pkg", "sort_prefix": "0_i80_", "detail": "x"}] + PypiCatalog._last_refresh = 9_999_999_999 + + packages = await PypiCatalog.get_packages() + assert packages[0]["name"] == "cached-pkg" + + +def test_as_completion_items(): + PypiCatalog._packages = [ + {"name": "my-pkg", "detail": "i80", "sort_prefix": "0_i80_"}, + ] + items = PypiCatalog.as_completion_items() + assert len(items) == 1 + assert items[0].label == "my-pkg" + assert items[0].sort_text == "0_i80_my-pkg" diff --git a/tests/test_proxy.py b/tests/test_proxy.py new file mode 100644 index 0000000..7c0bce5 --- /dev/null +++ b/tests/test_proxy.py @@ -0,0 +1,85 @@ +"""Unit tests for Bicep proxy message injection.""" + +import json + +import pytest + +from ilsp.bicep_lsp.modules import BicepModuleCatalog +from ilsp.bicep_lsp.proxy import BicepProxy, _ContentLengthFramer + + +def test_frame_produces_correct_header(): + body = b'{"jsonrpc":"2.0"}' + framed = _ContentLengthFramer.frame(body) + assert framed.startswith(b"Content-Length: 17\r\n\r\n") + assert framed.endswith(body) + + +@pytest.fixture(autouse=True) +def reset_modules(): + BicepModuleCatalog._modules = [] + yield + + +def _make_proxy() -> BicepProxy: + return BicepProxy.__new__(BicepProxy) + + +def _completion_response(items: list) -> dict: + return { + "jsonrpc": "2.0", + "id": 1, + "result": {"isIncomplete": False, "items": items}, + } + + +def test_standard_items_not_downgraded_without_lru(): + """Without LRU modules, standard items keep their original sortText.""" + proxy = _make_proxy() + msg = _completion_response([{"label": "Microsoft.Storage", "sortText": "az"}]) + out = json.loads(proxy._maybe_inject_completions(msg)) + # No LRU modules → no downgrade, original sortText preserved + assert out["result"]["items"][0]["sortText"] == "az" + + +def test_lru_modules_injected_at_top(): + BicepModuleCatalog._modules = [{ + "name": "appservice", + "path": "bicep/modules/appservice", + "versions": ["2.3.0", "latest"], + "latest": "latest", + "registry": "iactemplatereg.azurecr.io", + }] + + proxy = _make_proxy() + msg = _completion_response([{"label": "Microsoft.Web/sites", "sortText": "az"}]) + out = json.loads(proxy._maybe_inject_completions(msg)) + items = out["result"]["items"] + + assert items[0]["label"] == "appservice" + assert items[0]["sortText"].startswith("0_lru_") + assert items[1]["label"] == "Microsoft.Web/sites" + assert items[1]["sortText"].startswith("1_az_") + + +def test_list_result_also_handled(): + BicepModuleCatalog._modules = [{ + "name": "roleassignments", + "path": "bicep/modules/roleassignments", + "versions": ["2.0.0"], + "latest": "2.0.0", + "registry": "iactemplatereg.azurecr.io", + }] + + proxy = _make_proxy() + msg = {"jsonrpc": "2.0", "id": 2, "result": [{"label": "az-item", "sortText": "az"}]} + out = json.loads(proxy._maybe_inject_completions(msg)) + assert isinstance(out["result"], list) + assert out["result"][0]["label"] == "roleassignments" + + +def test_non_completion_message_passthrough(): + proxy = _make_proxy() + msg = {"jsonrpc": "2.0", "method": "initialized", "params": {}} + out = json.loads(proxy._maybe_inject_completions(msg)) + assert out["method"] == "initialized"