For specific LRU contexts (version, param, param_value), the Bicep LS was appending its own random/irrelevant completions alongside the LRU catalog items. The LS has no knowledge of the private ACR registry, so its suggestions in these positions are noise. Now the LS items are discarded entirely for these three contexts. LS items are still kept (below LRU items) for module_path and unknown contexts.
423 lines
15 KiB
Python
423 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_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"
|
|
|