Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .claude/CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,7 +118,7 @@ Three types of tests, all must pass before committing:

- **Unit tests** (`tests/unit/`): C++ tests using the project's own test framework. Test names should be at most 4 words.
- **Integration tests** (`tests/integration/`): Python pytest tests that start a real clice server and communicate via LSP.
- **Smoke tests** (`tests/smoke/`): Replay recorded LSP sessions via `tests/replay.py`.
- **Smoke tests** (`tests/smoke/`): Replay recorded LSP sessions via `tests/tools/replay.py`.

### Integration Test Style

Expand Down
2 changes: 1 addition & 1 deletion .claude/commands/test.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Filtering specific tests:

- Unit tests: `pixi run unit-test [type] --test-filter=SuiteName.CaseName`
- Integration tests: `pixi run pytest tests/integration -k "test_name" --executable=./build/[type]/bin/clice`
- Smoke tests: `pixi run python tests/replay.py tests/smoke/specific.jsonl --clice=./build/[type]/bin/clice`
- Smoke tests: `pixi run python tests/tools/replay.py tests/smoke/specific.jsonl --clice=./build/[type]/bin/clice`

Example usage:

Expand Down
2 changes: 1 addition & 1 deletion docs/en/dev/test-and-debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pixi run smoke-test Debug # debug build
Equivalent to:

```bash
python tests/replay.py tests/smoke/*.jsonl \
python tests/tools/replay.py tests/smoke/*.jsonl \
--clice=./build/RelWithDebInfo/bin/clice
```

Expand Down
2 changes: 1 addition & 1 deletion docs/zh/dev/test-and-debug.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ pixi run smoke-test Debug # debug 构建
等价于:

```bash
python tests/replay.py tests/smoke/*.jsonl \
python tests/tools/replay.py tests/smoke/*.jsonl \
--clice=./build/RelWithDebInfo/bin/clice
```

Expand Down
2 changes: 1 addition & 1 deletion editors/vscode/.vscode-test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ import { fileURLToPath } from "url";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");

// Each fixture under tests/data becomes one test run with that workspace.
// Run tests/prepare.py first to generate compile databases.
// Run tests/tools/prepare.py first to generate compile databases.
// Keep in sync with the editor tasks in pixi.toml.
const fixtures = ["hello_world", "modules/hover_on_imported_symbol", "header_context"];

Expand Down
4 changes: 2 additions & 2 deletions pixi.toml
Original file line number Diff line number Diff line change
Expand Up @@ -194,7 +194,7 @@ pytest -n auto --dist loadgroup --timeout=300 --timeout-method=thread \
[feature.test.tasks.smoke-test]
args = [{ arg = "type", default = "RelWithDebInfo" }]
cmd = """
python tests/replay.py tests/smoke/*.jsonl \
python tests/tools/replay.py tests/smoke/*.jsonl \
--clice=./build/{{ type }}/bin/clice
"""

Expand All @@ -218,7 +218,7 @@ python = ">=3.13"

# Fixture list kept in sync with editors/vscode/.vscode-test.mjs.
[feature.editor.tasks.editor-prepare]
cmd = "python tests/prepare.py hello_world modules/hover_on_imported_symbol header_context"
cmd = "python tests/tools/prepare.py hello_world modules/hover_on_imported_symbol header_context"

# Requires nvim (stable) on PATH.
[feature.editor.tasks.nvim-e2e]
Expand Down
175 changes: 16 additions & 159 deletions tests/conftest.py
Original file line number Diff line number Diff line change
@@ -1,15 +1,16 @@
import asyncio
import os
import shutil
import socket
import sys
from pathlib import Path

import pytest

from tests.cdb import generate_cdb, generate_test_data_cdbs
from tests.integration.utils.client import CliceClient
from tests.integration.utils.assertions import assert_no_anomaly
from tests.tools.compile_commands import generate_cdb, generate_test_data_cdbs
from tests.tools.client import CliceClient
from tests.tools.lifecycle import (
check_no_anomaly,
find_free_port,
shutdown_client,
)


@pytest.hookimpl(tryfirst=True, hookwrapper=True)
Expand Down Expand Up @@ -104,20 +105,10 @@ def workspace(request: pytest.FixtureRequest, test_data_dir: Path):
shutil.rmtree(clice_dir, ignore_errors=True)


def build_init_options(request: pytest.FixtureRequest, workspace: Path) -> dict:
"""Initialization options from @pytest.mark.init_options plus test defaults."""
def marker_init_options(request: pytest.FixtureRequest) -> dict:
"""Initialization options from @pytest.mark.init_options, if present."""
marker = request.node.get_closest_marker("init_options")
init_options = dict(marker.args[0]) if marker else {}
project = dict(init_options.get("project", {}))
# Force cache_dir into the workspace so .clice/ cleanup prevents stale PCH.
project["cache_dir"] = str(workspace / ".clice")
# One worker of each kind is enough for tests and halves the per-test
# process-spawn cost (5 -> 3 processes), which dominates suite time on
# macOS Debug. Tests needing more override via @pytest.mark.init_options.
project.setdefault("stateless_worker_count", 1)
project.setdefault("stateful_worker_count", 1)
init_options["project"] = project
return init_options
return dict(marker.args[0]) if marker else {}


@pytest.fixture
Expand All @@ -133,8 +124,9 @@ async def client(
await c.start_io(*cmd)

if workspace is not None:
init_options = build_init_options(request, workspace)
await c.initialize(workspace, initialization_options=init_options)
await c.initialize(
workspace, initialization_options=marker_init_options(request)
)

yield c

Expand All @@ -150,43 +142,6 @@ async def client(
check_no_anomaly(request, c)


def check_no_anomaly(request: pytest.FixtureRequest, c: CliceClient) -> None:
"""Teardown gate: anomalies are internal clice bugs — every test session
must end without one. Tests that intentionally trigger anomalies opt out
with @pytest.mark.allow_anomaly and assert on them explicitly."""
if request.node.get_closest_marker("allow_anomaly") is not None:
return
assert_no_anomaly(c, c.workspace)


next_port_offset = 0


def find_free_port() -> int:
"""Pick a port from a per-xdist-worker range.

bind(0) draws from the kernel's shared pool: two concurrent xdist
workers can grab the same port in the close-then-rebind gap. Disjoint
per-worker ranges (below the ephemeral range) remove that race; the
advancing offset avoids immediately reusing a just-released port.
"""
global next_port_offset
worker = os.environ.get("PYTEST_XDIST_WORKER", "gw0")
suffix = worker.removeprefix("gw")
index = int(suffix) if suffix.isdigit() else 0
base = 21000 + index * 100
for _ in range(100):
port = base + next_port_offset
next_port_offset = (next_port_offset + 1) % 100
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(("127.0.0.1", port))
return port
except OSError:
continue
raise RuntimeError(f"no free port in range {base}-{base + 99}")


@pytest.fixture
async def agentic(
request: pytest.FixtureRequest,
Expand All @@ -202,111 +157,13 @@ async def agentic(
await c.start_io(*cmd)

if workspace is not None:
init_options = build_init_options(request, workspace)
await c.initialize(workspace, initialization_options=init_options)
await c.initialize(
workspace, initialization_options=marker_init_options(request)
)

yield executable, host, port

try:
await shutdown_client(c)
finally:
check_no_anomaly(request, c)


async def make_client(executable: Path, workspace: Path) -> CliceClient:
"""Spawn a fresh clice server and initialize it. For multi-session tests."""
c = CliceClient()
await c.start_io(str(executable), "serve")
await c.initialize(workspace)
return c


SANITIZER_MARKERS = (
"AddressSanitizer",
"LeakSanitizer",
"MemorySanitizer",
"ThreadSanitizer",
"UndefinedBehaviorSanitizer",
"==ERROR:",
"runtime error:",
)


def server_stderr_excerpt(stderr_text: str) -> str:
interesting = [
line
for line in stderr_text.splitlines()
if "[warn]" in line
or "[error]" in line
or "Sanitizer" in line
or "==ERROR:" in line
or "runtime error:" in line
]
return "\n".join(interesting[-80:])


async def assert_server_exited_cleanly(server, timeout: float = 10.0) -> None:
failures: list[str] = []

if server is None:
return

if server.returncode is None:
try:
await asyncio.wait_for(server.wait(), timeout=timeout)
except asyncio.TimeoutError:
server.kill()
await server.wait()
failures.append(f"server did not exit within {timeout:g}s after shutdown")

print(f"[server] exit code: {server.returncode}", flush=True)

stderr_text = ""
if server.stderr:
try:
stderr_data = await asyncio.wait_for(server.stderr.read(), timeout=2.0)
stderr_text = stderr_data.decode("utf-8", errors="replace")
except Exception as exc:
failures.append(f"failed to collect server stderr: {exc!r}")

for line in server_stderr_excerpt(stderr_text).splitlines():
print(f"[server] {line}", flush=True)

if server.returncode != 0:
failures.append(f"server exited with code {server.returncode}")

if any(marker in stderr_text for marker in SANITIZER_MARKERS):
failures.append("server stderr contains sanitizer/runtime error output")

if failures:
excerpt = server_stderr_excerpt(stderr_text)
if excerpt:
failures.append("server stderr excerpt:\n" + excerpt)
pytest.fail("\n".join(failures))


async def shutdown_client(c: CliceClient, *, verbose: bool = False) -> None:
"""Gracefully shut down a client, force-kill if needed."""
try:
await asyncio.wait_for(c.shutdown_async(None), timeout=10.0)
except Exception:
pass

try:
c.exit(None)
except Exception:
pass

if verbose and c.log_messages:
for msg in c.log_messages:
level = {1: "ERROR", 2: "WARN", 3: "INFO", 4: "LOG"}.get(msg.type, "?")
print(f"[logMessage/{level}] {msg.message}", flush=True)

try:
await assert_server_exited_cleanly(c.server)
finally:
try:
await c.stop_io()
await asyncio.sleep(0.1)
except Exception:
pass
37 changes: 20 additions & 17 deletions tests/integration/agentic/test_agentic.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@

import pytest

from tests.integration.utils.wait import wait_for_index
from tests.tools.checks import wait_for_index


class AgenticRpcClient:
Expand Down Expand Up @@ -142,8 +142,12 @@ def do_request(_):
@pytest.fixture
async def indexed_agentic(request, executable, workspace):
"""Start server with LSP+agentic, compile a file, wait for indexing."""
from tests.integration.utils.client import CliceClient
from tests.conftest import check_no_anomaly, shutdown_client, find_free_port
from tests.tools.client import CliceClient
from tests.tools.lifecycle import (
check_no_anomaly,
shutdown_client,
find_free_port,
)

host = "127.0.0.1"
port = find_free_port()
Expand All @@ -152,8 +156,7 @@ async def indexed_agentic(request, executable, workspace):
c = CliceClient()
await c.start_io(*cmd)

init_options = {"project": {"cache_dir": str(workspace / ".clice")}}
await c.initialize(workspace, initialization_options=init_options)
await c.initialize(workspace)

uri, _ = await c.open_and_wait(workspace / "main.cpp")
assert await wait_for_index(c, uri, "add"), "Index not ready"
Expand Down Expand Up @@ -423,17 +426,19 @@ async def test_rpc_status(indexed_agentic, workspace):
@pytest.mark.workspace("hello_world")
async def test_rpc_shutdown(executable, workspace):
"""Shutdown notification should cause the server to exit cleanly."""
from tests.integration.utils.client import CliceClient
from tests.conftest import find_free_port, assert_server_exited_cleanly
from tests.tools.client import CliceClient
from tests.tools.lifecycle import (
find_free_port,
assert_server_exited_cleanly,
)

host = "127.0.0.1"
port = find_free_port()
cmd = [str(executable), "serve", "--host", host, "--port", str(port)]

c = CliceClient()
await c.start_io(*cmd)
init_options = {"project": {"cache_dir": str(workspace / ".clice")}}
await c.initialize(workspace, initialization_options=init_options)
await c.initialize(workspace)

rpc = AgenticRpcClient(host, port)
body = json.dumps({"jsonrpc": "2.0", "method": "agentic/shutdown", "params": {}})
Expand Down Expand Up @@ -526,8 +531,11 @@ async def test_rpc_impact_analysis_unknown(indexed_agentic, workspace):

async def test_shutdown_during_indexing(executable, tmp_path):
"""Shutdown during active background indexing must exit cleanly."""
from tests.integration.utils.client import CliceClient
from tests.conftest import find_free_port, assert_server_exited_cleanly
from tests.tools.client import CliceClient
from tests.tools.lifecycle import (
find_free_port,
assert_server_exited_cleanly,
)

workspace = tmp_path / "ws"
workspace.mkdir()
Expand Down Expand Up @@ -558,12 +566,7 @@ async def test_shutdown_during_indexing(executable, tmp_path):
await c.start_io(*cmd)

try:
init_options = {
"project": {
"cache_dir": str(workspace / ".clice"),
"idle_timeout_ms": 0,
}
}
init_options = {"project": {"idle_timeout_ms": 0}}
try:
await c.initialize(workspace, initialization_options=init_options)
except Exception:
Expand Down
Loading