Add unit tests, smoke test script, fix CI to debian-host + test job
- tests/test_catalog.py: 5 unit tests for PypiCatalog (fetch, cache, sort prefix, completions) - tests/test_proxy.py: 5 unit tests for BicepProxy (framing, injection, list result, passthrough) - tests/conftest.py: pytest asyncio_mode=auto config - scripts/smoke_test.sh: end-to-end TCP + health smoke test script - .gitea/workflows/ci.yml: split into test + build-and-deploy jobs (test blocks deploy) - runs-on: debian-host (was ubuntu-latest = broken) - test job installs deps + runs pytest before building image - pyproject.toml: [project.optional-dependencies] dev = pytest + pytest-asyncio
This commit is contained in:
@@ -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"
|
||||
|
||||
@@ -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"]
|
||||
|
||||
80
scripts/smoke_test.sh
Executable file
80
scripts/smoke_test.sh
Executable file
@@ -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
|
||||
5
tests/conftest.py
Normal file
5
tests/conftest.py
Normal file
@@ -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")
|
||||
105
tests/test_catalog.py
Normal file
105
tests/test_catalog.py
Normal file
@@ -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 = """
|
||||
<!DOCTYPE html>
|
||||
<html><head><title>Simple Index</title></head>
|
||||
<body>
|
||||
<a href="/simple/azure-toolbox-database/">azure-toolbox-database</a>
|
||||
<a href="/simple/devops-gitea-sdk/">devops-gitea-sdk</a>
|
||||
<a href="/simple/toolbox-tests-framework/">toolbox-tests-framework</a>
|
||||
</body></html>
|
||||
"""
|
||||
|
||||
|
||||
@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"
|
||||
85
tests/test_proxy.py
Normal file
85
tests/test_proxy.py
Normal file
@@ -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"
|
||||
Reference in New Issue
Block a user