Files
iLSP/tests/test_proxy.py
Henrik Jess Nielsen 445ccb5769
All checks were successful
Build and Deploy iLSP / test (push) Successful in 21s
Build and Deploy iLSP / build-and-deploy (push) Successful in 1m24s
fix(bicep): detect param context when module ref has empty version
The lookback regex used ([^']+) requiring one or more version chars,
so 'br/modules:modules/keyvault:' (no version yet) silently fell through
to the unknown context and injected all module names instead of params.

Change + to * to allow empty version string. The param_completion_items
fallback already handles empty version by picking the closest schema.
2026-05-11 11:20:54 +02:00

439 lines
15 KiB
Python

"""Unit tests for Bicep proxy message injection."""
import json
import pytest
from ilsp.bicep_lsp.modules import BicepModuleCatalog
from ilsp.bicep_lsp.proxy import _ProxySession, _frame, _inject_completions
def test_frame_produces_correct_header():
body = b'{"jsonrpc":"2.0"}'
framed = _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 _completion_response(items: list) -> dict:
return {
"jsonrpc": "2.0",
"id": 1,
"result": {"isIncomplete": False, "items": items},
}
def _make_module(name, versions=None, schema=None):
versions = versions or ["1.0.0", "latest"]
path = f"bicep/modules/{name}"
return {
"name": name,
"path": path,
"ref_path": f"modules/{name}", # what appears after 'br/modules:' in bicep files
"versions": versions,
"latest": versions[-1],
"registry": "iactemplatereg.azurecr.io",
"schema": schema or {},
}
# ── Existing injection tests (unchanged behaviour) ─────────────────────────────
def test_standard_items_not_downgraded_without_lru():
"""Without LRU modules, standard items keep their original sortText."""
msg = _completion_response([{"label": "Microsoft.Storage", "sortText": "az"}])
out = json.loads(_inject_completions(msg))
assert out["result"]["items"][0]["sortText"] == "az"
def test_lru_modules_injected_at_top():
BicepModuleCatalog._modules = [_make_module("appservice", ["2.3.0", "latest"])]
msg = _completion_response([{"label": "Microsoft.Web/sites", "sortText": "az"}])
out = json.loads(_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 = [_make_module("roleassignments", ["2.0.0"])]
msg = {"jsonrpc": "2.0", "id": 2, "result": [{"label": "az-item", "sortText": "az"}]}
out = json.loads(_inject_completions(msg))
assert isinstance(out["result"], list)
assert out["result"][0]["label"] == "roleassignments"
def test_non_completion_message_passthrough():
msg = {"jsonrpc": "2.0", "method": "initialized", "params": {}}
out = json.loads(_inject_completions(msg))
assert out["method"] == "initialized"
# ── Version completion tests ───────────────────────────────────────────────────
def test_version_completions_injected_on_version_context():
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x", "2.0.x", "latest"],
schema={
"1.1.x": {"parameters": {"principalId": {"type": "string", "description": ""}}},
"2.0.x": {"parameters": {"assignments": {"type": "array", "description": ""}}},
"latest": {"parameters": {"assignments": {"type": "array", "description": ""}}},
},
)]
msg = _completion_response([{"label": "az-builtin", "sortText": "az"}])
context = {"type": "version", "module": "roleassignments", "prefix": ""}
out = json.loads(_inject_completions(msg, context))
items = out["result"]["items"]
labels = [i["label"] for i in items]
assert "1.1.x" in labels
assert "2.0.x" in labels
assert "latest" in labels
# LS items are replaced entirely — private registry versions only
assert all(i["sortText"].startswith("0_lru_ver_") for i in items)
assert not any(i["label"] == "az-builtin" for i in items)
def test_version_items_include_param_detail():
BicepModuleCatalog._modules = [_make_module(
"appservice",
versions=["2.3.2"],
schema={"2.3.2": {"parameters": {
"projectName": {"type": "string", "description": ""},
"environmentType": {"type": "string", "description": ""},
}}},
)]
items = BicepModuleCatalog.version_completion_items("appservice")
assert len(items) == 1
assert items[0]["label"] == "2.3.2"
assert "projectName" in items[0]["detail"]
assert items[0]["kind"] == 12 # Value
def test_version_completions_unknown_module_returns_empty():
items = BicepModuleCatalog.version_completion_items("nonexistent")
assert items == []
# ── Param completion tests ─────────────────────────────────────────────────────
_ROLEASSIGNMENTS_SCHEMA = {
"1.1.x": {
"parameters": {
"environmentType": {
"type": "string",
"description": "",
"allowed": ["DEV", "TEST", "PROD"],
},
"roleDefinitionIds": {
"type": "array",
"description": "The role definition ID.",
},
"principalId": {
"type": "string",
"description": "The principal ID.",
},
"principalType": {
"type": "string",
"description": "The principal type.",
},
}
}
}
def test_param_completions_injected_on_param_context():
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x"],
schema=_ROLEASSIGNMENTS_SCHEMA,
)]
msg = _completion_response([])
context = {"type": "param", "module": "roleassignments", "version": "1.1.x"}
out = json.loads(_inject_completions(msg, context))
items = out["result"]["items"]
labels = [i["label"] for i in items]
assert "environmentType" in labels
assert "roleDefinitionIds" in labels
assert "principalId" in labels
assert "principalType" in labels
assert items[0]["sortText"].startswith("0_lru_param_")
assert items[0]["kind"] == 5 # Field
def test_param_completion_items_have_insert_text():
BicepModuleCatalog._modules = [_make_module(
"roleassignments", versions=["1.1.x"], schema=_ROLEASSIGNMENTS_SCHEMA
)]
items = BicepModuleCatalog.param_completion_items("roleassignments", "1.1.x")
for item in items:
assert item["insertText"].endswith(": ")
def test_param_completions_fallback_to_closest_version():
"""When the exact version isn't in schema, fall back to any available version."""
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x", "1.1.4"],
schema=_ROLEASSIGNMENTS_SCHEMA, # only has "1.1.x"
)]
items = BicepModuleCatalog.param_completion_items("roleassignments", "1.1.4")
assert any(i["label"] == "principalId" for i in items)
def test_param_completions_unknown_module_returns_empty():
items = BicepModuleCatalog.param_completion_items("nonexistent", "1.0.0")
assert items == []
# ── _ProxySession context detection tests ─────────────────────────────────────
def _make_session_with_doc(uri: str, lines: list[str]) -> _ProxySession:
session = _ProxySession()
session.update_doc(uri, "\n".join(lines))
return session
URI = "file:///test/main.bicep"
def test_detect_version_context():
lines = ["module x 'br/modules:roleassignments:' = {}"]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 0, "character": 37})
assert ctx["type"] == "version"
assert ctx["module"] == "roleassignments"
def test_detect_version_context_partial_prefix():
lines = ["module x 'br/modules:roleassignments:1.1' = {}"]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 0, "character": 40})
assert ctx["type"] == "version"
assert ctx["module"] == "roleassignments"
assert ctx["prefix"] == "1.1"
def test_detect_module_path_context():
lines = ["module x 'br/modules:role' = {}"]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 0, "character": 25})
assert ctx["type"] == "module_path"
def test_detect_param_context():
lines = [
"module myMod 'br/modules:roleassignments:1.1.x' = {",
" name: 'test'",
" params: {",
" ", # ← cursor here (blank line, no param name yet)
" }",
"}",
]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 3, "character": 4})
assert ctx["type"] == "param"
assert ctx["module"] == "roleassignments"
assert ctx["version"] == "1.1.x"
def test_detect_param_context_empty_version():
"""Params context should work even when module ref has no version yet."""
lines = [
"module keyVault 'br/modules:modules/keyvault:' = {",
" params: {",
" ", # ← cursor here
" }",
"}",
]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 2, "character": 4})
assert ctx["type"] == "param"
assert ctx["module"] == "modules/keyvault"
assert ctx["version"] == ""
def test_detect_param_value_context_no_quote():
"""Cursor after 'principalType: ' (no opening quote) → param_value context."""
lines = [
"module myMod 'br/modules:roleassignments:1.1.x' = {",
" params: {",
" principalType: ", # ← cursor at end
" }",
"}",
]
session = _make_session_with_doc(URI, lines)
# character = len(" principalType: ") = 19
ctx = session._detect_context(URI, {"line": 2, "character": 19})
assert ctx["type"] == "param_value"
assert ctx["module"] == "roleassignments"
assert ctx["param"] == "principalType"
assert ctx["has_open_quote"] is False
def test_detect_param_value_context_open_quote():
"""Cursor after 'principalType: \\'' → param_value with has_open_quote=True."""
lines = [
"module myMod 'br/modules:roleassignments:1.1.x' = {",
" params: {",
" principalType: '", # ← cursor after opening quote
" }",
"}",
]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 2, "character": 20})
assert ctx["type"] == "param_value"
assert ctx["has_open_quote"] is True
def test_param_value_items_from_catalog_allowed():
"""environmentType completions come from catalog 'allowed' field."""
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x"],
schema={"1.1.x": {"parameters": {
"environmentType": {"type": "string", "allowed": ["DEV", "TEST", "PROD"]},
}}},
)]
items = BicepModuleCatalog.param_value_completion_items(
"roleassignments", "1.1.x", "environmentType"
)
labels = [i["label"] for i in items]
assert labels == ["DEV", "TEST", "PROD"]
# Without open quote, insertText should wrap in quotes
assert items[0]["insertText"] == "'DEV'"
def test_param_value_items_open_quote():
"""When has_open_quote=True, insertText closes the quote but doesn't open one."""
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x"],
schema={"1.1.x": {"parameters": {
"environmentType": {"type": "string", "allowed": ["DEV", "TEST", "PROD"]},
}}},
)]
items = BicepModuleCatalog.param_value_completion_items(
"roleassignments", "1.1.x", "environmentType", has_open_quote=True
)
assert items[0]["insertText"] == "DEV'"
def test_param_value_items_known_enum_fallback():
"""principalType uses _KNOWN_ENUMS fallback when not in catalog."""
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x"],
schema={"1.1.x": {"parameters": {
"principalType": {"type": "string"}, # no 'allowed' in catalog
}}},
)]
items = BicepModuleCatalog.param_value_completion_items(
"roleassignments", "1.1.x", "principalType"
)
labels = [i["label"] for i in items]
assert "Group" in labels
assert "ServicePrincipal" in labels
assert "User" in labels
def test_param_value_items_empty_for_free_string():
"""A plain string param with no allowed values returns no completions."""
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x"],
schema={"1.1.x": {"parameters": {
"principalId": {"type": "string"},
}}},
)]
items = BicepModuleCatalog.param_value_completion_items(
"roleassignments", "1.1.x", "principalId"
)
assert items == []
def test_param_value_injected_in_completion_response():
"""Full pipeline: param_value context injects enum completions at top."""
BicepModuleCatalog._modules = [_make_module(
"roleassignments",
versions=["1.1.x"],
schema={"1.1.x": {"parameters": {
"environmentType": {"type": "string", "allowed": ["DEV", "TEST", "PROD"]},
}}},
)]
msg = _completion_response([{"label": "existing", "sortText": "z"}])
ctx = {"type": "param_value", "module": "roleassignments", "version": "1.1.x",
"param": "environmentType", "has_open_quote": False}
out = json.loads(_inject_completions(msg, ctx))
labels = [i["label"] for i in out["result"]["items"]]
# LRU enum values only — LS completions are replaced entirely
assert labels == ["DEV", "TEST", "PROD"]
def test_detect_unknown_context_outside_module():
lines = ["var x = 'hello'"]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 0, "character": 10})
assert ctx["type"] == "unknown"
def test_detect_no_param_context_after_params_closed():
"""Cursor after the closing brace of params should NOT be param context."""
lines = [
"module myMod 'br/modules:roleassignments:1.1.x' = {",
" params: {",
" }",
" ", # ← cursor here (outside params block)
"}",
]
session = _make_session_with_doc(URI, lines)
ctx = session._detect_context(URI, {"line": 3, "character": 2})
# params block is closed, so should NOT be param context
assert ctx["type"] != "param"
# ── Session request tracking ───────────────────────────────────────────────────
def test_session_records_and_pops_context():
session = _ProxySession()
session.update_doc(URI, "\n".join([
"module m 'br/modules:appservice:2.3.0' = {",
" params: {",
" ",
" }",
"}",
]))
msg = {
"jsonrpc": "2.0",
"id": 42,
"method": "textDocument/completion",
"params": {
"textDocument": {"uri": URI},
"position": {"line": 2, "character": 4},
},
}
session.record_completion_request(msg)
ctx = session.pop_context(42)
assert ctx["type"] == "param"
assert ctx["module"] == "appservice"
# Second pop returns unknown
assert session.pop_context(42)["type"] == "unknown"