2026-05-10 12:38:41 +02:00
|
|
|
"""Unit tests for Bicep proxy message injection."""
|
|
|
|
|
|
|
|
|
|
import json
|
|
|
|
|
|
|
|
|
|
import pytest
|
|
|
|
|
|
|
|
|
|
from ilsp.bicep_lsp.modules import BicepModuleCatalog
|
2026-05-10 15:04:11 +02:00
|
|
|
from ilsp.bicep_lsp.proxy import _ProxySession, _frame, _inject_completions
|
2026-05-10 12:38:41 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_frame_produces_correct_header():
|
|
|
|
|
body = b'{"jsonrpc":"2.0"}'
|
2026-05-10 13:35:40 +02:00
|
|
|
framed = _frame(body)
|
2026-05-10 12:38:41 +02:00
|
|
|
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},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
2026-05-10 15:04:11 +02:00
|
|
|
def _make_module(name, versions=None, schema=None):
|
|
|
|
|
versions = versions or ["1.0.0", "latest"]
|
|
|
|
|
return {
|
|
|
|
|
"name": name,
|
|
|
|
|
"path": f"bicep/modules/{name}",
|
|
|
|
|
"versions": versions,
|
|
|
|
|
"latest": versions[-1],
|
|
|
|
|
"registry": "iactemplatereg.azurecr.io",
|
|
|
|
|
"schema": schema or {},
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── Existing injection tests (unchanged behaviour) ─────────────────────────────
|
|
|
|
|
|
2026-05-10 12:38:41 +02:00
|
|
|
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"}])
|
2026-05-10 13:35:40 +02:00
|
|
|
out = json.loads(_inject_completions(msg))
|
2026-05-10 12:38:41 +02:00
|
|
|
assert out["result"]["items"][0]["sortText"] == "az"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_lru_modules_injected_at_top():
|
2026-05-10 15:04:11 +02:00
|
|
|
BicepModuleCatalog._modules = [_make_module("appservice", ["2.3.0", "latest"])]
|
2026-05-10 12:38:41 +02:00
|
|
|
|
|
|
|
|
msg = _completion_response([{"label": "Microsoft.Web/sites", "sortText": "az"}])
|
2026-05-10 13:35:40 +02:00
|
|
|
out = json.loads(_inject_completions(msg))
|
2026-05-10 12:38:41 +02:00
|
|
|
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():
|
2026-05-10 15:04:11 +02:00
|
|
|
BicepModuleCatalog._modules = [_make_module("roleassignments", ["2.0.0"])]
|
2026-05-10 12:38:41 +02:00
|
|
|
|
|
|
|
|
msg = {"jsonrpc": "2.0", "id": 2, "result": [{"label": "az-item", "sortText": "az"}]}
|
2026-05-10 13:35:40 +02:00
|
|
|
out = json.loads(_inject_completions(msg))
|
2026-05-10 12:38:41 +02:00
|
|
|
assert isinstance(out["result"], list)
|
|
|
|
|
assert out["result"][0]["label"] == "roleassignments"
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
def test_non_completion_message_passthrough():
|
|
|
|
|
msg = {"jsonrpc": "2.0", "method": "initialized", "params": {}}
|
2026-05-10 13:35:40 +02:00
|
|
|
out = json.loads(_inject_completions(msg))
|
2026-05-10 12:38:41 +02:00
|
|
|
assert out["method"] == "initialized"
|
2026-05-10 15:04:11 +02:00
|
|
|
|
|
|
|
|
|
|
|
|
|
# ── 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
|
|
|
|
|
# LRU versions come first
|
|
|
|
|
assert items[0]["sortText"].startswith("0_lru_ver_")
|
|
|
|
|
assert items[-1]["sortText"].startswith("1_az_")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
" }",
|
|
|
|
|
"}",
|
|
|
|
|
]
|
|
|
|
|
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_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"
|
|
|
|
|
|