diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index 6f2efe64c..353df31a6 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -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 diff --git a/.claude/commands/test.md b/.claude/commands/test.md index 161ff272e..dceaeb938 100644 --- a/.claude/commands/test.md +++ b/.claude/commands/test.md @@ -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: diff --git a/docs/en/dev/test-and-debug.md b/docs/en/dev/test-and-debug.md index 8b5df6bfd..f9c856457 100644 --- a/docs/en/dev/test-and-debug.md +++ b/docs/en/dev/test-and-debug.md @@ -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 ``` diff --git a/docs/zh/dev/test-and-debug.md b/docs/zh/dev/test-and-debug.md index 9d5a1cd4f..fec65a088 100644 --- a/docs/zh/dev/test-and-debug.md +++ b/docs/zh/dev/test-and-debug.md @@ -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 ``` diff --git a/editors/vscode/.vscode-test.mjs b/editors/vscode/.vscode-test.mjs index 5a5e000ae..786cc1a30 100644 --- a/editors/vscode/.vscode-test.mjs +++ b/editors/vscode/.vscode-test.mjs @@ -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"]; diff --git a/pixi.toml b/pixi.toml index f9af87546..26474767c 100644 --- a/pixi.toml +++ b/pixi.toml @@ -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 """ @@ -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] diff --git a/tests/conftest.py b/tests/conftest.py index a426576ef..7a7e16518 100644 --- a/tests/conftest.py +++ b/tests/conftest.py @@ -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) @@ -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 @@ -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 @@ -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, @@ -202,8 +157,9 @@ 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 @@ -211,102 +167,3 @@ async def agentic( 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 diff --git a/tests/integration/agentic/test_agentic.py b/tests/integration/agentic/test_agentic.py index 19fad6a07..a1db68117 100644 --- a/tests/integration/agentic/test_agentic.py +++ b/tests/integration/agentic/test_agentic.py @@ -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: @@ -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() @@ -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" @@ -423,8 +426,11 @@ 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() @@ -432,8 +438,7 @@ async def test_rpc_shutdown(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) rpc = AgenticRpcClient(host, port) body = json.dumps({"jsonrpc": "2.0", "method": "agentic/shutdown", "params": {}}) @@ -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() @@ -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: diff --git a/tests/integration/agentic/test_cli.py b/tests/integration/agentic/test_cli.py index e01cfa277..1449d630a 100644 --- a/tests/integration/agentic/test_cli.py +++ b/tests/integration/agentic/test_cli.py @@ -6,7 +6,7 @@ import pytest -from tests.integration.utils.wait import wait_for_index +from tests.tools.checks import wait_for_index def run_cli(executable, host, port, method, **kwargs): @@ -30,8 +30,12 @@ def run_cli(executable, host, port, method, **kwargs): async def indexed_server(request, executable, workspace): """Start server with LSP+agentic, compile a file, wait for indexing.""" import asyncio - 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() @@ -40,8 +44,7 @@ async def indexed_server(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" @@ -192,13 +195,10 @@ async def test_cli_status(indexed_server, workspace): assert isinstance(data["indexed"], int) -# --- Server mode and CLI entry point tests --- - - @pytest.mark.workspace("hello_world") async def test_socket_mode_connects(executable, workspace): - from tests.conftest import find_free_port, shutdown_client - from tests.integration.utils.client import CliceClient + from tests.tools.lifecycle import find_free_port, shutdown_client + from tests.tools.client import CliceClient port = find_free_port() cmd = [str(executable), "serve", "--mode", "socket", "--port", str(port)] diff --git a/tests/integration/compilation/test_header_pch.py b/tests/integration/compilation/test_header_pch.py index ab90189e6..306103df0 100644 --- a/tests/integration/compilation/test_header_pch.py +++ b/tests/integration/compilation/test_header_pch.py @@ -6,9 +6,9 @@ no directives of its own (bound == 0) still gets a PCH. """ -from tests.integration.utils import write_cdb -from tests.integration.utils.assertions import assert_clean_compile -from tests.integration.utils.cache import list_pch_files +from tests.tools.compile_commands import write_cdb +from tests.tools.checks import assert_clean_compile +from tests.tools.workspace import list_pch_files async def test_prefix_not_reprocessed(client, tmp_path): diff --git a/tests/integration/compilation/test_pch.py b/tests/integration/compilation/test_pch.py index 3b22788b6..60edb92b0 100644 --- a/tests/integration/compilation/test_pch.py +++ b/tests/integration/compilation/test_pch.py @@ -10,10 +10,10 @@ Position, ) -from tests.integration.utils import doc -from tests.integration.utils.workspace import did_change -from tests.integration.utils.wait import wait_for_recompile -from tests.integration.utils.assertions import assert_clean_compile, assert_no_errors +from tests.tools.workspace import doc +from tests.tools.workspace import did_change +from tests.tools.checks import wait_for_recompile +from tests.tools.checks import assert_clean_compile, assert_no_errors @pytest.mark.workspace("pch_test") diff --git a/tests/integration/compilation/test_persistent_cache.py b/tests/integration/compilation/test_persistent_cache.py index 44ec1e795..de63a0244 100644 --- a/tests/integration/compilation/test_persistent_cache.py +++ b/tests/integration/compilation/test_persistent_cache.py @@ -15,10 +15,11 @@ Position, ) -from tests.conftest import make_client, shutdown_client -from tests.integration.utils import write_cdb, doc -from tests.integration.utils.wait import MTIME_GRANULARITY, SETTLE_TIME -from tests.integration.utils.cache import ( +from tests.tools.lifecycle import make_client, shutdown_client +from tests.tools.compile_commands import write_cdb +from tests.tools.workspace import doc +from tests.tools.checks import MTIME_GRANULARITY, SETTLE_TIME +from tests.tools.workspace import ( cache_root, list_pch_files, list_pcm_files, @@ -26,7 +27,8 @@ pin_cache_to_workspace, read_cache_json, ) -from tests.integration.utils.assertions import assert_clean_compile, assert_no_anomaly +from tests.tools.checks import assert_no_anomaly +from tests.tools.checks import assert_clean_compile async def test_pch_written_to_cache_dir(client, tmp_path): @@ -410,7 +412,7 @@ async def test_cache_wiped_while_running(client, tmp_path): await asyncio.sleep(1.1) (tmp_path / "header.h").write_text("#pragma once\nstruct W { int x; int y; };\n") - from tests.integration.utils.wait import wait_for_recompile + from tests.tools.checks import wait_for_recompile await wait_for_recompile(client, uri) assert_clean_compile(client, uri) diff --git a/tests/integration/compilation/test_self_containment.py b/tests/integration/compilation/test_self_containment.py index 9273453b1..9c924491b 100644 --- a/tests/integration/compilation/test_self_containment.py +++ b/tests/integration/compilation/test_self_containment.py @@ -7,11 +7,12 @@ choices persist across server sessions via cache.json. """ -from tests.conftest import make_client, shutdown_client -from tests.integration.utils import get_field, write_cdb, write_entries -from tests.integration.utils.assertions import assert_clean_compile -from tests.integration.utils.cache import read_cache_json -from tests.integration.utils.wait import wait_for_recompile +from tests.tools.lifecycle import make_client, shutdown_client +from tests.tools.compile_commands import write_cdb, write_entries +from tests.tools.workspace import get_field +from tests.tools.checks import assert_clean_compile +from tests.tools.workspace import read_cache_json +from tests.tools.checks import wait_for_recompile def prefix_files(workspace): @@ -149,7 +150,7 @@ async def test_header_save_resets_verdict(executable, tmp_path): VersionedTextDocumentIdentifier, ) - from tests.integration.utils import doc + from tests.tools.workspace import doc (tmp_path / "types.h").write_text("#pragma once\nstruct Point { int x; int y; };\n") utils_h = tmp_path / "utils.h" diff --git a/tests/integration/compilation/test_staleness.py b/tests/integration/compilation/test_staleness.py index 8a0cd0d8e..55f68c2c3 100644 --- a/tests/integration/compilation/test_staleness.py +++ b/tests/integration/compilation/test_staleness.py @@ -21,19 +21,17 @@ VersionedTextDocumentIdentifier, ) -from tests.conftest import make_client, shutdown_client -from tests.integration.utils import write_cdb, doc -from tests.integration.utils.cache import list_pch_files, pin_cache_to_workspace -from tests.integration.utils.wait import ( +from tests.tools.lifecycle import make_client, shutdown_client +from tests.tools.compile_commands import write_cdb +from tests.tools.workspace import doc +from tests.tools.workspace import list_pch_files, pin_cache_to_workspace +from tests.tools.checks import ( MTIME_GRANULARITY, SETTLE_TIME, wait_for_recompile, ) -from tests.integration.utils.assertions import ( - assert_clean_compile, - assert_has_errors, - assert_no_anomaly, -) +from tests.tools.checks import assert_no_anomaly +from tests.tools.checks import assert_clean_compile, assert_has_errors async def test_header_change_invalidates_ast(client, tmp_path): @@ -394,7 +392,7 @@ async def test_didsave_with_module_deps(client, test_data_dir, tmp_path): if f.is_file(): shutil.copy2(f, tmp_path / f.name) - from tests.cdb import generate_cdb + from tests.tools.compile_commands import generate_cdb generate_cdb(tmp_path) await client.initialize(tmp_path) diff --git a/tests/integration/extensions/test_context_switching.py b/tests/integration/extensions/test_context_switching.py index 25917a28b..7e2d43988 100644 --- a/tests/integration/extensions/test_context_switching.py +++ b/tests/integration/extensions/test_context_switching.py @@ -7,9 +7,10 @@ import asyncio -from tests.integration.utils import get_field, write_cdb, write_entries -from tests.integration.utils.assertions import assert_clean_compile, assert_has_errors -from tests.integration.utils.wait import MTIME_GRANULARITY, wait_for_recompile +from tests.tools.compile_commands import write_cdb, write_entries +from tests.tools.workspace import get_field +from tests.tools.checks import assert_clean_compile, assert_has_errors +from tests.tools.checks import MTIME_GRANULARITY, wait_for_recompile async def test_source_command_switch(client, tmp_path): @@ -193,7 +194,7 @@ async def test_stale_epoch_rejected(client, tmp_path): from lsprotocol.types import DidSaveTextDocumentParams - from tests.integration.utils import doc + from tests.tools.workspace import doc (tmp_path / "shared.h").write_text("VALUE_TYPE get_value();\n") (tmp_path / "main.cpp").write_text( @@ -282,7 +283,7 @@ async def test_saved_include_updates_hosts(client, tmp_path): VersionedTextDocumentIdentifier, ) - from tests.integration.utils import doc + from tests.tools.workspace import doc (tmp_path / "lonely.h").write_text("inline int lonely() { return 1; }\n") main_cpp = tmp_path / "main.cpp" diff --git a/tests/integration/extensions/test_header_context.py b/tests/integration/extensions/test_header_context.py index 0ccc9bbbe..17718fca6 100644 --- a/tests/integration/extensions/test_header_context.py +++ b/tests/integration/extensions/test_header_context.py @@ -17,7 +17,8 @@ TextDocumentIdentifier, ) -from tests.integration.utils import doc, get_field, write_entries +from tests.tools.compile_commands import write_entries +from tests.tools.workspace import doc, get_field @pytest.mark.workspace("header_context") diff --git a/tests/integration/features/test_completion.py b/tests/integration/features/test_completion.py index 44574f941..183e90360 100644 --- a/tests/integration/features/test_completion.py +++ b/tests/integration/features/test_completion.py @@ -9,8 +9,8 @@ TextDocumentIdentifier, ) -from tests.integration.utils import doc -from tests.integration.utils.workspace import did_change +from tests.tools.workspace import doc +from tests.tools.workspace import did_change @pytest.mark.workspace("include_completion") diff --git a/tests/integration/features/test_file_tracker.py b/tests/integration/features/test_file_tracker.py index e1a82b8fe..9e9ff0499 100644 --- a/tests/integration/features/test_file_tracker.py +++ b/tests/integration/features/test_file_tracker.py @@ -4,19 +4,19 @@ import asyncio -from tests.integration.utils import write_cdb -from tests.integration.utils.assertions import ( +from tests.tools.compile_commands import write_cdb +from tests.tools.checks import ( assert_has_errors, assert_no_errors, get_errors, ) -from tests.integration.utils.wait import ( +from tests.tools.checks import ( MTIME_GRANULARITY, wait_for_index, wait_for_recompile, wait_for_reference, ) -from tests.integration.utils.workspace import get_field +from tests.tools.workspace import get_field GATED_MAIN = """\ #ifndef FEATURE diff --git a/tests/integration/features/test_formatting.py b/tests/integration/features/test_formatting.py index 57d23efef..14691aa60 100644 --- a/tests/integration/features/test_formatting.py +++ b/tests/integration/features/test_formatting.py @@ -1,7 +1,7 @@ import pytest from lsprotocol.types import Position, Range -from tests.integration.utils.workspace import did_change +from tests.tools.workspace import did_change UNFORMATTED = "int add( int a , int b ) {\nreturn a+b ;\n}\n" FORMATTED = "int add(int a, int b) { return a + b; }\n" diff --git a/tests/integration/features/test_guidance_diagnostics.py b/tests/integration/features/test_guidance_diagnostics.py index 6a3903ec8..c7dd6fa8f 100644 --- a/tests/integration/features/test_guidance_diagnostics.py +++ b/tests/integration/features/test_guidance_diagnostics.py @@ -7,9 +7,10 @@ from lsprotocol.types import DiagnosticSeverity -from tests.conftest import make_client, shutdown_client -from tests.integration.utils import write_cdb -from tests.integration.utils.assertions import assert_no_anomaly, guidance_messages +from tests.tools.lifecycle import make_client, shutdown_client +from tests.tools.compile_commands import write_cdb +from tests.tools.checks import assert_no_anomaly +from tests.tools.checks import guidance_messages GUIDANCE_CODE = "inferred-compile-command" diff --git a/tests/integration/features/test_header_reindex.py b/tests/integration/features/test_header_reindex.py index 389eb7f55..5d4b65f99 100644 --- a/tests/integration/features/test_header_reindex.py +++ b/tests/integration/features/test_header_reindex.py @@ -10,8 +10,8 @@ VersionedTextDocumentIdentifier, ) -from tests.integration.utils import write_cdb -from tests.integration.utils.wait import ( +from tests.tools.compile_commands import write_cdb +from tests.tools.checks import ( MTIME_GRANULARITY, reference_uris, wait_for_reference, diff --git a/tests/integration/features/test_inactive_regions.py b/tests/integration/features/test_inactive_regions.py index 50a771d4d..e0b88a0aa 100644 --- a/tests/integration/features/test_inactive_regions.py +++ b/tests/integration/features/test_inactive_regions.py @@ -8,7 +8,7 @@ import asyncio -from tests.integration.utils import write_cdb, write_entries +from tests.tools.compile_commands import write_cdb, write_entries async def wait_regions(captured, timeout=15.0): diff --git a/tests/integration/features/test_index.py b/tests/integration/features/test_index.py index aa0706c06..808d355eb 100644 --- a/tests/integration/features/test_index.py +++ b/tests/integration/features/test_index.py @@ -16,8 +16,8 @@ WorkspaceSymbolParams, ) -from tests.integration.utils import doc -from tests.integration.utils.wait import wait_for_index +from tests.tools.workspace import doc +from tests.tools.checks import wait_for_index @pytest.mark.workspace("index_features") diff --git a/tests/integration/features/test_index_staleness.py b/tests/integration/features/test_index_staleness.py index 7df1f674b..26bdf3d43 100644 --- a/tests/integration/features/test_index_staleness.py +++ b/tests/integration/features/test_index_staleness.py @@ -3,10 +3,10 @@ import asyncio -from tests.conftest import make_client, shutdown_client -from tests.integration.utils import write_cdb -from tests.integration.utils.cache import cache_root, pin_cache_to_workspace -from tests.integration.utils.wait import MTIME_GRANULARITY +from tests.tools.lifecycle import make_client, shutdown_client +from tests.tools.compile_commands import write_cdb +from tests.tools.workspace import cache_root, pin_cache_to_workspace +from tests.tools.checks import MTIME_GRANULARITY HEADER = "#pragma once\ninline int alpha() { return 1; }\n" CLOSED_TU = '#include "header.h"\nint use() { return alpha(); }\n' diff --git a/tests/integration/features/test_query_freshness.py b/tests/integration/features/test_query_freshness.py index 81b1d3d9b..a65e87b53 100644 --- a/tests/integration/features/test_query_freshness.py +++ b/tests/integration/features/test_query_freshness.py @@ -1,8 +1,8 @@ """Navigation right after an edit must resolve against the edited buffer: the server settles the file's compile before answering, with no timeout.""" -from tests.integration.utils import write_cdb -from tests.integration.utils.workspace import did_change +from tests.tools.compile_commands import write_cdb +from tests.tools.workspace import did_change SOURCE_V1 = "int foo() { return 1; }\nint main() { return foo(); }\n" diff --git a/tests/integration/features/test_server.py b/tests/integration/features/test_server.py index 879fa4309..6b28c5028 100644 --- a/tests/integration/features/test_server.py +++ b/tests/integration/features/test_server.py @@ -10,9 +10,9 @@ Range, ) -from tests.integration.utils import doc -from tests.integration.utils.wait import SETTLE_TIME -from tests.integration.utils.workspace import did_change +from tests.tools.workspace import doc +from tests.tools.checks import SETTLE_TIME +from tests.tools.workspace import did_change @pytest.mark.workspace("hello_world") diff --git a/tests/integration/lifecycle/test_anomaly.py b/tests/integration/lifecycle/test_anomaly.py index 3c2948d25..908ee7dd9 100644 --- a/tests/integration/lifecycle/test_anomaly.py +++ b/tests/integration/lifecycle/test_anomaly.py @@ -13,9 +13,9 @@ import pytest -from tests.conftest import make_client, shutdown_client -from tests.integration.utils import write_cdb -from tests.integration.utils.assertions import ( +from tests.tools.lifecycle import make_client, shutdown_client +from tests.tools.compile_commands import write_cdb +from tests.tools.checks import ( anomalies_in_log_files, anomalies_in_log_messages, ) diff --git a/tests/integration/lifecycle/test_config.py b/tests/integration/lifecycle/test_config.py index 6359d3d09..a60afbe35 100644 --- a/tests/integration/lifecycle/test_config.py +++ b/tests/integration/lifecycle/test_config.py @@ -8,11 +8,11 @@ import pytest from lsprotocol.types import DiagnosticSeverity -from tests.conftest import make_client, shutdown_client -from tests.integration.utils.assertions import ( +from tests.tools.lifecycle import make_client, shutdown_client +from tests.tools.checks import assert_no_anomaly +from tests.tools.checks import ( assert_clean_compile, assert_has_errors, - assert_no_anomaly, get_errors, ) diff --git a/tests/integration/lifecycle/test_file_operation.py b/tests/integration/lifecycle/test_file_operation.py index c74406ff5..01091f18b 100644 --- a/tests/integration/lifecycle/test_file_operation.py +++ b/tests/integration/lifecycle/test_file_operation.py @@ -12,9 +12,9 @@ VersionedTextDocumentIdentifier, ) -from tests.integration.utils import doc -from tests.integration.utils.wait import IDLE_TIMEOUT -from tests.integration.utils.workspace import did_change +from tests.tools.workspace import doc +from tests.tools.checks import IDLE_TIMEOUT +from tests.tools.workspace import did_change @pytest.mark.workspace("hello_world") diff --git a/tests/integration/lifecycle/test_protocol_edges.py b/tests/integration/lifecycle/test_protocol_edges.py index 1f2c31e53..5fa8a6770 100644 --- a/tests/integration/lifecycle/test_protocol_edges.py +++ b/tests/integration/lifecycle/test_protocol_edges.py @@ -16,10 +16,11 @@ VersionedTextDocumentIdentifier, ) -from tests.conftest import check_no_anomaly, shutdown_client -from tests.integration.utils.assertions import get_errors, guidance_messages -from tests.integration.utils.client import CliceClient -from tests.integration.utils.workspace import did_change, write_cdb, write_source +from tests.tools.lifecycle import check_no_anomaly, shutdown_client +from tests.tools.checks import get_errors, guidance_messages +from tests.tools.client import CliceClient +from tests.tools.compile_commands import write_cdb +from tests.tools.workspace import did_change, write_source TEST_TOML = ( '[project]\ncache_dir = "${workspace}/.clice"\nenable_indexing = false\n' diff --git a/tests/integration/lifecycle/test_protocol_robustness.py b/tests/integration/lifecycle/test_protocol_robustness.py index d3d00f29b..f9a718b41 100644 --- a/tests/integration/lifecycle/test_protocol_robustness.py +++ b/tests/integration/lifecycle/test_protocol_robustness.py @@ -6,8 +6,12 @@ import pytest from lsprotocol.types import InitializeParams -from tests.integration.utils.injection import build_params -from tests.replay import SERVER_REQUEST_DEFAULTS, read_lsp_message, write_lsp_message +from tests.tools.injection import build_params +from tests.tools.replay import ( + SERVER_REQUEST_DEFAULTS, + read_lsp_message, + write_lsp_message, +) INJECTION_FLOOR = 5 diff --git a/tests/integration/modules/test_modules.py b/tests/integration/modules/test_modules.py index fdb9fdd65..44e5bfde5 100644 --- a/tests/integration/modules/test_modules.py +++ b/tests/integration/modules/test_modules.py @@ -4,7 +4,7 @@ import shutil import pytest -from tests.cdb import generate_cdb +from tests.tools.compile_commands import generate_cdb from lsprotocol.types import ( DidOpenTextDocumentParams, HoverParams, @@ -13,8 +13,8 @@ TextDocumentItem, ) -from tests.integration.utils.assertions import assert_clean_compile, assert_has_errors -from tests.integration.utils.wait import IDLE_TIMEOUT, wait_for_index +from tests.tools.checks import assert_clean_compile, assert_has_errors +from tests.tools.checks import IDLE_TIMEOUT, wait_for_index @pytest.mark.workspace("modules/single_module_no_deps") diff --git a/tests/integration/stress/test_eviction.py b/tests/integration/stress/test_eviction.py index 35b14593f..b80a8e31c 100644 --- a/tests/integration/stress/test_eviction.py +++ b/tests/integration/stress/test_eviction.py @@ -1,7 +1,7 @@ """Worker document eviction: opening more files than a stateful worker's LRU cap must not silently break features on the evicted documents.""" -from tests.integration.utils import write_cdb +from tests.tools.compile_commands import write_cdb FILE_COUNT = 18 # one stateful worker holds at most 16 compiled documents diff --git a/tests/integration/stress/test_rapid_edit.py b/tests/integration/stress/test_rapid_edit.py index 7c32a08f4..16a38bc0c 100644 --- a/tests/integration/stress/test_rapid_edit.py +++ b/tests/integration/stress/test_rapid_edit.py @@ -9,9 +9,9 @@ Position, ) -from tests.integration.utils import doc -from tests.integration.utils.wait import SETTLE_TIME -from tests.integration.utils.workspace import did_change +from tests.tools.workspace import doc +from tests.tools.checks import SETTLE_TIME +from tests.tools.workspace import did_change @pytest.mark.workspace("hello_world") diff --git a/tests/integration/utils/__init__.py b/tests/integration/utils/__init__.py deleted file mode 100644 index e51135231..000000000 --- a/tests/integration/utils/__init__.py +++ /dev/null @@ -1,40 +0,0 @@ -"""Shared utilities for clice integration tests.""" - -from tests.integration.utils.client import CliceClient -from tests.integration.utils.workspace import ( - doc, - get_field, - write_cdb, - write_entries, - write_source, -) -from tests.integration.utils.assertions import ( - assert_no_errors, - assert_has_errors, - assert_diagnostics_count, -) -from tests.integration.utils.wait import wait_for_recompile, wait_for_index -from tests.integration.utils.cache import ( - list_pch_files, - list_pcm_files, - list_tmp_files, - read_cache_json, -) - -__all__ = [ - "get_field", - "write_entries", - "CliceClient", - "doc", - "write_cdb", - "write_source", - "assert_no_errors", - "assert_has_errors", - "assert_diagnostics_count", - "wait_for_recompile", - "wait_for_index", - "list_pch_files", - "list_pcm_files", - "list_tmp_files", - "read_cache_json", -] diff --git a/tests/integration/utils/wait.py b/tests/integration/utils/wait.py deleted file mode 100644 index b0807eb94..000000000 --- a/tests/integration/utils/wait.py +++ /dev/null @@ -1,78 +0,0 @@ -"""Wait and polling helpers for integration tests.""" - -import asyncio - -from lsprotocol.types import ( - HoverParams, - Position, - TextDocumentIdentifier, - WorkspaceSymbolParams, -) - -# Standard timing constants — use these instead of hardcoded sleep values. -MTIME_GRANULARITY = 1.1 # Filesystem mtime precision (1s on some FSes, +0.1 margin) -SETTLE_TIME = 0.5 # Time for the server to stabilize after an operation -IDLE_TIMEOUT = 5.0 # Idle soak time in lifecycle tests - - -async def wait_for_recompile(client, uri: str, *, timeout: float = 60.0) -> None: - """Trigger recompilation via hover and wait for fresh diagnostics. - - Useful after didChange or on-disk file modifications. Sends a hover - request at (0,0) to trigger ensure_compiled(), then waits for the - resulting diagnostics notification. - """ - event = client.wait_for_diagnostics(uri) - await client.text_document_hover_async( - HoverParams( - text_document=TextDocumentIdentifier(uri=uri), - position=Position(line=0, character=0), - ) - ) - await asyncio.wait_for(event.wait(), timeout=timeout) - - -async def wait_for_index( - client, - uri: str, - symbol_name: str = "add", - *, - timeout: int = 30, -) -> bool: - """Poll workspace/symbol until a specific symbol appears in the index. - - Sends a hover to trigger compilation/indexing, then polls every second. - Returns True if the symbol was found, False on timeout. - """ - await client.text_document_hover_async( - HoverParams( - text_document=TextDocumentIdentifier(uri=uri), - position=Position(line=0, character=0), - ) - ) - - for _ in range(timeout): - result = await client.workspace_symbol_async( - WorkspaceSymbolParams(query=symbol_name) - ) - if result and any(s.name == symbol_name for s in result): - return True - await asyncio.sleep(1) - return False - - -async def reference_uris(client, uri: str, line: int, character: int) -> list[str]: - """URIs of the references at a position (declaration excluded).""" - refs = await client.references_at(uri, line, character, include_declaration=False) - return [ref.uri for ref in (refs or [])] - - -async def wait_for_reference( - client, uri: str, line: int, character: int, expected_uri: str, timeout: int = 30 -) -> bool: - """Poll references at a position until expected_uri shows up.""" - for _ in range(timeout): - if expected_uri in await reference_uris(client, uri, line, character): - return True - await asyncio.sleep(1) - return False diff --git a/tests/integration/utils/workspace.py b/tests/integration/utils/workspace.py deleted file mode 100644 index 4333a8032..000000000 --- a/tests/integration/utils/workspace.py +++ /dev/null @@ -1,97 +0,0 @@ -"""Workspace and file utilities for integration tests.""" - -import json -from pathlib import Path - -from lsprotocol.types import ( - DidChangeTextDocumentParams, - TextDocumentContentChangeWholeDocument, - TextDocumentIdentifier, - VersionedTextDocumentIdentifier, -) - - -def doc(uri: str) -> TextDocumentIdentifier: - """Create a TextDocumentIdentifier from a URI string.""" - return TextDocumentIdentifier(uri=uri) - - -def write_cdb( - workspace: Path, - files: list[str], - *, - extra_args: list[str] | None = None, - std: str = "c++17", -) -> None: - """Write a compile_commands.json for the given source files. - - Args: - workspace: Root directory of the workspace. - files: List of source file names (relative to workspace). - extra_args: Additional compiler arguments (e.g. ["-DFOO", "-I/bar"]). - std: C++ standard version (default: c++17). - """ - entries = [] - for f in files: - args = ["clang++", f"-std={std}", "-fsyntax-only"] - if extra_args: - args.extend(extra_args) - args.append(str(workspace / f)) - entries.append( - { - "directory": str(workspace), - "file": str(workspace / f), - "arguments": args, - } - ) - (workspace / "compile_commands.json").write_text(json.dumps(entries, indent=2)) - - -def write_source(workspace: Path, name: str, content: str) -> Path: - """Write a source file to the workspace directory. Returns the file path.""" - path = workspace / name - path.parent.mkdir(parents=True, exist_ok=True) - path.write_text(content) - return path - - -def did_change(client, uri: str, version: int, text: str) -> None: - """Send a didChange notification with whole-document replacement.""" - client.text_document_did_change( - DidChangeTextDocumentParams( - text_document=VersionedTextDocumentIdentifier(uri=uri, version=version), - content_changes=[TextDocumentContentChangeWholeDocument(text=text)], - ) - ) - - -def get_field(obj, key, default=None): - """Read a field from a dict or attribute-style LSP response object.""" - if isinstance(obj, dict): - return obj.get(key, default) - return getattr(obj, key, default) - - -def write_entries(workspace, entries): - """Write a compile_commands.json with per-file extra arguments. - - Args: - workspace: Root directory of the workspace. - entries: List of (file_name, extra_args) pairs; a file may appear - multiple times to model multi-configuration projects. - """ - data = [ - { - "directory": str(workspace), - "file": str(workspace / f), - "arguments": [ - "clang++", - "-std=c++17", - "-fsyntax-only", - *args, - str(workspace / f), - ], - } - for f, args in entries - ] - (workspace / "compile_commands.json").write_text(json.dumps(data)) diff --git a/tests/tools/__init__.py b/tests/tools/__init__.py new file mode 100644 index 000000000..e69de29bb diff --git a/tests/integration/utils/assertions.py b/tests/tools/checks.py similarity index 55% rename from tests/integration/utils/assertions.py rename to tests/tools/checks.py index 73da2c183..384aef84c 100644 --- a/tests/integration/utils/assertions.py +++ b/tests/tools/checks.py @@ -1,9 +1,86 @@ -"""Diagnostic and anomaly assertion helpers for integration tests.""" +"""Observation and assertion helpers: diagnostics, anomalies, waits.""" +import asyncio import re from pathlib import Path -from lsprotocol.types import Diagnostic, DiagnosticSeverity +from lsprotocol.types import ( + Diagnostic, + DiagnosticSeverity, + HoverParams, + Position, + TextDocumentIdentifier, + WorkspaceSymbolParams, +) + +# Standard timing constants — use these instead of hardcoded sleep values. +MTIME_GRANULARITY = 1.1 # Filesystem mtime precision (1s on some FSes, +0.1 margin) +SETTLE_TIME = 0.5 # Time for the server to stabilize after an operation +IDLE_TIMEOUT = 5.0 # Idle soak time in lifecycle tests + + +async def wait_for_recompile(client, uri: str, *, timeout: float = 60.0) -> None: + """Trigger recompilation via hover and wait for fresh diagnostics. + + Useful after didChange or on-disk file modifications. Sends a hover + request at (0,0) to trigger ensure_compiled(), then waits for the + resulting diagnostics notification. + """ + event = client.wait_for_diagnostics(uri) + await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) + ) + await asyncio.wait_for(event.wait(), timeout=timeout) + + +async def wait_for_index( + client, + uri: str, + symbol_name: str = "add", + *, + timeout: int = 30, +) -> bool: + """Poll workspace/symbol until a specific symbol appears in the index. + + Sends a hover to trigger compilation/indexing, then polls every second. + Returns True if the symbol was found, False on timeout. + """ + await client.text_document_hover_async( + HoverParams( + text_document=TextDocumentIdentifier(uri=uri), + position=Position(line=0, character=0), + ) + ) + + for _ in range(timeout): + result = await client.workspace_symbol_async( + WorkspaceSymbolParams(query=symbol_name) + ) + if result and any(s.name == symbol_name for s in result): + return True + await asyncio.sleep(1) + return False + + +async def reference_uris(client, uri: str, line: int, character: int) -> list[str]: + """URIs of the references at a position (declaration excluded).""" + refs = await client.references_at(uri, line, character, include_declaration=False) + return [ref.uri for ref in (refs or [])] + + +async def wait_for_reference( + client, uri: str, line: int, character: int, expected_uri: str, timeout: int = 30 +) -> bool: + """Poll references at a position until expected_uri shows up.""" + for _ in range(timeout): + if expected_uri in await reference_uris(client, uri, line, character): + return True + await asyncio.sleep(1) + return False + ANOMALY_PATTERN = re.compile(r"\[anomaly:([A-Za-z]+)\]") diff --git a/tests/integration/utils/client.py b/tests/tools/client.py similarity index 92% rename from tests/integration/utils/client.py rename to tests/tools/client.py index 573f4797e..fda469e83 100644 --- a/tests/integration/utils/client.py +++ b/tests/tools/client.py @@ -89,8 +89,6 @@ def on_progress(params: ProgressParams) -> None: token = str(params.token) if isinstance(params.token, int) else params.token self.progress_events.append({"token": token, "value": params.value}) - # ── URI helpers ────────────────────────────────────────────────── - @staticmethod def normalize_uri(uri: str) -> str: return unquote(uri) @@ -98,8 +96,6 @@ def normalize_uri(uri: str) -> str: def path_to_uri(self, filepath: Path) -> str: return self.normalize_uri(filepath.as_uri()) - # ── Lifecycle ──────────────────────────────────────────────────── - async def initialize( self, workspace: Path, @@ -109,7 +105,15 @@ async def initialize( if initialization_options is None: initialization_options = {} project = dict(initialization_options.get("project", {})) - project.setdefault("cache_dir", str(workspace / ".clice")) + # 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 pass their own + # counts via initialization_options. + project.setdefault("stateless_worker_count", 1) + project.setdefault("stateful_worker_count", 1) initialization_options["project"] = project # Disable the stat-polling loops: tests drive ticks deterministically # through the clice/internal/poll hook instead. @@ -130,7 +134,6 @@ async def initialize( self.workspace = workspace return result - # ── Server process control ─────────────────────────────────────── # Single home for the pygls internals these wrap; tests must not poke # at _server/_stop_event/_async_tasks directly. @@ -152,8 +155,6 @@ async def stop_io(self) -> None: # Wait the cancellations out so no task outlives the test teardown. await asyncio.gather(*self._async_tasks, return_exceptions=True) - # ── Document operations ────────────────────────────────────────── - def open(self, filepath: Path, version: int = 0) -> tuple[str, str]: """Open a text document. Returns (normalized_uri, content).""" content = filepath.read_bytes().decode("utf-8") @@ -173,8 +174,6 @@ def close(self, uri: str) -> None: DidCloseTextDocumentParams(text_document=TextDocumentIdentifier(uri=uri)) ) - # ── Diagnostics ────────────────────────────────────────────────── - def wait_for_diagnostics(self, uri: str) -> asyncio.Event: uri = self.normalize_uri(uri) if uri not in self.diagnostics_events: @@ -192,8 +191,6 @@ async def wait_diagnostics(self, uri: str, timeout: float = 30.0) -> None: return await asyncio.wait_for(event.wait(), timeout=timeout) - # ── Compile & wait ─────────────────────────────────────────────── - async def open_and_wait( self, filepath: Path, timeout: float = 60.0 ) -> tuple[str, str]: @@ -209,8 +206,6 @@ async def open_and_wait( await asyncio.wait_for(event.wait(), timeout=timeout) return uri, content - # ── Feature request shortcuts ──────────────────────────────────── - async def hover_at( self, uri: str, line: int, character: int, *, timeout: float = 30.0 ): @@ -420,8 +415,6 @@ async def format_range(self, uri: str, range_: Range, *, timeout: float = 30.0): timeout=timeout, ) - # ── Extension protocol ─────────────────────────────────────────── - async def query_context( self, uri: str, *, offset: int | None = None, timeout: float = 30.0 ): diff --git a/tests/cdb.py b/tests/tools/compile_commands.py similarity index 66% rename from tests/cdb.py rename to tests/tools/compile_commands.py index 9c3f5bc49..06ded833f 100644 --- a/tests/cdb.py +++ b/tests/tools/compile_commands.py @@ -1,7 +1,7 @@ """Compilation database generation for test fixtures. Stdlib-only so it can be used both by pytest (conftest.py) and by -standalone scripts (tests/editors/prepare.py) without pulling in +standalone scripts (tests/tools/prepare.py) without pulling in pytest/pygls dependencies. """ @@ -16,7 +16,7 @@ def generate_cdb(workspace: Path) -> None: cmake = shutil.which("cmake") if cmake is None: raise RuntimeError("cmake executable not found in PATH") - toolchain = Path(__file__).resolve().parent.parent / "cmake" / "toolchain.cmake" + toolchain = Path(__file__).resolve().parents[2] / "cmake" / "toolchain.cmake" cmd = [ cmake, "-G", @@ -113,3 +113,59 @@ def entry(directory: Path, source: Path, extra_args: list[str] | None = None): entries.append(entry(pt_dir, src)) if entries: write(pt_dir, entries) + + +def write_cdb( + workspace: Path, + files: list[str], + *, + extra_args: list[str] | None = None, + std: str = "c++17", +) -> None: + """Write a compile_commands.json for the given source files. + + Args: + workspace: Root directory of the workspace. + files: List of source file names (relative to workspace). + extra_args: Additional compiler arguments (e.g. ["-DFOO", "-I/bar"]). + std: C++ standard version (default: c++17). + """ + entries = [] + for f in files: + args = ["clang++", f"-std={std}", "-fsyntax-only"] + if extra_args: + args.extend(extra_args) + args.append(str(workspace / f)) + entries.append( + { + "directory": str(workspace), + "file": str(workspace / f), + "arguments": args, + } + ) + (workspace / "compile_commands.json").write_text(json.dumps(entries, indent=2)) + + +def write_entries(workspace, entries): + """Write a compile_commands.json with per-file extra arguments. + + Args: + workspace: Root directory of the workspace. + entries: List of (file_name, extra_args) pairs; a file may appear + multiple times to model multi-configuration projects. + """ + data = [ + { + "directory": str(workspace), + "file": str(workspace / f), + "arguments": [ + "clang++", + "-std=c++17", + "-fsyntax-only", + *args, + str(workspace / f), + ], + } + for f, args in entries + ] + (workspace / "compile_commands.json").write_text(json.dumps(data)) diff --git a/tests/integration/utils/injection.py b/tests/tools/injection.py similarity index 100% rename from tests/integration/utils/injection.py rename to tests/tools/injection.py diff --git a/tests/tools/lifecycle.py b/tests/tools/lifecycle.py new file mode 100644 index 000000000..0242d0f88 --- /dev/null +++ b/tests/tools/lifecycle.py @@ -0,0 +1,145 @@ +"""Server lifecycle helpers: spawn, graceful shutdown, clean-exit gates.""" + +import asyncio +import os +import socket +from pathlib import Path + +import pytest + +from tests.tools.checks import assert_no_anomaly +from tests.tools.client import CliceClient + +SANITIZER_MARKERS = ( + "AddressSanitizer", + "LeakSanitizer", + "MemorySanitizer", + "ThreadSanitizer", + "UndefinedBehaviorSanitizer", + "==ERROR:", + "runtime error:", +) + +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}") + + +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 + + +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) + + +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 diff --git a/tests/prepare.py b/tests/tools/prepare.py similarity index 93% rename from tests/prepare.py rename to tests/tools/prepare.py index 299b0ebc2..852455070 100644 --- a/tests/prepare.py +++ b/tests/tools/prepare.py @@ -1,6 +1,6 @@ """Prepare test data fixtures for editor E2E tests. -Usage: python tests/prepare.py [ ...] +Usage: python tests/tools/prepare.py [ ...] Each fixture is a subdirectory of tests/data. Fixtures with a CMakeLists.txt get compile_commands.json generated via CMake; plain @@ -13,10 +13,10 @@ import sys from pathlib import Path -REPO_ROOT = Path(__file__).resolve().parents[1] +REPO_ROOT = Path(__file__).resolve().parents[2] sys.path.insert(0, str(REPO_ROOT)) -from tests.cdb import generate_cdb, generate_test_data_cdbs # noqa: E402 +from tests.tools.compile_commands import generate_cdb, generate_test_data_cdbs # noqa: E402 def xdg_cache_dir(workspace: Path) -> Path | None: diff --git a/tests/replay.py b/tests/tools/replay.py similarity index 99% rename from tests/replay.py rename to tests/tools/replay.py index ee484b920..6e0032aa4 100644 --- a/tests/replay.py +++ b/tests/tools/replay.py @@ -19,7 +19,7 @@ from pathlib import Path from urllib.parse import quote, unquote -REPO_ROOT = Path(__file__).resolve().parent.parent +REPO_ROOT = Path(__file__).resolve().parents[2] SERVER_REQUEST_DEFAULTS: dict[str, object] = { "window/workDoneProgress/create": None, diff --git a/tests/stress.py b/tests/tools/stress.py similarity index 95% rename from tests/stress.py rename to tests/tools/stress.py index 4730f6f71..9c5c18700 100644 --- a/tests/stress.py +++ b/tests/tools/stress.py @@ -3,16 +3,16 @@ Usage: # Full indexing test on LLVM (wait for completion): - pixi run python tests/stress.py /home/ykiko/workspace/llvm-project \ + pixi run python tests/tools/stress.py /home/ykiko/workspace/llvm-project \ --executable build/RelWithDebInfo/bin/clice # Time-limited run (just see how far it gets in 5 minutes): - pixi run python tests/stress.py /home/ykiko/workspace/llvm-project \ + pixi run python tests/tools/stress.py /home/ykiko/workspace/llvm-project \ --executable build/RelWithDebInfo/bin/clice \ --timeout 300 # Custom worker limits: - pixi run python tests/stress.py /home/ykiko/workspace/llvm-project \ + pixi run python tests/tools/stress.py /home/ykiko/workspace/llvm-project \ --executable build/RelWithDebInfo/bin/clice \ --max-stateless 16 """ @@ -27,8 +27,8 @@ import time from pathlib import Path -sys.path.insert(0, str(Path(__file__).parent.parent)) -from tests.integration.utils.client import CliceClient +sys.path.insert(0, str(Path(__file__).resolve().parents[2])) +from tests.tools.client import CliceClient def parse_args(): @@ -266,6 +266,11 @@ async def run_stress_test(args): "cache_dir": str(cache_dir), "logging_dir": str(log_dir), "enable_indexing": True, + # 0 = server-side auto. Explicit so CliceClient.initialize's + # 1-worker test default doesn't apply — this tool exists to + # stress real worker pools. + "stateless_worker_count": 0, + "stateful_worker_count": 0, } } if args.max_stateless > 0: diff --git a/tests/integration/utils/cache.py b/tests/tools/workspace.py similarity index 57% rename from tests/integration/utils/cache.py rename to tests/tools/workspace.py index fd0e641f2..9fb78a640 100644 --- a/tests/integration/utils/cache.py +++ b/tests/tools/workspace.py @@ -1,8 +1,46 @@ -"""Cache inspection helpers for persistent cache tests.""" +"""On-disk workspace helpers: sources, document edits, cache inspection.""" import json from pathlib import Path +from lsprotocol.types import ( + DidChangeTextDocumentParams, + TextDocumentContentChangeWholeDocument, + TextDocumentIdentifier, + VersionedTextDocumentIdentifier, +) + + +def doc(uri: str) -> TextDocumentIdentifier: + """Create a TextDocumentIdentifier from a URI string.""" + return TextDocumentIdentifier(uri=uri) + + +def write_source(workspace: Path, name: str, content: str) -> Path: + """Write a source file to the workspace directory. Returns the file path.""" + path = workspace / name + path.parent.mkdir(parents=True, exist_ok=True) + path.write_text(content) + return path + + +def did_change(client, uri: str, version: int, text: str) -> None: + """Send a didChange notification with whole-document replacement.""" + client.text_document_did_change( + DidChangeTextDocumentParams( + text_document=VersionedTextDocumentIdentifier(uri=uri, version=version), + content_changes=[TextDocumentContentChangeWholeDocument(text=text)], + ) + ) + + +def get_field(obj, key, default=None): + """Read a field from a dict or attribute-style LSP response object.""" + if isinstance(obj, dict): + return obj.get(key, default) + return getattr(obj, key, default) + + # Versioned root of the unified cache store; bump together with # cache_format_version in src/server/state/workspace.h. CACHE_ROOT = Path(".clice") / "cache" / "v3"