Skip to content

Commit 3250f94

Browse files
chore: pin hed-lsp to merge commit + add in-process daemon tests
hed-lsp PR #31 merged as 36447afb5e48bfecc45af0464b652614ba571414 on main (v0.4.0). Pin both deploy/Dockerfile and .github/workflows/test.yml to that commit SHA so the build is reproducible and survives any branch cleanup upstream. tests/lsp/test_daemon_internals.py: in-process exercise of _NodeChild and _Daemon. The existing end-to-end test launches the daemon in a subprocess, which means coverage.py never sees the daemon code. These five new tests instantiate _NodeChild and _Daemon directly within the test process so coverage on src/lsp/daemon.py jumps from ~14% (just the entry-point main()) to most of the proxy and lifecycle logic. Still no mocks: tests use the real Node hed-lsp child and a real Unix socket; the hed_lsp_server_js fixture skips when neither is available.
1 parent 23ecae2 commit 3250f94

3 files changed

Lines changed: 169 additions & 9 deletions

File tree

.github/workflows/test.yml

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -54,10 +54,10 @@ jobs:
5454

5555
- name: Build hed-lsp (for tests/lsp/ real-LSP tests)
5656
env:
57-
HED_LSP_REF: feat/hed-suggest-request
57+
HED_LSP_REF: 36447afb5e48bfecc45af0464b652614ba571414
5858
run: |
59-
git clone --depth 1 --branch "${HED_LSP_REF}" \
60-
https://github.com/hed-standard/hed-lsp.git "$RUNNER_TEMP/hed-lsp"
59+
git clone https://github.com/hed-standard/hed-lsp.git "$RUNNER_TEMP/hed-lsp"
60+
git -C "$RUNNER_TEMP/hed-lsp" checkout --quiet "${HED_LSP_REF}"
6161
cd "$RUNNER_TEMP/hed-lsp"
6262
corepack enable
6363
corepack prepare pnpm@10.33.4 --activate

deploy/Dockerfile

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -23,14 +23,14 @@ RUN curl -fsSL https://deb.nodesource.com/setup_22.x | bash - && \
2323
rm -rf /var/lib/apt/lists/*
2424

2525
# Clone HED repositories (self-contained)
26-
# Using schemas_latest_json from official hed-schemas repo (JSON inheritance fix now merged)
27-
# hed-lsp is pinned to the branch that adds the `hed/suggest` JSON-RPC
28-
# request handler; once that lands upstream this can switch back to a
29-
# tag-based clone of main.
30-
ARG HED_LSP_REF=feat/hed-suggest-request
26+
# hed-lsp is pinned to the merge commit on main that landed the
27+
# `hed/suggest` JSON-RPC request (hed-standard/hed-lsp#31, v0.4.0).
28+
# Pinning to a SHA (not a tag/branch) so the image is reproducible.
29+
ARG HED_LSP_REF=36447afb5e48bfecc45af0464b652614ba571414
3130
RUN git clone --depth 1 https://github.com/hed-standard/hed-schemas.git /app/hed-schemas && \
3231
git clone --depth 1 https://github.com/hed-standard/hed-javascript.git /app/hed-javascript && \
33-
git clone --depth 1 --branch "${HED_LSP_REF}" https://github.com/hed-standard/hed-lsp.git /app/hed-lsp
32+
git clone https://github.com/hed-standard/hed-lsp.git /app/hed-lsp && \
33+
git -C /app/hed-lsp checkout --quiet "${HED_LSP_REF}"
3434

3535
# Build HED JavaScript validator
3636
WORKDIR /app/hed-javascript

tests/lsp/test_daemon_internals.py

Lines changed: 160 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,160 @@
1+
"""In-process tests for the hedit-lspd daemon's internals.
2+
3+
The end-to-end test in test_daemon_lifecycle.py launches the daemon
4+
in a subprocess, which exercises the SIGTERM/exit-code path but leaves
5+
no coverage in the parent test process. This module exercises the same
6+
classes directly so coverage measurement sees them, while still using
7+
the real Node hed-lsp child and a real Unix socket -- no mocks.
8+
"""
9+
10+
from __future__ import annotations
11+
12+
import asyncio
13+
import tempfile
14+
from collections.abc import Iterator
15+
from pathlib import Path
16+
17+
import pytest
18+
19+
from src.lsp import HedLspClient
20+
from src.lsp.daemon import (
21+
_Daemon,
22+
_NodeChild,
23+
default_runtime_dir,
24+
meta_file_path,
25+
pid_file_path,
26+
socket_file_path,
27+
)
28+
29+
30+
@pytest.fixture
31+
def short_runtime_dir() -> Iterator[Path]:
32+
"""Daemon runtime under /tmp so the Unix socket path stays under 104 bytes."""
33+
with tempfile.TemporaryDirectory(prefix="hedit-lspd-int-", dir="/tmp") as path:
34+
yield Path(path)
35+
36+
37+
def test_runtime_path_helpers_use_runtime_dir(tmp_path: Path) -> None:
38+
"""The helper functions derive every path from the runtime dir argument."""
39+
assert pid_file_path(tmp_path).parent == tmp_path
40+
assert socket_file_path(tmp_path).parent == tmp_path
41+
assert meta_file_path(tmp_path).parent == tmp_path
42+
assert pid_file_path(tmp_path).name == "lspd.pid"
43+
assert socket_file_path(tmp_path).name == "lspd.sock"
44+
assert meta_file_path(tmp_path).name == "lspd.meta.json"
45+
46+
47+
def test_default_runtime_dir_is_per_user() -> None:
48+
"""default_runtime_dir() returns a per-user path with the hedit subdir."""
49+
path = default_runtime_dir()
50+
assert path.name == "hedit"
51+
52+
53+
async def test_node_child_request_response(hed_lsp_server_js: Path) -> None:
54+
"""Spawn _NodeChild directly, drive an initialize+hed/suggest cycle, shut down."""
55+
node = await _NodeChild.spawn(hed_lsp_server_js)
56+
try:
57+
init_queue: asyncio.Queue = asyncio.Queue()
58+
suggest_queue: asyncio.Queue = asyncio.Queue()
59+
60+
# LSP initialize handshake. claim_response_for routes the matching
61+
# response into a dedicated queue, so unrelated server-side
62+
# notifications (window/showMessage, hed/modelProgress, ...) don't
63+
# collide with the response we're waiting on.
64+
node.claim_response_for(1, init_queue)
65+
await node.send(
66+
{
67+
"jsonrpc": "2.0",
68+
"id": 1,
69+
"method": "initialize",
70+
"params": {"processId": None, "rootUri": None, "capabilities": {}},
71+
}
72+
)
73+
init_resp = await asyncio.wait_for(init_queue.get(), timeout=15.0)
74+
assert init_resp["id"] == 1
75+
assert "capabilities" in init_resp.get("result", {})
76+
77+
await node.send({"jsonrpc": "2.0", "method": "initialized", "params": {}})
78+
79+
# Real hed/suggest round-trip through the spawned Node child.
80+
node.claim_response_for(2, suggest_queue)
81+
await node.send(
82+
{
83+
"jsonrpc": "2.0",
84+
"id": 2,
85+
"method": "hed/suggest",
86+
"params": {"queries": ["red square"], "schema": "8.4.0", "top": 5},
87+
}
88+
)
89+
suggest_resp = await asyncio.wait_for(suggest_queue.get(), timeout=10.0)
90+
assert suggest_resp["id"] == 2
91+
assert "Red" in suggest_resp["result"]["red square"]
92+
finally:
93+
await node.shutdown()
94+
95+
96+
async def test_node_child_broadcast_queue_receives_notifications(
97+
hed_lsp_server_js: Path,
98+
) -> None:
99+
"""Server notifications (no JSON-RPC id) fan out to broadcast queues."""
100+
node = await _NodeChild.spawn(hed_lsp_server_js)
101+
try:
102+
bcast: asyncio.Queue = asyncio.Queue()
103+
node.add_broadcast_queue(bcast)
104+
try:
105+
await node.send(
106+
{
107+
"jsonrpc": "2.0",
108+
"id": 99,
109+
"method": "initialize",
110+
"params": {"processId": None, "rootUri": None, "capabilities": {}},
111+
}
112+
)
113+
# Drain until we see at least one unclaimed message (the
114+
# initialize response with id=99 also routes here since we did
115+
# not claim that id).
116+
seen = await asyncio.wait_for(bcast.get(), timeout=15.0)
117+
assert isinstance(seen, dict)
118+
finally:
119+
node.remove_broadcast_queue(bcast)
120+
finally:
121+
await node.shutdown()
122+
123+
124+
async def test_daemon_in_process_lifecycle(
125+
hed_lsp_server_js: Path, short_runtime_dir: Path
126+
) -> None:
127+
"""Build a _Daemon in the test process, connect a client through it, then stop.
128+
129+
This exercises the socket-bind, PID/meta-write, peer-connection
130+
handling, and stop() cleanup paths inside the same process the
131+
coverage tool measures, lifting coverage on src/lsp/daemon.py above
132+
what the subprocess-based lifecycle test alone can reach.
133+
"""
134+
daemon = _Daemon(
135+
server_js=hed_lsp_server_js,
136+
runtime_dir=short_runtime_dir,
137+
node_path="node",
138+
)
139+
await daemon.start()
140+
try:
141+
socket = socket_file_path(short_runtime_dir)
142+
assert socket.exists()
143+
assert pid_file_path(short_runtime_dir).exists()
144+
assert meta_file_path(short_runtime_dir).exists()
145+
146+
client = await HedLspClient.connect_unix(socket)
147+
try:
148+
result = await client.suggest("button press")
149+
assert result.success, result.error
150+
assert result.raw["button press"], "expected at least one suggestion"
151+
finally:
152+
await client.shutdown()
153+
finally:
154+
await daemon.stop()
155+
156+
# stop() must clean up the runtime files so a subsequent start
157+
# doesn't see stale state.
158+
assert not socket_file_path(short_runtime_dir).exists()
159+
assert not pid_file_path(short_runtime_dir).exists()
160+
assert not meta_file_path(short_runtime_dir).exists()

0 commit comments

Comments
 (0)