9.5 KiB
Test Suite
Summary
Add a comprehensive test suite covering the extension's core modules: LSP client, LSP manager, file sync, tree-sitter engines, tool implementations, and the extension entry point. Tests should run without requiring real LSP servers (except for optional integration tests).
Motivation
The extension has grown to ~6,000 lines across 20+ source files with no automated tests. Both competing LSP extensions have similar gaps — lsp-pi is the exception with ~1,700 lines of tests. Without tests, refactoring is risky and regressions are invisible until a user hits them.
Goals
- Unit tests for all core modules with mocked LSP connections
- Integration tests (optional, gated behind
--integration) that spin up real LSP servers - Test runner that works with
npm testout of the box - CI-friendly — no flaky timeouts, no filesystem side effects, deterministic output
Non-goals
- 100% coverage — focus on logic-heavy modules and known edge cases
- Testing the pi extension API itself — we trust the framework
- Testing TUI rendering pixel-perfectly — just verify the render functions return
Textnodes
Design
Test framework
Use vitest — it supports TypeScript natively (no build step), has built-in mocking, and is fast. Add to devDependencies.
{
"devDependencies": {
"vitest": "^3.0.0"
},
"scripts": {
"test": "vitest run",
"test:watch": "vitest",
"test:integration": "vitest run --project integration"
}
}
File layout
tests/
├── unit/
│ ├── lsp-client.test.ts
│ ├── lsp-manager.test.ts
│ ├── file-sync.test.ts
│ ├── resolve-provider.test.ts
│ ├── tree-sitter/
│ │ ├── parser-manager.test.ts
│ │ ├── pattern-compiler.test.ts
│ │ ├── search-engine.test.ts
│ │ ├── rewrite-engine.test.ts
│ │ ├── symbol-extractor.test.ts
│ │ └── workspace-index.test.ts
│ └── tools/
│ ├── diagnostics.test.ts
│ ├── hover.test.ts
│ ├── definition.test.ts
│ ├── references.test.ts
│ ├── symbols.test.ts
│ ├── rename.test.ts
│ ├── completions.test.ts
│ ├── code-search.test.ts
│ ├── code-rewrite.test.ts
│ ├── code-overview.test.ts
│ ├── signature-help.test.ts # new tool
│ └── code-actions.test.ts # new tool
├── integration/
│ ├── typescript-server.test.ts
│ └── python-server.test.ts
├── fixtures/
│ ├── sample.ts
│ ├── sample.py
│ ├── sample.rs
│ └── sample-project/
│ ├── package.json
│ ├── src/
│ │ ├── index.ts
│ │ └── utils.ts
│ └── tsconfig.json
└── helpers/
├── mock-lsp-client.ts
├── mock-lsp-manager.ts
└── test-utils.ts
Mock strategy
MockLspClient
A fake LspClient that:
- Records all
sendRequestcalls with method + params - Returns pre-configured responses per method (set via
mockResponse(method, response)) - Stores and returns diagnostics via
getDiagnostics(uri)/getAllDiagnostics() - Tracks
didOpen/didChangenotifications
class MockLspClient {
private responses = new Map<string, unknown>();
private diagnostics = new Map<string, Diagnostic[]>();
private requests: Array<{ method: string; params: unknown }> = [];
mockResponse(method: string, response: unknown) { ... }
setDiagnostics(uri: string, diags: Diagnostic[]) { ... }
async sendRequest<R>(method: string, params: unknown): Promise<R> {
this.requests.push({ method, params });
return this.responses.get(method) as R;
}
getRequests(method?: string) { ... }
}
MockLspManager
Wraps MockLspClient and provides:
getClientForFile(path)→ returns the mock clientgetFileUri(path)/resolvePath(path)→ deterministic pathsgetLanguageId(path)→ based on extensiongetRunningClient(languageId)→ returns the mock clientgetUnavailableReason(path)→ returns a test message
Test categories
1. LSP Client (lsp-client.test.ts)
- JSON-RPC message framing (Content-Length header parsing)
- Request/response correlation (matching IDs)
- Notification handling (
textDocument/publishDiagnostics) - Connection error handling and reconnection
- Graceful shutdown sequence
- Timeout behavior for pending requests
2. LSP Manager (lsp-manager.test.ts)
- Server config resolution (built-in defaults, custom overrides)
- Language detection from file extension
- Lazy server startup (only when
getClientForFileis called) - File URI generation (handles spaces, special characters)
- Multiple servers for different languages
- Brazil workspace detection and bemol integration
- Daemon mode (shared server lifecycle)
3. File Sync (file-sync.test.ts)
handleFileRead→ sendsdidOpenwith correct URI and contenthandleFileWrite→ sendsdidChangewith incremented version- Deduplication (multiple reads of the same file don't re-open)
- Version tracking consistency
- Tree-sitter integration (parse/index updates on file change)
4. Tree-sitter modules
parser-manager.test.ts
- WASM init lifecycle
- Parse TypeScript, Python, Rust, Go, Java source
- Incremental re-parse after edit
- Unknown language handling
pattern-compiler.test.ts
$NAMEsingle-node metavariable compilation$$$NAMEvariadic metavariable compilation- Literal node preservation
- Expression wrapping for partial patterns
- Error cases (unparseable patterns)
search-engine.test.ts
- Single-file pattern matching
- Multi-file directory scanning
- Metavariable capture extraction
max_resultslimiting.gitignorerespecting
rewrite-engine.test.ts
- Simple substitution (
$A → $A) - Multi-capture replacement (
$A.$B → $B.$A) - Variadic replacement (
$$$ARGS) - Bottom-up application (offset preservation)
- Dry-run vs. apply modes
symbol-extractor.test.ts
- Function/method/class/interface extraction per language
- Nested symbol handling (methods inside classes)
- Signature text extraction
- Syntax error detection
workspace-index.test.ts
- Index build from fixture project
- Symbol search (exact and fuzzy)
- Incremental update on file change
- Skip patterns (
node_modules,.git, etc.)
5. Tool tests
Each tool test follows the same pattern:
- Create a
MockLspManagerwith pre-configured responses - Call the tool's
executefunction with test params - Assert on the returned
contenttext anddetailsobject - Verify
renderCallreturns aTextnode with expected content - Verify
renderResultreturns aTextnode for both partial and complete states
Example for hover.test.ts:
describe("lsp_hover", () => {
it("formats MarkupContent hover response", async () => {
const mockManager = createMockManager();
mockManager.client.mockResponse("textDocument/hover", {
contents: { kind: "markdown", value: "```ts\nfunction foo(): void\n```" },
});
const tool = createHoverTool(mockManager);
const result = await tool.execute("call-1", { path: "test.ts", line: 5, character: 10 });
expect(result.content[0].text).toContain("function foo(): void");
expect(result.details.hasResult).toBe(true);
});
it("falls back to tree-sitter when no LSP", async () => { ... });
it("returns unavailable message for unknown language", async () => { ... });
it("handles LSP error gracefully", async () => { ... });
it("renderCall shows file:line:col", () => { ... });
it("renderResult shows truncated hover on collapse", () => { ... });
});
6. Integration tests
Gated behind --project integration flag. Require real servers installed:
typescript-server.test.ts
- Start
typescript-language-server - Open a TypeScript file
- Get diagnostics, hover, definition, references, completions
- Verify results against known fixture content
- Clean shutdown
python-server.test.ts
- Start
pyright-langserver - Same test pattern for Python fixtures
These tests have a 30-second timeout and skip if the server binary isn't found.
Configuration
vitest.config.ts:
import { defineConfig } from "vitest/config";
export default defineConfig({
test: {
include: ["tests/unit/**/*.test.ts"],
testTimeout: 10000,
projects: [
{
name: "unit",
include: ["tests/unit/**/*.test.ts"],
},
{
name: "integration",
include: ["tests/integration/**/*.test.ts"],
testTimeout: 30000,
},
],
},
});
Coverage targets
- Unit tests: aim for 80%+ line coverage on core modules
- Tools: every tool should have at least 3 tests (happy path, error, edge case)
- Tree-sitter: heavy coverage on pattern-compiler and search-engine (complex logic)
- Integration: smoke-test level — verify end-to-end flow works
Open questions
- Snapshot testing — should
renderCall/renderResulttests use snapshot assertions for theTextoutput? Pros: catches regressions in formatting. Cons: noisy diffs when theme changes. - Fixture management — should fixtures be inline strings or separate files? Inline is easier to read in tests; files are better for integration tests that need real project structure.
- Mock fidelity — how closely should
MockLspClientmimic real JSON-RPC behavior? A thin mock (just record/playback) is easier to maintain but may miss framing bugs.