diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh index ca9d0f1b..474ed65e 100755 --- a/.devcontainer/post-create.sh +++ b/.devcontainer/post-create.sh @@ -25,7 +25,8 @@ echo " - stylua (formatter)" echo " - All other development tools" echo "" echo "You can also run development commands directly:" -echo " - make # Run full validation (format, lint, test)" -echo " - make test # Run tests" -echo " - make check # Run linter" -echo " - make format # Format code" +echo " - make # Run full validation (format, lint, test)" +echo " - make test # Run tests (fast, no coverage)" +echo " - make test-cov # Run tests with coverage (luacov)" +echo " - make check # Run linter" +echo " - make format # Format code" diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index d1a791cc..07f2c692 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -52,7 +52,7 @@ jobs: run: nix develop .#ci -c make check - name: Run tests - run: nix develop .#ci -c make test + run: nix develop .#ci -c make test-cov - name: Check formatting run: nix flake check @@ -60,7 +60,10 @@ jobs: - name: Generate coverage report run: | if [ -f "luacov.stats.out" ]; then - nix develop .#ci -c luacov + # `make test-cov` runs `luacov` already, but keep this as a fallback. + if [ ! -f "luacov.report.out" ]; then + nix develop .#ci -c luacov + fi echo "Creating lcov.info from luacov.report.out" { diff --git a/.luacov b/.luacov new file mode 100644 index 00000000..96bd9e34 --- /dev/null +++ b/.luacov @@ -0,0 +1,15 @@ +return { + statsfile = "luacov.stats.out", + reportfile = "luacov.report.out", + + -- Only collect coverage for the plugin itself; excluding the test suite and + -- mocks makes `--coverage` runs significantly faster. + include = { + "lua/claudecode/", + "plugin/", + }, + exclude = { + "tests/", + "fixtures/", + }, +} diff --git a/AGENTS.md b/AGENTS.md index e4f4d014..4cbdb01f 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -12,10 +12,12 @@ - `make check`: Syntax checks + `luacheck` on `lua/` and `tests/`. - `make format`: Format with StyLua (via Nix `nix fmt` in this repo). -- `make test`: Run Busted tests with coverage (outputs `luacov.stats.out`). +- `make test`: Run Busted tests (fast, no coverage). +- `make test-cov`: Run Busted tests with coverage (generates `luacov.stats.out` + `luacov.report.out`). - `make clean`: Remove coverage artifacts. Examples: - Run all tests: `make test` +- Run all tests with coverage: `make test-cov` - Run one file: `busted -v tests/unit/terminal_spec.lua` ## Coding Style & Naming Conventions @@ -32,13 +34,13 @@ - File naming: `*_spec.lua` or `*_test.lua` under `tests/unit` or `tests/integration`. - Mocks/helpers: place in `tests/mocks` and `tests/helpers`; prefer pure‑Lua mocks. - Coverage: keep high signal; exercise server, tools, diff, and terminal paths. -- Quick tip: prefer `make test` (it sets `LUA_PATH` and coverage flags). +- Quick tip: prefer `make test` (fast) or `make test-cov` (coverage) since they set `LUA_PATH` consistently. ## Commit & Pull Request Guidelines - Commits: Use Conventional Commits (e.g., `feat:`, `fix:`, `docs:`, `test:`, `chore:`). Keep messages imperative and scoped. - PRs: include a clear description, linked issues, repro steps, and tests. Update docs (`README.md`, `ARCHITECTURE.md`, or `DEVELOPMENT.md`) when behavior changes. -- Pre-flight: run `make format`, `make check`, and `make test`. Attach screenshots or terminal recordings for UX-visible changes (diff flows, terminal behavior). +- Pre-flight: run `make format`, `make check`, and `make test` (optionally `make test-cov` for CI-like coverage). Attach screenshots or terminal recordings for UX-visible changes (diff flows, terminal behavior). ## Security & Configuration Tips diff --git a/CLAUDE.md b/CLAUDE.md index 0298fc12..b7c82d98 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,9 +10,10 @@ claudecode.nvim - A Neovim plugin that implements the same WebSocket-based MCP p ### Testing -- `make test` - Run all tests using busted with coverage +- `make test` - Run all tests (fast, no coverage) +- `make test-cov` - Run all tests with coverage (luacov) - `busted tests/unit/specific_spec.lua` - Run specific test file -- `busted --coverage -v` - Run tests with coverage +- `busted --coverage -v` - Run tests with coverage (prefer `make test-cov`) ### Code Quality @@ -22,12 +23,12 @@ claudecode.nvim - A Neovim plugin that implements the same WebSocket-based MCP p ### Build Commands -- `make` - **RECOMMENDED**: Run formatting, linting, and testing (complete validation) -- `make all` - Run check and format (default target) -- `make test` - Run all tests using busted with coverage +- `make` / `make all` - **RECOMMENDED**: Run formatting, linting, and tests (fast, no coverage) +- `make test` - Run all tests (fast, no coverage) +- `make test-cov` - Run all tests with coverage (luacov) - `make check` - Check Lua syntax and run luacheck - `make format` - Format code with stylua (or nix fmt if available) -- `make clean` - Remove generated test files +- `make clean` - Remove coverage artifacts - `make help` - Show available commands **Best Practice**: Always use `make` at the end of editing sessions for complete validation. @@ -156,7 +157,7 @@ claudecode.nvim implements **100% feature parity** with Anthropic's official VS ### Protocol Validation -Run `make test` to verify MCP compliance: +Run `make test` (or `make test-cov` for coverage) to verify MCP compliance: - **Tool Format Validation**: All tools return proper MCP structure - **Schema Compliance**: JSON schemas validated against VS Code specs @@ -189,12 +190,13 @@ export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$LUA_PATH" busted tests/unit/tools/specific_tool_spec.lua --verbose # Or use make for full validation -make test # Recommended for complete validation +make test # Fast (no coverage) +make test-cov # Coverage (slower) ``` **Coverage Metrics**: -- **320+ tests** covering all MCP tools and core functionality +- **430+ tests** covering all MCP tools and core functionality - **Unit Tests**: Individual tool behavior and error cases - **Integration Tests**: End-to-end MCP protocol flow - **Format Tests**: MCP compliance and VS Code compatibility @@ -463,7 +465,7 @@ error({ ### Code Quality Standards -- **Test Coverage**: Maintain comprehensive test coverage (currently **320+ tests**, 100% success rate) +- **Test Coverage**: Maintain comprehensive test coverage (currently **430+ tests**, 100% success rate) - **Zero Warnings**: All code must pass luacheck with 0 warnings/errors - **MCP Compliance**: All tools must return proper MCP format with JSON-stringified content - **VS Code Compatibility**: New tools must match VS Code extension behavior exactly @@ -473,7 +475,7 @@ error({ ### Development Quality Gates 1. **`make check`** - Syntax and linting (0 warnings required) -2. **`make test`** - All tests passing (320/320 success rate required) +2. **`make test`** - All tests passing (optionally `make test-cov` for coverage) 3. **`make format`** - Consistent code formatting 4. **MCP Validation** - Tools return proper format structure 5. **Integration Test** - End-to-end protocol flow verification diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index 7c0311ad..21091c51 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -81,9 +81,12 @@ claudecode.nvim/ Run tests using: ```bash -# Run all tests +# Run all tests (fast, no coverage) make test +# Run all tests with coverage (writes luacov.report.out) +make test-cov + # Run specific test file nvim --headless -u tests/minimal_init.lua -c "lua require('tests.unit.config_spec')" diff --git a/Makefile b/Makefile index 2c2b329e..cde70f80 100644 --- a/Makefile +++ b/Makefile @@ -1,11 +1,11 @@ -.PHONY: check format test clean +.PHONY: check format test test-cov clean help # Default target all: format check test -# Detect if we are already inside a Nix shell -ifeq (,$(IN_NIX_SHELL)) -NIX_PREFIX := nix develop .#ci -c +# Detect if the required dev tools are already available; otherwise run via Nix. +ifeq (,$(shell command -v busted >/dev/null 2>&1 && command -v luacheck >/dev/null 2>&1 && echo ok)) +NIX_PREFIX := nix develop .\#ci -c else NIX_PREFIX := endif @@ -21,15 +21,24 @@ check: format: nix fmt -# Run tests +# Run tests (fast, no coverage) test: - @echo "Running all tests..." - @export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$$LUA_PATH"; \ - TEST_FILES=$$(find tests -type f -name "*_test.lua" -o -name "*_spec.lua" | sort); \ - echo "Found test files:"; \ - echo "$$TEST_FILES"; \ + @echo "Running all tests (no coverage)..." + @TEST_FILES=$$(find tests -type f \( -name "*_test.lua" -o -name "*_spec.lua" \) | sort); \ if [ -n "$$TEST_FILES" ]; then \ - $(NIX_PREFIX) busted --coverage -v $$TEST_FILES; \ + $(NIX_PREFIX) sh -c 'export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$$LUA_PATH"; busted -v "$$@"' -- $$TEST_FILES; \ + else \ + echo "No test files found"; \ + fi + +# Run tests with coverage +# (Generates luacov.stats.out and luacov.report.out) +test-cov: + @echo "Running all tests with coverage..." + @TEST_FILES=$$(find tests -type f \( -name "*_test.lua" -o -name "*_spec.lua" \) | sort); \ + if [ -n "$$TEST_FILES" ]; then \ + $(NIX_PREFIX) sh -c 'export LUA_PATH="./lua/?.lua;./lua/?/init.lua;./?.lua;./?/init.lua;$$LUA_PATH"; busted --coverage -v "$$@"' -- $$TEST_FILES; \ + $(NIX_PREFIX) luacov; \ else \ echo "No test files found"; \ fi @@ -38,13 +47,14 @@ test: clean: @echo "Cleaning generated files..." @rm -f luacov.report.out luacov.stats.out - @rm -f tests/lcov.info + @rm -f lcov.info tests/lcov.info # Print available commands help: @echo "Available commands:" - @echo " make check - Check for syntax errors" - @echo " make format - Format all files (uses nix fmt or stylua)" - @echo " make test - Run tests" - @echo " make clean - Clean generated files" - @echo " make help - Print this help message" + @echo " make check - Check for syntax errors" + @echo " make format - Format all files (uses nix fmt or stylua)" + @echo " make test - Run tests (fast, no coverage)" + @echo " make test-cov - Run tests with coverage (luacov)" + @echo " make clean - Clean generated files" + @echo " make help - Print this help message" diff --git a/README.md b/README.md index 117cc51d..455e3302 100644 --- a/README.md +++ b/README.md @@ -776,7 +776,7 @@ opts = { ## Contributing -See [DEVELOPMENT.md](./DEVELOPMENT.md) for build instructions and development guidelines. Tests can be run with `make test`. +See [DEVELOPMENT.md](./DEVELOPMENT.md) for build instructions and development guidelines. Tests can be run with `make test` (fast) or `make test-cov` (coverage). ## License diff --git a/lua/claudecode/server/tcp.lua b/lua/claudecode/server/tcp.lua index 4aac69e6..b2c2ef33 100644 --- a/lua/claudecode/server/tcp.lua +++ b/lua/claudecode/server/tcp.lua @@ -1,6 +1,5 @@ ---@brief TCP server implementation using vim.loop local client_manager = require("claudecode.server.client") -local utils = require("claudecode.server.utils") local M = {} @@ -19,20 +18,38 @@ local M = {} ---@param max_port number Maximum port to try ---@return number|nil port Available port number, or nil if none found function M.find_available_port(min_port, max_port) + assert(type(min_port) == "number", "Expected min_port to be a number") + assert(type(max_port) == "number", "Expected max_port to be a number") + + min_port = math.floor(min_port) + max_port = math.floor(max_port) + if min_port > max_port then - return nil -- Or handle error appropriately + return nil end - local ports = {} - for i = min_port, max_port do - table.insert(ports, i) + local range_size = max_port - min_port + 1 + assert(range_size >= 1, "Expected port range to be non-empty") + + local start_offset = 0 + if range_size > 1 then + -- Avoid `math.randomseed` here: it mutates global RNG state and is expensive + -- under coverage. We only need a pseudo-random starting point to avoid always + -- trying min_port first. + local uv = vim.loop + if uv and type(uv.hrtime) == "function" then + start_offset = tonumber(uv.hrtime() % range_size) + elseif uv and type(uv.now) == "function" then + start_offset = uv.now() % range_size + else + start_offset = os.time() % range_size + end end - -- Shuffle the ports - utils.shuffle_array(ports) + -- Try every port in the range exactly once, starting from a pseudo-random point. + for i = 0, range_size - 1 do + local port = min_port + ((start_offset + i) % range_size) - -- Try to bind to a port from the shuffled list - for _, port in ipairs(ports) do local test_server = vim.loop.new_tcp() if test_server then local success = test_server:bind("127.0.0.1", port) @@ -42,7 +59,6 @@ function M.find_available_port(min_port, max_port) return port end end - -- Continue to next port if test_server creation failed or bind failed end return nil diff --git a/lua/claudecode/server/utils.lua b/lua/claudecode/server/utils.lua index 6ae89f55..fae64ac7 100644 --- a/lua/claudecode/server/utils.lua +++ b/lua/claudecode/server/utils.lua @@ -364,57 +364,37 @@ function M.bytes_to_uint64(bytes) return num end ----XOR lookup table for faster operations -local xor_table = {} -for i = 0, 255 do - xor_table[i] = {} - for j = 0, 255 do - local result = 0 - local a, b = i, j - local bit_val = 1 - - while a > 0 or b > 0 do - local a_bit = a % 2 - local b_bit = b % 2 - - if a_bit ~= b_bit then - result = result + bit_val - end - - a = math.floor(a / 2) - b = math.floor(b / 2) - bit_val = bit_val * 2 - end - - xor_table[i][j] = result - end -end - ---Apply XOR mask to payload data ---@param data string The data to mask/unmask ---@param mask string The 4-byte mask ---@return string masked The masked/unmasked data +local bit_bxor = nil + +do + local ok, bit = pcall(require, "bit") + if ok and type(bit.bxor) == "function" then + bit_bxor = bit.bxor + end +end + function M.apply_mask(data, mask) + assert(type(data) == "string", "Expected data to be a string") + assert(type(mask) == "string", "Expected mask to be a string") + assert(#mask == 4, "Expected mask to be 4 bytes") + local result = {} - local mask_bytes = { mask:byte(1, 4) } + local m1, m2, m3, m4 = mask:byte(1, 4) + assert(type(m1) == "number" and type(m2) == "number" and type(m3) == "number" and type(m4) == "number", "Invalid mask") + local mask_bytes = { m1, m2, m3, m4 } + local do_bxor = bit_bxor or bxor for i = 1, #data do local mask_idx = ((i - 1) % 4) + 1 local data_byte = data:byte(i) - result[i] = string.char(xor_table[data_byte][mask_bytes[mask_idx]]) + result[i] = string.char(do_bxor(data_byte, mask_bytes[mask_idx])) end return table.concat(result) end ----Shuffle an array in place using Fisher-Yates algorithm ----@param tbl table The array to shuffle -function M.shuffle_array(tbl) - math.randomseed(os.time()) - for i = #tbl, 2, -1 do - local j = math.random(i) - tbl[i], tbl[j] = tbl[j], tbl[i] - end -end - return M diff --git a/scripts/run_integration_tests_individually.sh b/scripts/run_integration_tests_individually.sh index a0b70b70..95fe4be8 100755 --- a/scripts/run_integration_tests_individually.sh +++ b/scripts/run_integration_tests_individually.sh @@ -3,6 +3,12 @@ # Script to run integration tests individually to avoid plenary test_directory hanging # Each test file is run separately with test_file + +# Re-run the script inside the Nix dev shell once, to avoid per-file nix overhead. +if [[ -z "${IN_NIX_SHELL:-}" ]]; then + echo "Entering nix develop .#ci environment..." + exec nix develop .#ci -c "$0" "$@" +fi set -e echo "=== Running Integration Tests Individually ===" @@ -27,7 +33,7 @@ run_test_file() { temp_output=$(mktemp) # Run the test with timeout - if timeout 30s nix develop .#ci -c nvim --headless -u tests/minimal_init.lua \ + if timeout 30s nvim --headless -u tests/minimal_init.lua \ -c "lua require('plenary.test_harness').test_file('$test_file', {minimal_init = 'tests/minimal_init.lua'})" \ 2>&1 | tee "$temp_output"; then EXIT_CODE=0 diff --git a/tests/mocks/vim.lua b/tests/mocks/vim.lua index 2c1e5b9e..63b2f646 100644 --- a/tests/mocks/vim.lua +++ b/tests/mocks/vim.lua @@ -710,17 +710,26 @@ local vim = { -- Additional missing vim functions wait = function(timeout, condition, interval, fast_only) - -- Optimized mock implementation for faster test execution - local start_time = os.clock() - interval = interval or 10 -- Reduced from 200ms to 10ms for faster polling + assert(timeout == nil or type(timeout) == "number", "Expected timeout to be a number") + if condition ~= nil then + assert(type(condition) == "function", "Expected condition to be a function") + end + timeout = timeout or 1000 + interval = interval or 10 + + -- In this unit test environment, `vim.schedule`/`vim.defer_fn` execute + -- synchronously. Avoid spawning a shell via `os.execute("sleep ...")` here; + -- it is extremely slow and also breaks `os.clock()`-based timing. + if condition and condition() then + return true + end - while (os.clock() - start_time) * 1000 < timeout do + local max_polls = math.max(1, math.floor(timeout / math.max(interval, 1))) + for _ = 1, max_polls do if condition and condition() then return true end - -- Add a small sleep to prevent busy-waiting and reduce CPU usage - os.execute("sleep 0.001") -- 1ms sleep end return false diff --git a/tests/unit/server/utils_spec.lua b/tests/unit/server/utils_spec.lua new file mode 100644 index 00000000..3c4c0fb6 --- /dev/null +++ b/tests/unit/server/utils_spec.lua @@ -0,0 +1,35 @@ +require("tests.busted_setup") + +local utils = require("claudecode.server.utils") + +describe("server.utils.apply_mask", function() + it("XORs with a 4-byte mask and cycles", function() + local data = "Hello" + local mask = string.char(1, 2, 3, 4) + + local masked = utils.apply_mask(data, mask) + + expect(masked).to_be("Igohn") + expect(utils.apply_mask(masked, mask)).to_be(data) + end) + + it("handles empty payloads", function() + expect(utils.apply_mask("", string.char(1, 2, 3, 4))).to_be("") + end) + + it("handles 0x00 and 0xFF bytes", function() + local data = string.char(0, 255, 1, 128) + local mask = string.char(255, 0, 255, 0) + + local masked = utils.apply_mask(data, mask) + expect(masked).to_be(string.char(255, 255, 254, 128)) + expect(utils.apply_mask(masked, mask)).to_be(data) + end) + + it("errors on invalid mask length", function() + local ok, err = pcall(utils.apply_mask, "hi", "a") + + expect(ok).to_be_false() + assert_contains(tostring(err), "Expected mask to be 4 bytes") + end) +end)