mirror of
https://github.com/catlog22/Claude-Code-Workflow.git
synced 2026-03-20 19:03:51 +08:00
Remove outdated tests for CodexLens and LiteLLM client, refactor Smart Search MCP usage tests to use new command structure, and clean up unified vector index tests.
This commit is contained in:
@@ -1,93 +0,0 @@
|
||||
/**
|
||||
* Regression test: CodexLens bootstrap falls back to pip when UV bootstrap fails.
|
||||
*
|
||||
* We simulate a "broken UV" by pointing CCW_UV_PATH to the current Node executable.
|
||||
* `node --version` exits 0 so isUvAvailable() returns true, but `node venv ...` fails,
|
||||
* forcing the bootstrap code to try the pip path.
|
||||
*
|
||||
* This test runs bootstrapVenv in a child process to avoid mutating process-wide
|
||||
* environment variables that could affect other tests.
|
||||
*/
|
||||
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { spawn } from 'node:child_process';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { dirname, join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// repo root: <repo>/ccw/tests -> <repo>
|
||||
const REPO_ROOT = join(__dirname, '..', '..');
|
||||
|
||||
function runNodeEvalModule(script, env) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn(process.execPath, ['--input-type=module', '-e', script], {
|
||||
cwd: REPO_ROOT,
|
||||
env,
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
windowsHide: true,
|
||||
});
|
||||
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
|
||||
child.stdout.on('data', (d) => { stdout += d.toString(); });
|
||||
child.stderr.on('data', (d) => { stderr += d.toString(); });
|
||||
|
||||
child.on('error', (err) => reject(err));
|
||||
child.on('close', (code) => resolve({ code, stdout, stderr }));
|
||||
});
|
||||
}
|
||||
|
||||
describe('CodexLens bootstrap fallback', () => {
|
||||
it('falls back to pip when UV bootstrap fails', { timeout: 10 * 60 * 1000 }, async () => {
|
||||
const dataDir = mkdtempSync(join(tmpdir(), 'codexlens-bootstrap-fallback-'));
|
||||
|
||||
try {
|
||||
const script = `
|
||||
import { bootstrapVenv } from './ccw/dist/tools/codex-lens.js';
|
||||
|
||||
(async () => {
|
||||
const result = await bootstrapVenv();
|
||||
console.log('@@RESULT@@' + JSON.stringify(result));
|
||||
})().catch((e) => {
|
||||
console.error(e?.stack || String(e));
|
||||
process.exit(1);
|
||||
});
|
||||
`;
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
// Isolate test venv + dependencies from user/global CodexLens state.
|
||||
CODEXLENS_DATA_DIR: dataDir,
|
||||
// Make isUvAvailable() return true, but createVenv() fail.
|
||||
CCW_UV_PATH: process.execPath,
|
||||
};
|
||||
|
||||
const { code, stdout, stderr } = await runNodeEvalModule(script, env);
|
||||
assert.equal(code, 0, `bootstrapVenv child process failed:\nSTDOUT:\n${stdout}\nSTDERR:\n${stderr}`);
|
||||
|
||||
const marker = '@@RESULT@@';
|
||||
const idx = stdout.lastIndexOf(marker);
|
||||
assert.ok(idx !== -1, `Missing result marker in stdout:\n${stdout}`);
|
||||
|
||||
const jsonText = stdout.slice(idx + marker.length).trim();
|
||||
const parsed = JSON.parse(jsonText);
|
||||
|
||||
assert.equal(parsed?.success, true, `Expected success=true, got:\n${jsonText}`);
|
||||
assert.ok(Array.isArray(parsed.warnings), 'Expected warnings array on pip fallback result');
|
||||
assert.ok(parsed.warnings.some((w) => String(w).includes('UV bootstrap failed')), `Expected UV failure warning, got: ${JSON.stringify(parsed.warnings)}`);
|
||||
} finally {
|
||||
try {
|
||||
rmSync(dataDir, { recursive: true, force: true });
|
||||
} catch {
|
||||
// Best effort cleanup; leave artifacts only if Windows locks prevent removal.
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,139 +0,0 @@
|
||||
import { after, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { join } from 'node:path';
|
||||
import { tmpdir } from 'node:os';
|
||||
|
||||
const tempDirs = [];
|
||||
|
||||
after(() => {
|
||||
for (const dir of tempDirs) {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('CodexLens CLI compatibility retries', () => {
|
||||
it('builds hidden Python spawn options for CLI invocations', async () => {
|
||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?spawn-opts=${Date.now()}`, import.meta.url).href;
|
||||
const { __testables } = await import(moduleUrl);
|
||||
|
||||
const options = __testables.buildCodexLensSpawnOptions(tmpdir(), 12345);
|
||||
|
||||
assert.equal(options.cwd, tmpdir());
|
||||
assert.equal(options.shell, false);
|
||||
assert.equal(options.timeout, 12345);
|
||||
assert.equal(options.windowsHide, true);
|
||||
assert.equal(options.env.PYTHONIOENCODING, 'utf-8');
|
||||
});
|
||||
|
||||
it('probes Python version without a shell-backed console window', async () => {
|
||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?python-probe=${Date.now()}`, import.meta.url).href;
|
||||
const { __testables } = await import(moduleUrl);
|
||||
const probeCalls = [];
|
||||
|
||||
const version = __testables.probePythonVersion({ command: 'python', args: [], display: 'python' }, (command, args, options) => {
|
||||
probeCalls.push({ command, args, options });
|
||||
return { status: 0, stdout: '', stderr: 'Python 3.11.9\n' };
|
||||
});
|
||||
|
||||
assert.equal(version, 'Python 3.11.9');
|
||||
assert.equal(probeCalls.length, 1);
|
||||
assert.equal(probeCalls[0].command, 'python');
|
||||
assert.deepEqual(probeCalls[0].args, ['--version']);
|
||||
assert.equal(probeCalls[0].options.shell, false);
|
||||
assert.equal(probeCalls[0].options.windowsHide, true);
|
||||
assert.equal(probeCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
||||
});
|
||||
|
||||
it('initializes a tiny index even when CLI emits compatibility conflicts first', async () => {
|
||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?compat=${Date.now()}`, import.meta.url).href;
|
||||
const { checkVenvStatus, executeCodexLens } = await import(moduleUrl);
|
||||
|
||||
const ready = await checkVenvStatus(true);
|
||||
if (!ready.ready) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'codexlens-init-'));
|
||||
tempDirs.push(projectDir);
|
||||
writeFileSync(join(projectDir, 'sample.ts'), 'export const sample = 1;\n');
|
||||
|
||||
const result = await executeCodexLens(['index', 'init', projectDir, '--force'], { timeout: 600000 });
|
||||
|
||||
assert.equal(result.success, true, result.error ?? 'Expected init to succeed');
|
||||
assert.ok((result.output ?? '').length > 0 || (result.warning ?? '').length > 0, 'Expected init output or compatibility warning');
|
||||
});
|
||||
|
||||
it('synthesizes a machine-readable fallback when JSON search output is empty', async () => {
|
||||
const moduleUrl = new URL(`../dist/tools/codex-lens.js?compat-empty=${Date.now()}`, import.meta.url).href;
|
||||
const { __testables } = await import(moduleUrl);
|
||||
|
||||
const normalized = __testables.normalizeSearchCommandResult(
|
||||
{ success: true },
|
||||
{ query: 'missing symbol', cwd: tmpdir(), limit: 5, filesOnly: false },
|
||||
);
|
||||
|
||||
assert.equal(normalized.success, true);
|
||||
assert.match(normalized.warning ?? '', /empty stdout/i);
|
||||
assert.deepEqual(normalized.results, {
|
||||
success: true,
|
||||
result: {
|
||||
query: 'missing symbol',
|
||||
count: 0,
|
||||
results: [],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('returns structured semantic search results for a local embedded workspace', async () => {
|
||||
const codexLensUrl = new URL(`../dist/tools/codex-lens.js?compat-search=${Date.now()}`, import.meta.url).href;
|
||||
const smartSearchUrl = new URL(`../dist/tools/smart-search.js?compat-search=${Date.now()}`, import.meta.url).href;
|
||||
const codexLensModule = await import(codexLensUrl);
|
||||
const smartSearchModule = await import(smartSearchUrl);
|
||||
|
||||
const ready = await codexLensModule.checkVenvStatus(true);
|
||||
if (!ready.ready) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const semantic = await codexLensModule.checkSemanticStatus();
|
||||
if (!semantic.available) {
|
||||
console.log('Skipping: semantic dependencies not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'codexlens-search-'));
|
||||
tempDirs.push(projectDir);
|
||||
writeFileSync(
|
||||
join(projectDir, 'sample.ts'),
|
||||
'export function greet(name) { return `hello ${name}`; }\nexport const sum = (a, b) => a + b;\n',
|
||||
);
|
||||
|
||||
const init = await smartSearchModule.handler({ action: 'init', path: projectDir });
|
||||
assert.equal(init.success, true, init.error ?? 'Expected smart-search init to succeed');
|
||||
|
||||
const embed = await smartSearchModule.handler({
|
||||
action: 'embed',
|
||||
path: projectDir,
|
||||
embeddingBackend: 'local',
|
||||
force: true,
|
||||
});
|
||||
assert.equal(embed.success, true, embed.error ?? 'Expected smart-search embed to succeed');
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'search',
|
||||
path: projectDir,
|
||||
query: 'greet function',
|
||||
mode: 'semantic',
|
||||
format: 'json',
|
||||
});
|
||||
|
||||
assert.equal(result.success, true, result.error ?? 'Expected semantic search compatibility fallback to succeed');
|
||||
const payload = result.results?.result ?? result.results;
|
||||
assert.ok(Array.isArray(payload?.results), 'Expected structured search results payload');
|
||||
assert.ok(payload.results.length > 0, 'Expected at least one structured semantic search result');
|
||||
assert.doesNotMatch(result.error ?? '', /unexpected extra arguments/i);
|
||||
});
|
||||
});
|
||||
@@ -1,485 +0,0 @@
|
||||
/**
|
||||
* Integration Tests for CodexLens with actual file operations
|
||||
*
|
||||
* These tests create temporary files and directories to test
|
||||
* the full indexing and search workflow.
|
||||
*/
|
||||
|
||||
import { describe, it, before, after } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync, mkdirSync, rmSync, writeFileSync, readdirSync } from 'fs';
|
||||
import { tmpdir } from 'os';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Import the codex-lens module
|
||||
const codexLensPath = new URL('../dist/tools/codex-lens.js', import.meta.url).href;
|
||||
|
||||
describe('CodexLens Full Integration Tests', async () => {
|
||||
let codexLensModule;
|
||||
let testDir;
|
||||
let isReady = false;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
codexLensModule = await import(codexLensPath);
|
||||
|
||||
// Check if CodexLens is installed
|
||||
const status = await codexLensModule.checkVenvStatus();
|
||||
isReady = status.ready;
|
||||
|
||||
if (!isReady) {
|
||||
console.log('CodexLens not installed - some integration tests will be skipped');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create temporary test directory
|
||||
testDir = join(tmpdir(), `codexlens-test-${Date.now()}`);
|
||||
mkdirSync(testDir, { recursive: true });
|
||||
|
||||
// Create test Python files
|
||||
writeFileSync(join(testDir, 'main.py'), `
|
||||
"""Main module for testing."""
|
||||
|
||||
def hello_world():
|
||||
"""Say hello to the world."""
|
||||
print("Hello, World!")
|
||||
return "hello"
|
||||
|
||||
def calculate_sum(a, b):
|
||||
"""Calculate sum of two numbers."""
|
||||
return a + b
|
||||
|
||||
class Calculator:
|
||||
"""A simple calculator class."""
|
||||
|
||||
def __init__(self):
|
||||
self.result = 0
|
||||
|
||||
def add(self, value):
|
||||
"""Add value to result."""
|
||||
self.result += value
|
||||
return self.result
|
||||
|
||||
def subtract(self, value):
|
||||
"""Subtract value from result."""
|
||||
self.result -= value
|
||||
return self.result
|
||||
`);
|
||||
|
||||
writeFileSync(join(testDir, 'utils.py'), `
|
||||
"""Utility functions."""
|
||||
|
||||
def format_string(text):
|
||||
"""Format a string."""
|
||||
return text.strip().lower()
|
||||
|
||||
def validate_email(email):
|
||||
"""Validate email format."""
|
||||
return "@" in email and "." in email
|
||||
|
||||
async def fetch_data(url):
|
||||
"""Fetch data from URL (async)."""
|
||||
pass
|
||||
`);
|
||||
|
||||
// Create test JavaScript file
|
||||
writeFileSync(join(testDir, 'app.js'), `
|
||||
/**
|
||||
* Main application module
|
||||
*/
|
||||
|
||||
function initApp() {
|
||||
console.log('App initialized');
|
||||
}
|
||||
|
||||
const processData = async (data) => {
|
||||
return data.map(item => item.value);
|
||||
};
|
||||
|
||||
class Application {
|
||||
constructor(name) {
|
||||
this.name = name;
|
||||
}
|
||||
|
||||
start() {
|
||||
console.log(\`Starting \${this.name}\`);
|
||||
}
|
||||
}
|
||||
|
||||
export { initApp, processData, Application };
|
||||
`);
|
||||
|
||||
console.log(`Test directory created at: ${testDir}`);
|
||||
} catch (err) {
|
||||
console.log('Setup failed:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
// Cleanup test directory
|
||||
if (testDir && existsSync(testDir)) {
|
||||
try {
|
||||
rmSync(testDir, { recursive: true, force: true });
|
||||
console.log('Test directory cleaned up');
|
||||
} catch (err) {
|
||||
console.log('Cleanup failed:', err.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe('Index Initialization', () => {
|
||||
it('should initialize index for test directory', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'init',
|
||||
path: testDir
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
assert.ok('success' in result, 'Result should have success property');
|
||||
|
||||
if (result.success) {
|
||||
// CodexLens stores indexes in the global data directory (e.g. ~/.codexlens/indexes)
|
||||
// rather than creating a per-project ".codexlens" folder.
|
||||
assert.ok(true);
|
||||
}
|
||||
});
|
||||
|
||||
it('should create index.db file', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
const indexDb = join(testDir, '.codexlens', 'index.db');
|
||||
|
||||
// May need to wait for previous init to complete
|
||||
// Index.db should exist after successful init
|
||||
if (existsSync(join(testDir, '.codexlens'))) {
|
||||
// Check files in .codexlens directory
|
||||
const files = readdirSync(join(testDir, '.codexlens'));
|
||||
console.log('.codexlens contents:', files);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Status Query', () => {
|
||||
it('should return index status for test directory', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'status',
|
||||
path: testDir
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
console.log('Index status:', JSON.stringify(result, null, 2));
|
||||
|
||||
if (result.success) {
|
||||
// Navigate nested structure: result.status.result or result.result
|
||||
const statusData = result.status?.result || result.result || result.status || result;
|
||||
const hasIndexInfo = (
|
||||
'files' in statusData ||
|
||||
'db_path' in statusData ||
|
||||
result.output ||
|
||||
(result.status && 'success' in result.status)
|
||||
);
|
||||
assert.ok(hasIndexInfo, 'Status should contain index information or raw output');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Symbol Extraction', () => {
|
||||
it('should extract symbols from Python file', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'symbol',
|
||||
file: join(testDir, 'main.py')
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
|
||||
if (result.success) {
|
||||
console.log('Symbols found:', result.symbols || result.output);
|
||||
|
||||
// Parse output if needed
|
||||
let symbols = result.symbols;
|
||||
if (!symbols && result.output) {
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
symbols = parsed.result?.file?.symbols || parsed.symbols;
|
||||
} catch {
|
||||
// Keep raw output
|
||||
}
|
||||
}
|
||||
|
||||
if (symbols && Array.isArray(symbols)) {
|
||||
// Check for expected symbols
|
||||
const symbolNames = symbols.map(s => s.name);
|
||||
assert.ok(symbolNames.includes('hello_world') || symbolNames.some(n => n.includes('hello')),
|
||||
'Should find hello_world function');
|
||||
assert.ok(symbolNames.includes('Calculator') || symbolNames.some(n => n.includes('Calc')),
|
||||
'Should find Calculator class');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should extract symbols from JavaScript file', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'symbol',
|
||||
file: join(testDir, 'app.js')
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
|
||||
if (result.success) {
|
||||
console.log('JS Symbols found:', result.symbols || result.output);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Full-Text Search', () => {
|
||||
it('should search for text in indexed files', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
// First ensure index is initialized
|
||||
await codexLensModule.codexLensTool.execute({
|
||||
action: 'init',
|
||||
path: testDir
|
||||
});
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'search',
|
||||
query: 'hello',
|
||||
path: testDir,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
|
||||
if (result.success) {
|
||||
console.log('Search results:', result.results || result.output);
|
||||
}
|
||||
});
|
||||
|
||||
it('should search for class names', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'search',
|
||||
query: 'Calculator',
|
||||
path: testDir,
|
||||
limit: 10
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
|
||||
if (result.success) {
|
||||
console.log('Class search results:', result.results || result.output);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('Incremental Update', () => {
|
||||
it('should update index when file changes', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a new file
|
||||
const newFile = join(testDir, 'new_module.py');
|
||||
writeFileSync(newFile, `
|
||||
def new_function():
|
||||
"""A newly added function."""
|
||||
return "new"
|
||||
`);
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'update',
|
||||
files: [newFile],
|
||||
path: testDir
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
|
||||
if (result.success) {
|
||||
console.log('Update result:', result.updateResult || result.output);
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle deleted files in update', async () => {
|
||||
if (!isReady || !testDir) {
|
||||
console.log('Skipping: CodexLens not ready or test dir not created');
|
||||
return;
|
||||
}
|
||||
|
||||
// Reference a non-existent file
|
||||
const deletedFile = join(testDir, 'deleted_file.py');
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'update',
|
||||
files: [deletedFile],
|
||||
path: testDir
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
// Should handle gracefully without crashing
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexLens CLI Commands via executeCodexLens', async () => {
|
||||
let codexLensModule;
|
||||
let isReady = false;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
codexLensModule = await import(codexLensPath);
|
||||
const status = await codexLensModule.checkVenvStatus();
|
||||
isReady = status.ready;
|
||||
} catch (err) {
|
||||
console.log('Setup failed:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute --version command', async () => {
|
||||
if (!isReady) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Note: codexlens may not have --version, use --help instead
|
||||
const result = await codexLensModule.executeCodexLens(['--help']);
|
||||
assert.ok(typeof result === 'object');
|
||||
|
||||
if (result.success) {
|
||||
assert.ok(result.output, 'Should have output');
|
||||
}
|
||||
});
|
||||
|
||||
it('should execute status --json command', async () => {
|
||||
if (!isReady) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.executeCodexLens(['status', '--json'], {
|
||||
cwd: __dirname
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object');
|
||||
|
||||
if (result.success && result.output) {
|
||||
// Try to parse JSON output
|
||||
try {
|
||||
const parsed = JSON.parse(result.output);
|
||||
assert.ok(typeof parsed === 'object', 'Output should be valid JSON');
|
||||
} catch {
|
||||
// Output might not be JSON if index doesn't exist
|
||||
console.log('Status output (non-JSON):', result.output);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle inspect command', async () => {
|
||||
if (!isReady) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use this test file as input
|
||||
const testFile = join(__dirname, 'codex-lens.test.js');
|
||||
if (!existsSync(testFile)) {
|
||||
console.log('Skipping: Test file not found');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.executeCodexLens([
|
||||
'inspect', testFile, '--json'
|
||||
]);
|
||||
|
||||
assert.ok(typeof result === 'object');
|
||||
|
||||
if (result.success) {
|
||||
console.log('Inspect result received');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexLens Workspace Detection', async () => {
|
||||
let codexLensModule;
|
||||
let isReady = false;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
codexLensModule = await import(codexLensPath);
|
||||
const status = await codexLensModule.checkVenvStatus();
|
||||
isReady = status.ready;
|
||||
} catch (err) {
|
||||
console.log('Setup failed:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
it('should detect existing workspace', async () => {
|
||||
if (!isReady) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Try to get status from project root where .codexlens might exist
|
||||
const projectRoot = join(__dirname, '..', '..');
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'status',
|
||||
path: projectRoot
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object');
|
||||
console.log('Project root status:', result.success ? 'Found' : 'Not found');
|
||||
});
|
||||
|
||||
it('should use global database when workspace not found', async () => {
|
||||
if (!isReady) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a path that definitely won't have .codexlens
|
||||
const tempPath = tmpdir();
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'status',
|
||||
path: tempPath
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object');
|
||||
// Should fall back to global database
|
||||
});
|
||||
});
|
||||
@@ -1,521 +0,0 @@
|
||||
/**
|
||||
* Tests for CodexLens API endpoints and tool integration
|
||||
*
|
||||
* Tests the following endpoints:
|
||||
* - GET /api/codexlens/status
|
||||
* - POST /api/codexlens/bootstrap
|
||||
* - POST /api/codexlens/init
|
||||
* - GET /api/codexlens/semantic/status
|
||||
* - POST /api/codexlens/semantic/install
|
||||
*
|
||||
* Also tests the codex-lens.js tool functions directly
|
||||
*/
|
||||
|
||||
import { describe, it, before, after, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { createServer } from 'http';
|
||||
import { join, dirname } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
import { existsSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'fs';
|
||||
import { homedir, tmpdir } from 'os';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Import the codex-lens module - use file:// URL format for Windows compatibility
|
||||
const codexLensPath = new URL('../dist/tools/codex-lens.js', import.meta.url).href;
|
||||
|
||||
describe('CodexLens Tool Functions', async () => {
|
||||
let codexLensModule;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
codexLensModule = await import(codexLensPath);
|
||||
} catch (err) {
|
||||
console.log('Note: codex-lens module import skipped (module may not be available):', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
describe('checkVenvStatus', () => {
|
||||
it('should return an object with ready property', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await codexLensModule.checkVenvStatus();
|
||||
assert.ok(typeof status === 'object', 'Status should be an object');
|
||||
assert.ok('ready' in status, 'Status should have ready property');
|
||||
assert.ok(typeof status.ready === 'boolean', 'ready should be boolean');
|
||||
|
||||
if (status.ready) {
|
||||
assert.ok('version' in status, 'Ready status should include version');
|
||||
} else {
|
||||
assert.ok('error' in status, 'Not ready status should include error');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkSemanticStatus', () => {
|
||||
it('should return semantic availability status', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await codexLensModule.checkSemanticStatus();
|
||||
assert.ok(typeof status === 'object', 'Status should be an object');
|
||||
assert.ok('available' in status, 'Status should have available property');
|
||||
assert.ok(typeof status.available === 'boolean', 'available should be boolean');
|
||||
|
||||
if (status.available) {
|
||||
assert.ok('backend' in status, 'Available status should include backend');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('executeCodexLens', () => {
|
||||
it('should execute codexlens command and return result', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// First check if CodexLens is ready
|
||||
const status = await codexLensModule.checkVenvStatus();
|
||||
if (!status.ready) {
|
||||
console.log('Skipping: CodexLens not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Execute a simple status command
|
||||
const result = await codexLensModule.executeCodexLens(['--help']);
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
assert.ok('success' in result, 'Result should have success property');
|
||||
|
||||
// --help should succeed
|
||||
if (result.success) {
|
||||
assert.ok('output' in result, 'Success result should have output');
|
||||
assert.ok(result.output.includes('CodexLens') || result.output.includes('codexlens'),
|
||||
'Help output should mention CodexLens');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle timeout gracefully', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const status = await codexLensModule.checkVenvStatus();
|
||||
if (!status.ready) {
|
||||
console.log('Skipping: CodexLens not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use a very short timeout to trigger timeout behavior
|
||||
// Note: This test may not always trigger timeout depending on system speed
|
||||
const result = await codexLensModule.executeCodexLens(['status', '--json'], { timeout: 1 });
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
assert.ok('success' in result, 'Result should have success property');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codexLensTool.execute', () => {
|
||||
it('should handle check action', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({ action: 'check' });
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
assert.ok('ready' in result, 'Check result should have ready property');
|
||||
});
|
||||
|
||||
it('should return error for unknown action', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({ action: 'unknown_action' });
|
||||
assert.strictEqual(result.success, false, 'Should return success: false');
|
||||
assert.ok(result.error, 'Should have error message');
|
||||
});
|
||||
|
||||
it('should handle status action', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkResult = await codexLensModule.checkVenvStatus();
|
||||
if (!checkResult.ready) {
|
||||
console.log('Skipping: CodexLens not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'status',
|
||||
path: __dirname
|
||||
});
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
assert.ok('success' in result, 'Result should have success property');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexLens API Endpoints (Integration)', async () => {
|
||||
// These tests require a running server
|
||||
// They test the actual HTTP endpoints
|
||||
|
||||
const TEST_PORT = 19999;
|
||||
let serverModule;
|
||||
let server;
|
||||
let baseUrl;
|
||||
|
||||
before(async () => {
|
||||
// Note: We cannot easily start the ccw server in tests
|
||||
// So we test the endpoint handlers directly or mock the server
|
||||
baseUrl = `http://localhost:${TEST_PORT}`;
|
||||
|
||||
// Try to import server module for handler testing
|
||||
try {
|
||||
// serverModule = await import(join(__dirname, '..', 'src', 'core', 'server.js'));
|
||||
console.log('Note: Server integration tests require manual server start');
|
||||
} catch (err) {
|
||||
console.log('Server module not available for direct testing');
|
||||
}
|
||||
});
|
||||
|
||||
describe('GET /api/codexlens/status', () => {
|
||||
it('should return JSON response with ready status', async () => {
|
||||
// This test requires a running server
|
||||
// Skip if server is not running
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/codexlens/status`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
assert.ok(typeof data === 'object', 'Response should be JSON object');
|
||||
assert.ok('ready' in data, 'Response should have ready property');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.log('Skipping: Server not running on port', TEST_PORT);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('POST /api/codexlens/init', () => {
|
||||
it('should initialize index for given path', async () => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/codexlens/init`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: __dirname })
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
assert.ok(typeof data === 'object', 'Response should be JSON object');
|
||||
assert.ok('success' in data, 'Response should have success property');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.log('Skipping: Server not running on port', TEST_PORT);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('GET /api/codexlens/semantic/status', () => {
|
||||
it('should return semantic search status', async () => {
|
||||
try {
|
||||
const response = await fetch(`${baseUrl}/api/codexlens/semantic/status`);
|
||||
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
assert.ok(typeof data === 'object', 'Response should be JSON object');
|
||||
assert.ok('available' in data, 'Response should have available property');
|
||||
}
|
||||
} catch (err) {
|
||||
if (err.cause?.code === 'ECONNREFUSED') {
|
||||
console.log('Skipping: Server not running on port', TEST_PORT);
|
||||
} else {
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexLens Tool Definition', async () => {
|
||||
let codexLensModule;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
codexLensModule = await import(codexLensPath);
|
||||
} catch (err) {
|
||||
console.log('Note: codex-lens module not available');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have correct tool name', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
assert.strictEqual(codexLensModule.codexLensTool.name, 'codex_lens');
|
||||
});
|
||||
|
||||
it('should have description', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
assert.ok(codexLensModule.codexLensTool.description, 'Should have description');
|
||||
assert.ok(codexLensModule.codexLensTool.description.includes('CodexLens'),
|
||||
'Description should mention CodexLens');
|
||||
});
|
||||
|
||||
it('should have parameters schema', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { parameters } = codexLensModule.codexLensTool;
|
||||
assert.ok(parameters, 'Should have parameters');
|
||||
assert.strictEqual(parameters.type, 'object');
|
||||
assert.ok(parameters.properties, 'Should have properties');
|
||||
assert.ok(parameters.properties.action, 'Should have action property');
|
||||
assert.deepStrictEqual(parameters.required, ['action'], 'action should be required');
|
||||
});
|
||||
|
||||
it('should support all documented actions', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { parameters } = codexLensModule.codexLensTool;
|
||||
const supportedActions = parameters.properties.action.enum;
|
||||
|
||||
const expectedActions = ['init', 'search', 'symbol', 'status', 'update', 'bootstrap', 'check'];
|
||||
|
||||
for (const action of expectedActions) {
|
||||
assert.ok(supportedActions.includes(action), `Should support ${action} action`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should have execute function', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
assert.ok(typeof codexLensModule.codexLensTool.execute === 'function',
|
||||
'Should have execute function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexLens Path Configuration', () => {
|
||||
it('should use correct venv path based on platform', async () => {
|
||||
const codexLensDataDir = join(homedir(), '.codexlens');
|
||||
const codexLensVenv = join(codexLensDataDir, 'venv');
|
||||
|
||||
const expectedPython = process.platform === 'win32'
|
||||
? join(codexLensVenv, 'Scripts', 'python.exe')
|
||||
: join(codexLensVenv, 'bin', 'python');
|
||||
|
||||
// Just verify the path construction logic is correct
|
||||
assert.ok(expectedPython.includes('codexlens'), 'Python path should include codexlens');
|
||||
assert.ok(expectedPython.includes('venv'), 'Python path should include venv');
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
assert.ok(expectedPython.includes('Scripts'), 'Windows should use Scripts directory');
|
||||
assert.ok(expectedPython.endsWith('.exe'), 'Windows should have .exe extension');
|
||||
} else {
|
||||
assert.ok(expectedPython.includes('bin'), 'Unix should use bin directory');
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexLens Error Handling', async () => {
|
||||
let codexLensModule;
|
||||
const testTempDirs = []; // Track temp directories for cleanup
|
||||
|
||||
after(() => {
|
||||
// Clean up temp directories created during tests
|
||||
for (const dir of testTempDirs) {
|
||||
try {
|
||||
rmSync(dir, { recursive: true, force: true });
|
||||
} catch (e) {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
}
|
||||
|
||||
// Clean up any indexes created for temp directories
|
||||
const indexDir = join(homedir(), '.codexlens', 'indexes');
|
||||
const tempIndexPattern = join(indexDir, 'C', 'Users', '*', 'AppData', 'Local', 'Temp', 'ccw-codexlens-update-*');
|
||||
try {
|
||||
const glob = require('glob');
|
||||
const matches = glob.sync(tempIndexPattern.replace(/\\/g, '/'));
|
||||
for (const match of matches) {
|
||||
rmSync(match, { recursive: true, force: true });
|
||||
}
|
||||
} catch (e) {
|
||||
// glob may not be available, try direct cleanup
|
||||
try {
|
||||
const tempPath = join(indexDir, 'C', 'Users');
|
||||
if (existsSync(tempPath)) {
|
||||
console.log('Note: Temp indexes may need manual cleanup at:', indexDir);
|
||||
}
|
||||
} catch (e2) {
|
||||
// Ignore
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
codexLensModule = await import(codexLensPath);
|
||||
} catch (err) {
|
||||
console.log('Note: codex-lens module not available');
|
||||
}
|
||||
});
|
||||
|
||||
it('should handle missing file parameter for symbol action', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkResult = await codexLensModule.checkVenvStatus();
|
||||
if (!checkResult.ready) {
|
||||
console.log('Skipping: CodexLens not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'symbol'
|
||||
// file is missing
|
||||
});
|
||||
|
||||
// Should either error or return success: false
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
});
|
||||
|
||||
it('should support update action without files parameter', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkResult = await codexLensModule.checkVenvStatus();
|
||||
if (!checkResult.ready) {
|
||||
console.log('Skipping: CodexLens not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateRoot = mkdtempSync(join(tmpdir(), 'ccw-codexlens-update-'));
|
||||
testTempDirs.push(updateRoot); // Track for cleanup
|
||||
writeFileSync(join(updateRoot, 'main.py'), 'def hello():\n return 1\n', 'utf8');
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'update',
|
||||
path: updateRoot,
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
assert.ok('success' in result, 'Result should have success property');
|
||||
});
|
||||
|
||||
it('should ignore extraneous files parameter for update action', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const checkResult = await codexLensModule.checkVenvStatus();
|
||||
if (!checkResult.ready) {
|
||||
console.log('Skipping: CodexLens not installed');
|
||||
return;
|
||||
}
|
||||
|
||||
const updateRoot = mkdtempSync(join(tmpdir(), 'ccw-codexlens-update-'));
|
||||
testTempDirs.push(updateRoot); // Track for cleanup
|
||||
writeFileSync(join(updateRoot, 'main.py'), 'def hello():\n return 1\n', 'utf8');
|
||||
|
||||
const result = await codexLensModule.codexLensTool.execute({
|
||||
action: 'update',
|
||||
path: updateRoot,
|
||||
files: []
|
||||
});
|
||||
|
||||
assert.ok(typeof result === 'object', 'Result should be an object');
|
||||
assert.ok('success' in result, 'Result should have success property');
|
||||
});
|
||||
});
|
||||
|
||||
describe('CodexLens Search Parameters', async () => {
|
||||
let codexLensModule;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
codexLensModule = await import(codexLensPath);
|
||||
} catch (err) {
|
||||
console.log('Note: codex-lens module not available');
|
||||
}
|
||||
});
|
||||
|
||||
it('should support text and semantic search modes', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { parameters } = codexLensModule.codexLensTool;
|
||||
const modeEnum = parameters.properties.mode?.enum;
|
||||
|
||||
assert.ok(modeEnum, 'Should have mode enum');
|
||||
assert.ok(modeEnum.includes('text'), 'Should support text mode');
|
||||
assert.ok(modeEnum.includes('semantic'), 'Should support semantic mode');
|
||||
});
|
||||
|
||||
it('should have limit parameter with default', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { parameters } = codexLensModule.codexLensTool;
|
||||
const limitProp = parameters.properties.limit;
|
||||
|
||||
assert.ok(limitProp, 'Should have limit property');
|
||||
assert.strictEqual(limitProp.type, 'number', 'limit should be number');
|
||||
assert.strictEqual(limitProp.default, 20, 'Default limit should be 20');
|
||||
});
|
||||
|
||||
it('should support output format options', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { parameters } = codexLensModule.codexLensTool;
|
||||
const formatEnum = parameters.properties.format?.enum;
|
||||
|
||||
assert.ok(formatEnum, 'Should have format enum');
|
||||
assert.ok(formatEnum.includes('json'), 'Should support json format');
|
||||
});
|
||||
});
|
||||
@@ -161,54 +161,16 @@ describe('E2E: MCP Tool Execution', async () => {
|
||||
|
||||
// Verify essential tools are present
|
||||
const toolNames = response.result.tools.map((t: any) => t.name);
|
||||
assert.ok(toolNames.includes('smart_search'));
|
||||
assert.ok(toolNames.includes('edit_file'));
|
||||
assert.ok(toolNames.includes('write_file'));
|
||||
assert.ok(toolNames.includes('session_manager'));
|
||||
|
||||
// Verify tool schema structure
|
||||
const smartSearch = response.result.tools.find((t: any) => t.name === 'smart_search');
|
||||
assert.ok(smartSearch.description);
|
||||
assert.ok(smartSearch.inputSchema);
|
||||
assert.equal(smartSearch.inputSchema.type, 'object');
|
||||
assert.ok(smartSearch.inputSchema.properties);
|
||||
});
|
||||
|
||||
it('executes smart_search tool with valid parameters', async () => {
|
||||
const response = await mcpClient.call('tools/call', {
|
||||
name: 'smart_search',
|
||||
arguments: {
|
||||
action: 'status',
|
||||
path: process.cwd()
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(response.jsonrpc, '2.0');
|
||||
assert.ok(response.result);
|
||||
assert.ok(Array.isArray(response.result.content));
|
||||
assert.equal(response.result.content[0].type, 'text');
|
||||
assert.ok(response.result.content[0].text.length > 0);
|
||||
});
|
||||
|
||||
it('validates required parameters and returns error for missing params', async () => {
|
||||
const response = await mcpClient.call('tools/call', {
|
||||
name: 'smart_search',
|
||||
arguments: {
|
||||
action: 'search'
|
||||
// Missing required 'query' parameter
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(response.jsonrpc, '2.0');
|
||||
assert.ok(response.result);
|
||||
assert.equal(response.result.isError, true);
|
||||
// Error message should mention query is required
|
||||
assert.ok(
|
||||
response.result.content[0].text.includes('Query is required') ||
|
||||
response.result.content[0].text.includes('query') ||
|
||||
response.result.content[0].text.includes('required'),
|
||||
`Expected error about missing query, got: ${response.result.content[0].text}`
|
||||
);
|
||||
const editFile = response.result.tools.find((t: any) => t.name === 'edit_file');
|
||||
assert.ok(editFile.description);
|
||||
assert.ok(editFile.inputSchema);
|
||||
assert.equal(editFile.inputSchema.type, 'object');
|
||||
assert.ok(editFile.inputSchema.properties);
|
||||
});
|
||||
|
||||
it('returns error for non-existent tool', async () => {
|
||||
@@ -374,10 +336,6 @@ describe('E2E: MCP Tool Execution', async () => {
|
||||
it('handles concurrent tool calls without interference', async () => {
|
||||
const calls = await Promise.all([
|
||||
mcpClient.call('tools/list', {}),
|
||||
mcpClient.call('tools/call', {
|
||||
name: 'smart_search',
|
||||
arguments: { action: 'status', path: process.cwd() }
|
||||
}),
|
||||
mcpClient.call('tools/call', {
|
||||
name: 'session_manager',
|
||||
arguments: { operation: 'list', location: 'active' }
|
||||
@@ -392,8 +350,7 @@ describe('E2E: MCP Tool Execution', async () => {
|
||||
|
||||
// Verify different results
|
||||
assert.ok(Array.isArray(calls[0].result.tools)); // tools/list
|
||||
assert.ok(calls[1].result.content); // smart_search
|
||||
assert.ok(calls[2].result.content); // session_manager
|
||||
assert.ok(calls[1].result.content); // session_manager
|
||||
});
|
||||
|
||||
it('validates path parameters for security (path traversal prevention)', async () => {
|
||||
@@ -415,24 +372,6 @@ describe('E2E: MCP Tool Execution', async () => {
|
||||
assert.ok(hasError);
|
||||
});
|
||||
|
||||
it('supports progress reporting for long-running operations', async () => {
|
||||
// smart_search init action supports progress reporting
|
||||
const response = await mcpClient.call('tools/call', {
|
||||
name: 'smart_search',
|
||||
arguments: {
|
||||
action: 'status',
|
||||
path: process.cwd()
|
||||
}
|
||||
});
|
||||
|
||||
assert.equal(response.jsonrpc, '2.0');
|
||||
assert.ok(response.result);
|
||||
assert.ok(response.result.content);
|
||||
|
||||
// For status action, should return immediately
|
||||
// Progress is logged to stderr but doesn't affect result structure
|
||||
});
|
||||
|
||||
it('handles tool execution timeout gracefully', async () => {
|
||||
// Create a tool call that should complete quickly
|
||||
// If it times out, the client will throw
|
||||
@@ -495,14 +434,10 @@ describe('E2E: MCP Tool Execution', async () => {
|
||||
|
||||
it('preserves parameter types in tool execution', async () => {
|
||||
const response = await mcpClient.call('tools/call', {
|
||||
name: 'smart_search',
|
||||
name: 'session_manager',
|
||||
arguments: {
|
||||
action: 'find_files',
|
||||
pattern: '*.json',
|
||||
path: process.cwd(),
|
||||
limit: 10, // Number
|
||||
offset: 0, // Number
|
||||
caseSensitive: true // Boolean
|
||||
operation: 'list',
|
||||
location: 'active'
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -1,403 +0,0 @@
|
||||
/**
|
||||
* Unit tests for LiteLLM client bridge (ccw/dist/tools/litellm-client.js).
|
||||
*
|
||||
* Notes:
|
||||
* - Uses Node's built-in test runner (node:test) (no Jest in this repo).
|
||||
* - Stubs `child_process.spawn` to avoid depending on local Python/ccw_litellm installation.
|
||||
*/
|
||||
|
||||
import { after, beforeEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const childProcess = require('child_process') as typeof import('child_process');
|
||||
|
||||
type SpawnBehavior =
|
||||
| { type: 'close'; code?: number; stdout?: string; stderr?: string }
|
||||
| { type: 'error'; error: Error }
|
||||
| { type: 'hang' };
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
stderr = new EventEmitter();
|
||||
killCalls: string[] = [];
|
||||
|
||||
kill(signal?: NodeJS.Signals | number | string): boolean {
|
||||
this.killCalls.push(signal === undefined ? 'undefined' : String(signal));
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
type SpawnCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: any;
|
||||
proc: FakeChildProcess;
|
||||
};
|
||||
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
const spawnPlan: SpawnBehavior[] = [];
|
||||
|
||||
const originalSpawn = childProcess.spawn;
|
||||
|
||||
childProcess.spawn = ((command: string, args: string[] = [], options: any = {}) => {
|
||||
const normalizedArgs = (args ?? []).map(String);
|
||||
const shouldIntercept = normalizedArgs[0] === '-m' && normalizedArgs[1] === 'ccw_litellm.cli';
|
||||
if (!shouldIntercept) {
|
||||
return originalSpawn(command as any, args as any, options as any);
|
||||
}
|
||||
|
||||
const proc = new FakeChildProcess();
|
||||
spawnCalls.push({ command: String(command), args: normalizedArgs, options, proc });
|
||||
|
||||
const next = spawnPlan.shift() ?? { type: 'close', code: 0, stdout: '' };
|
||||
|
||||
queueMicrotask(() => {
|
||||
if (next.type === 'error') {
|
||||
proc.emit('error', next.error);
|
||||
return;
|
||||
}
|
||||
|
||||
if (next.type === 'close') {
|
||||
if (next.stdout !== undefined) proc.stdout.emit('data', next.stdout);
|
||||
if (next.stderr !== undefined) proc.stderr.emit('data', next.stderr);
|
||||
proc.emit('close', next.code ?? 0);
|
||||
return;
|
||||
}
|
||||
|
||||
// hang: intentionally do nothing
|
||||
});
|
||||
|
||||
return proc as any;
|
||||
}) as any;
|
||||
|
||||
function getClientModuleUrl(): URL {
|
||||
const url = new URL('../dist/tools/litellm-client.js', import.meta.url);
|
||||
url.searchParams.set('t', `${Date.now()}-${Math.random()}`);
|
||||
return url;
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let mod: any;
|
||||
|
||||
beforeEach(async () => {
|
||||
spawnCalls.length = 0;
|
||||
spawnPlan.length = 0;
|
||||
mod = await import(getClientModuleUrl().href);
|
||||
});
|
||||
|
||||
after(() => {
|
||||
childProcess.spawn = originalSpawn;
|
||||
});
|
||||
|
||||
describe('LiteLLM client bridge', () => {
|
||||
it('uses default pythonPath and version check arguments', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: '1.2.3\n' });
|
||||
|
||||
const client = new mod.LiteLLMClient();
|
||||
const available = await client.isAvailable();
|
||||
|
||||
assert.equal(available, true);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].command, mod.getCodexLensVenvPython());
|
||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'ccw_litellm.cli', 'version']);
|
||||
});
|
||||
|
||||
it('uses custom pythonPath when provided', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'ok' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ pythonPath: 'python3', timeout: 10 });
|
||||
await client.chat('hello', 'default');
|
||||
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].command, 'python3');
|
||||
});
|
||||
|
||||
it('spawns LiteLLM Python with hidden window options', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: '1.2.3\n' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const available = await client.isAvailable();
|
||||
|
||||
assert.equal(available, true);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].options.shell, false);
|
||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
||||
assert.equal(spawnCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
||||
});
|
||||
|
||||
it('isAvailable returns false on spawn error', async () => {
|
||||
spawnPlan.push({ type: 'error', error: new Error('ENOENT') });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const available = await client.isAvailable();
|
||||
|
||||
assert.equal(available, false);
|
||||
});
|
||||
|
||||
it('getStatus returns version on success', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'v9.9.9\n' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const status = await client.getStatus();
|
||||
|
||||
assert.equal(status.available, true);
|
||||
assert.equal(status.version, 'v9.9.9');
|
||||
});
|
||||
|
||||
it('getStatus returns error details on non-zero exit', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 500 Internal Server Error' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const status = await client.getStatus();
|
||||
|
||||
assert.equal(status.available, false);
|
||||
assert.ok(String(status.error).includes('HTTP 500'));
|
||||
});
|
||||
|
||||
it('getConfig parses JSON output', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: JSON.stringify({ ok: true }) });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const cfg = await client.getConfig();
|
||||
|
||||
assert.deepEqual(cfg, { ok: true });
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'ccw_litellm.cli', 'config']);
|
||||
});
|
||||
|
||||
it('getConfig throws on malformed JSON', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: '{not-json' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.getConfig());
|
||||
});
|
||||
|
||||
it('embed rejects empty texts input and does not spawn', async () => {
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.embed([]), /texts array cannot be empty/);
|
||||
assert.equal(spawnCalls.length, 0);
|
||||
});
|
||||
|
||||
it('embed rejects null/undefined input', async () => {
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.embed(null as any), /texts array cannot be empty/);
|
||||
await assert.rejects(() => client.embed(undefined as any), /texts array cannot be empty/);
|
||||
assert.equal(spawnCalls.length, 0);
|
||||
});
|
||||
|
||||
it('embed returns vectors with derived dimensions', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: JSON.stringify([[1, 2, 3], [4, 5, 6]]) });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const res = await client.embed(['a', 'b'], 'embed-model');
|
||||
|
||||
assert.equal(res.model, 'embed-model');
|
||||
assert.equal(res.dimensions, 3);
|
||||
assert.deepEqual(res.vectors, [
|
||||
[1, 2, 3],
|
||||
[4, 5, 6],
|
||||
]);
|
||||
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.deepEqual(spawnCalls[0].args, [
|
||||
'-m',
|
||||
'ccw_litellm.cli',
|
||||
'embed',
|
||||
'--model',
|
||||
'embed-model',
|
||||
'--output',
|
||||
'json',
|
||||
'a',
|
||||
'b',
|
||||
]);
|
||||
});
|
||||
|
||||
it('embed throws on malformed JSON output', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'not-json' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.embed(['a'], 'embed-model'));
|
||||
});
|
||||
|
||||
it('chat rejects empty message and does not spawn', async () => {
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat(''), /message cannot be empty/);
|
||||
assert.equal(spawnCalls.length, 0);
|
||||
});
|
||||
|
||||
it('chat returns trimmed stdout on success', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'Hello\n' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const out = await client.chat('hi', 'chat-model');
|
||||
|
||||
assert.equal(out, 'Hello');
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'ccw_litellm.cli', 'chat', '--model', 'chat-model', 'hi']);
|
||||
});
|
||||
|
||||
it('chat propagates auth errors (401)', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 401 Unauthorized' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /401/);
|
||||
});
|
||||
|
||||
it('chat propagates auth errors (403)', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 403 Forbidden' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /403/);
|
||||
});
|
||||
|
||||
it('chat propagates rate limit errors (429)', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 429 Too Many Requests' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /429/);
|
||||
});
|
||||
|
||||
it('chat propagates server errors (500)', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 500 Internal Server Error' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /500/);
|
||||
});
|
||||
|
||||
it('chat propagates server errors (503)', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'HTTP 503 Service Unavailable' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /503/);
|
||||
});
|
||||
|
||||
it('chat falls back to exit code when stderr is empty', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 2, stdout: '' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /Process exited with code 2/);
|
||||
});
|
||||
|
||||
it('chat surfaces spawn failures with descriptive message', async () => {
|
||||
spawnPlan.push({ type: 'error', error: new Error('spawn ENOENT') });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /Failed to spawn Python process: spawn ENOENT/);
|
||||
});
|
||||
|
||||
it('chat enforces timeout and terminates process', async () => {
|
||||
const originalSetTimeout = global.setTimeout;
|
||||
let observedDelay: number | null = null;
|
||||
|
||||
(global as any).setTimeout = ((fn: any, delay: number, ...args: any[]) => {
|
||||
observedDelay = delay;
|
||||
return originalSetTimeout(fn, 0, ...args);
|
||||
}) as any;
|
||||
|
||||
try {
|
||||
spawnPlan.push({ type: 'hang' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 11 });
|
||||
await assert.rejects(() => client.chat('hi', 'chat-model'), /Command timed out after 22ms/);
|
||||
|
||||
assert.equal(observedDelay, 22);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.ok(spawnCalls[0].proc.killCalls.includes('SIGTERM'));
|
||||
} finally {
|
||||
(global as any).setTimeout = originalSetTimeout;
|
||||
}
|
||||
});
|
||||
|
||||
it('chatMessages rejects empty inputs', async () => {
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
await assert.rejects(() => client.chatMessages([]), /messages array cannot be empty/);
|
||||
await assert.rejects(() => client.chatMessages(null as any), /messages array cannot be empty/);
|
||||
assert.equal(spawnCalls.length, 0);
|
||||
});
|
||||
|
||||
it('chatMessages uses the last message content', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 0, stdout: 'OK' });
|
||||
|
||||
const client = new mod.LiteLLMClient({ timeout: 10 });
|
||||
const res = await client.chatMessages(
|
||||
[
|
||||
{ role: 'user', content: 'first' },
|
||||
{ role: 'user', content: 'last' },
|
||||
],
|
||||
'chat-model',
|
||||
);
|
||||
|
||||
assert.equal(res.content, 'OK');
|
||||
assert.equal(res.model, 'chat-model');
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].args.at(-1), 'last');
|
||||
});
|
||||
|
||||
it('getLiteLLMClient returns a singleton instance', () => {
|
||||
const c1 = mod.getLiteLLMClient();
|
||||
const c2 = mod.getLiteLLMClient();
|
||||
assert.equal(c1, c2);
|
||||
});
|
||||
|
||||
it('checkLiteLLMAvailable returns false when version check fails', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'ccw_litellm not installed' });
|
||||
|
||||
const available = await mod.checkLiteLLMAvailable();
|
||||
assert.equal(available, false);
|
||||
});
|
||||
|
||||
it('getLiteLLMStatus includes error message when unavailable', async () => {
|
||||
spawnPlan.push({ type: 'close', code: 1, stderr: 'ccw_litellm not installed' });
|
||||
|
||||
const status = await mod.getLiteLLMStatus();
|
||||
assert.equal(status.available, false);
|
||||
assert.ok(String(status.error).includes('ccw_litellm not installed'));
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCodexLensVenvPython (Issue #68 fix)', () => {
|
||||
it('should be exported from the module', async () => {
|
||||
assert.ok(typeof mod.getCodexLensVenvPython === 'function');
|
||||
});
|
||||
|
||||
it('should return a string path', async () => {
|
||||
const pythonPath = mod.getCodexLensVenvPython();
|
||||
assert.equal(typeof pythonPath, 'string');
|
||||
assert.ok(pythonPath.length > 0);
|
||||
});
|
||||
|
||||
it('should return correct path structure for CodexLens venv', async () => {
|
||||
const pythonPath = mod.getCodexLensVenvPython();
|
||||
|
||||
// On Windows: should contain Scripts/python.exe
|
||||
// On Unix: should contain bin/python
|
||||
const isWindows = process.platform === 'win32';
|
||||
|
||||
if (isWindows) {
|
||||
// Either it's the venv path with Scripts, or fallback to 'python'
|
||||
const isVenvPath = pythonPath.includes('Scripts') && pythonPath.includes('python');
|
||||
const isFallback = pythonPath === 'python';
|
||||
assert.ok(isVenvPath || isFallback, `Expected venv path or 'python' fallback, got: ${pythonPath}`);
|
||||
} else {
|
||||
// On Unix: either venv path with bin/python, or fallback
|
||||
const isVenvPath = pythonPath.includes('bin') && pythonPath.includes('python');
|
||||
const isFallback = pythonPath === 'python';
|
||||
assert.ok(isVenvPath || isFallback, `Expected venv path or 'python' fallback, got: ${pythonPath}`);
|
||||
}
|
||||
});
|
||||
|
||||
it('should include .codexlens/venv in path when venv exists', async () => {
|
||||
const pythonPath = mod.getCodexLensVenvPython();
|
||||
|
||||
// If not falling back to 'python', should contain .codexlens/venv
|
||||
if (pythonPath !== 'python') {
|
||||
assert.ok(pythonPath.includes('.codexlens'), `Expected .codexlens in path, got: ${pythonPath}`);
|
||||
assert.ok(pythonPath.includes('venv'), `Expected venv in path, got: ${pythonPath}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -97,7 +97,7 @@ describe('MCP Server', () => {
|
||||
const toolNames = response.result.tools.map(t => t.name);
|
||||
assert(toolNames.includes('edit_file'));
|
||||
assert(toolNames.includes('write_file'));
|
||||
assert(toolNames.includes('smart_search'));
|
||||
// smart_search removed - use codexlens MCP server instead
|
||||
});
|
||||
|
||||
it('should respond to tools/call request', async () => {
|
||||
|
||||
@@ -1,256 +0,0 @@
|
||||
/**
|
||||
* Tests for smart_search with enrich parameter
|
||||
*
|
||||
* Tests the following:
|
||||
* - enrich parameter is passed to codex-lens
|
||||
* - relationship data is parsed from response
|
||||
* - SemanticMatch interface with relationships field
|
||||
*/
|
||||
|
||||
import { describe, it, before, mock } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
import { dirname, join } from 'path';
|
||||
import { fileURLToPath } from 'url';
|
||||
|
||||
const __filename = fileURLToPath(import.meta.url);
|
||||
const __dirname = dirname(__filename);
|
||||
|
||||
// Import the smart-search module (exports schema, not smartSearchTool)
|
||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
||||
|
||||
describe('Smart Search Enrich Parameter', async () => {
|
||||
let smartSearchModule;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
smartSearchModule = await import(smartSearchPath);
|
||||
} catch (err) {
|
||||
console.log('Note: smart-search module import skipped:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
describe('Parameter Schema', () => {
|
||||
it('should have enrich parameter in schema', async () => {
|
||||
if (!smartSearchModule) {
|
||||
console.log('Skipping: smart-search module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { schema } = smartSearchModule;
|
||||
assert.ok(schema, 'Should export schema');
|
||||
// Schema uses inputSchema (MCP standard), not parameters
|
||||
const params = schema.inputSchema || schema.parameters;
|
||||
assert.ok(params, 'Should have inputSchema or parameters');
|
||||
|
||||
const props = params.properties;
|
||||
assert.ok(props.enrich, 'Should have enrich parameter');
|
||||
assert.strictEqual(props.enrich.type, 'boolean', 'enrich should be boolean');
|
||||
assert.strictEqual(props.enrich.default, false, 'enrich should default to false');
|
||||
});
|
||||
|
||||
it('should describe enrich parameter purpose', async () => {
|
||||
if (!smartSearchModule) {
|
||||
console.log('Skipping: smart-search module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const { schema } = smartSearchModule;
|
||||
const params = schema.inputSchema || schema.parameters;
|
||||
const enrichDesc = params.properties.enrich?.description || '';
|
||||
|
||||
// Description should mention relationships or graph
|
||||
const mentionsRelationships = enrichDesc.toLowerCase().includes('relationship') ||
|
||||
enrichDesc.toLowerCase().includes('graph') ||
|
||||
enrichDesc.toLowerCase().includes('enrich');
|
||||
assert.ok(mentionsRelationships, 'enrich description should mention relationships/graph');
|
||||
});
|
||||
});
|
||||
|
||||
describe('SemanticMatch Interface', () => {
|
||||
it('should handle results with relationships field', async () => {
|
||||
if (!smartSearchModule) {
|
||||
console.log('Skipping: smart-search module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Create a mock result with relationships
|
||||
const mockResult = {
|
||||
file: 'test.py',
|
||||
score: 0.95,
|
||||
content: 'def main(): pass',
|
||||
symbol: 'main',
|
||||
relationships: [
|
||||
{
|
||||
type: 'calls',
|
||||
direction: 'outgoing',
|
||||
target: 'helper',
|
||||
file: 'test.py',
|
||||
line: 5
|
||||
},
|
||||
{
|
||||
type: 'called_by',
|
||||
direction: 'incoming',
|
||||
source: 'entrypoint',
|
||||
file: 'app.py',
|
||||
line: 10
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
// Verify structure
|
||||
assert.ok(Array.isArray(mockResult.relationships), 'relationships should be array');
|
||||
assert.strictEqual(mockResult.relationships.length, 2, 'should have 2 relationships');
|
||||
|
||||
const outgoing = mockResult.relationships[0];
|
||||
assert.strictEqual(outgoing.type, 'calls');
|
||||
assert.strictEqual(outgoing.direction, 'outgoing');
|
||||
assert.ok(outgoing.target, 'outgoing should have target');
|
||||
|
||||
const incoming = mockResult.relationships[1];
|
||||
assert.strictEqual(incoming.type, 'called_by');
|
||||
assert.strictEqual(incoming.direction, 'incoming');
|
||||
assert.ok(incoming.source, 'incoming should have source');
|
||||
});
|
||||
});
|
||||
|
||||
describe('RelationshipInfo Structure', () => {
|
||||
it('should validate relationship info structure', () => {
|
||||
// Test the expected structure of RelationshipInfo
|
||||
const validRelationship = {
|
||||
type: 'calls',
|
||||
direction: 'outgoing',
|
||||
target: 'some_function',
|
||||
file: 'module.py',
|
||||
line: 42
|
||||
};
|
||||
|
||||
assert.ok(['calls', 'imports', 'extends', 'called_by', 'imported_by', 'extended_by']
|
||||
.includes(validRelationship.type), 'type should be valid relationship type');
|
||||
assert.ok(['outgoing', 'incoming'].includes(validRelationship.direction),
|
||||
'direction should be outgoing or incoming');
|
||||
assert.ok(typeof validRelationship.file === 'string', 'file should be string');
|
||||
});
|
||||
|
||||
it('should allow optional line number', () => {
|
||||
const withLine = {
|
||||
type: 'calls',
|
||||
direction: 'outgoing',
|
||||
target: 'func',
|
||||
file: 'test.py',
|
||||
line: 10
|
||||
};
|
||||
|
||||
const withoutLine = {
|
||||
type: 'imports',
|
||||
direction: 'outgoing',
|
||||
target: 'os',
|
||||
file: 'test.py'
|
||||
// line is optional
|
||||
};
|
||||
|
||||
assert.strictEqual(withLine.line, 10);
|
||||
assert.strictEqual(withoutLine.line, undefined);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Smart Search Tool Definition', async () => {
|
||||
let smartSearchModule;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
smartSearchModule = await import(smartSearchPath);
|
||||
} catch (err) {
|
||||
console.log('Note: smart-search module not available');
|
||||
}
|
||||
});
|
||||
|
||||
it('should have correct tool name', () => {
|
||||
if (!smartSearchModule) {
|
||||
console.log('Skipping: smart-search module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
assert.strictEqual(smartSearchModule.schema.name, 'smart_search');
|
||||
});
|
||||
|
||||
it('should have all required parameters', () => {
|
||||
if (!smartSearchModule) {
|
||||
console.log('Skipping: smart-search module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = smartSearchModule.schema.inputSchema || smartSearchModule.schema.parameters;
|
||||
const props = params.properties;
|
||||
|
||||
// Core parameters
|
||||
assert.ok(props.action, 'Should have action parameter');
|
||||
assert.ok(props.query, 'Should have query parameter');
|
||||
assert.ok(props.path, 'Should have path parameter');
|
||||
|
||||
// Search parameters
|
||||
assert.ok(props.mode, 'Should have mode parameter');
|
||||
assert.ok(props.maxResults || props.limit, 'Should have maxResults/limit parameter');
|
||||
|
||||
// New enrich parameter
|
||||
assert.ok(props.enrich, 'Should have enrich parameter');
|
||||
});
|
||||
|
||||
it('should support search modes', () => {
|
||||
if (!smartSearchModule) {
|
||||
console.log('Skipping: smart-search module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
const params = smartSearchModule.schema.inputSchema || smartSearchModule.schema.parameters;
|
||||
const modeEnum = params.properties.mode?.enum;
|
||||
|
||||
assert.ok(modeEnum, 'Should have mode enum');
|
||||
assert.ok(modeEnum.includes('fuzzy'), 'Should support fuzzy mode');
|
||||
assert.ok(modeEnum.includes('semantic'), 'Should support semantic mode');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Enrich Flag Integration', async () => {
|
||||
let codexLensModule;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
const codexLensPath = new URL('../dist/tools/codex-lens.js', import.meta.url).href;
|
||||
codexLensModule = await import(codexLensPath);
|
||||
} catch (err) {
|
||||
console.log('Note: codex-lens module not available');
|
||||
}
|
||||
});
|
||||
|
||||
it('codex-lens should support enrich parameter', () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Use schema export (primary) or codexLensTool (backward-compatible)
|
||||
const toolDef = codexLensModule.schema || codexLensModule.codexLensTool;
|
||||
assert.ok(toolDef, 'Should have schema or codexLensTool export');
|
||||
|
||||
// Schema uses inputSchema (MCP standard), codexLensTool uses parameters
|
||||
const params = toolDef.inputSchema || toolDef.parameters;
|
||||
const props = params.properties;
|
||||
assert.ok(props.enrich, 'should have enrich parameter');
|
||||
assert.strictEqual(props.enrich.type, 'boolean', 'enrich should be boolean');
|
||||
});
|
||||
|
||||
it('should pass enrich flag to command line', async () => {
|
||||
if (!codexLensModule) {
|
||||
console.log('Skipping: codex-lens module not available');
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if executeCodexLens function is exported
|
||||
const { executeCodexLens } = codexLensModule;
|
||||
if (executeCodexLens) {
|
||||
// The function should be available for passing enrich parameter
|
||||
assert.ok(typeof executeCodexLens === 'function', 'executeCodexLens should be a function');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,141 +0,0 @@
|
||||
/**
|
||||
* Tests for query intent detection + adaptive RRF weights (TypeScript/Python parity).
|
||||
*
|
||||
* References:
|
||||
* - `ccw/src/tools/smart-search.ts` (detectQueryIntent, adjustWeightsByIntent, getRRFWeights)
|
||||
* - `codex-lens/src/codexlens/search/hybrid_search.py` (weight intent concept + defaults)
|
||||
*/
|
||||
|
||||
import { describe, it, before } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
|
||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
||||
|
||||
describe('Smart Search - Query Intent + RRF Weights', async () => {
|
||||
/** @type {any} */
|
||||
let smartSearchModule;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
smartSearchModule = await import(smartSearchPath);
|
||||
} catch (err) {
|
||||
// Keep tests non-blocking for environments that haven't built `ccw/dist` yet.
|
||||
console.log('Note: smart-search module import skipped:', err.message);
|
||||
}
|
||||
});
|
||||
|
||||
describe('detectQueryIntent', () => {
|
||||
it('classifies "def authenticate" as keyword', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('def authenticate'), 'keyword');
|
||||
});
|
||||
|
||||
it('classifies CamelCase identifiers as keyword', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('MyClass'), 'keyword');
|
||||
});
|
||||
|
||||
it('classifies snake_case identifiers as keyword', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('user_id'), 'keyword');
|
||||
});
|
||||
|
||||
it('classifies namespace separators "::" as keyword', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('UserService::authenticate'), 'keyword');
|
||||
});
|
||||
|
||||
it('classifies pointer arrows "->" as keyword', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('ptr->next'), 'keyword');
|
||||
});
|
||||
|
||||
it('classifies dotted member access as keyword', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('foo.bar'), 'keyword');
|
||||
});
|
||||
|
||||
it('classifies natural language questions as semantic', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('how to handle user login'), 'semantic');
|
||||
});
|
||||
|
||||
it('classifies interrogatives with question marks as semantic', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('what is authentication?'), 'semantic');
|
||||
});
|
||||
|
||||
it('classifies queries with both code + NL signals as mixed', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('why does FooBar crash?'), 'mixed');
|
||||
});
|
||||
|
||||
it('classifies long NL queries containing identifiers as mixed', () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent('how to use user_id in query'), 'mixed');
|
||||
});
|
||||
});
|
||||
|
||||
describe('classifyIntent lexical routing', () => {
|
||||
it('routes config/backend queries to exact when index and embeddings are available', () => {
|
||||
if (!smartSearchModule) return;
|
||||
const classification = smartSearchModule.__testables.classifyIntent(
|
||||
'embedding backend fastembed local litellm api config',
|
||||
true,
|
||||
true,
|
||||
);
|
||||
assert.strictEqual(classification.mode, 'exact');
|
||||
assert.match(classification.reasoning, /lexical priority/i);
|
||||
});
|
||||
|
||||
it('routes generated artifact queries to exact when index and embeddings are available', () => {
|
||||
if (!smartSearchModule) return;
|
||||
const classification = smartSearchModule.__testables.classifyIntent('dist bundle output', true, true);
|
||||
assert.strictEqual(classification.mode, 'exact');
|
||||
assert.match(classification.reasoning, /generated artifact/i);
|
||||
});
|
||||
});
|
||||
|
||||
describe('adjustWeightsByIntent', () => {
|
||||
it('maps keyword intent to exact-heavy weights', () => {
|
||||
if (!smartSearchModule) return;
|
||||
const weights = smartSearchModule.adjustWeightsByIntent('keyword', { exact: 0.3, fuzzy: 0.1, vector: 0.6 });
|
||||
assert.deepStrictEqual(weights, { exact: 0.5, fuzzy: 0.1, vector: 0.4 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getRRFWeights parity set', () => {
|
||||
it('produces stable weights for 20 representative queries', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const base = { exact: 0.3, fuzzy: 0.1, vector: 0.6 };
|
||||
const expected = [
|
||||
['def authenticate', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['class UserService', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['user_id', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['MyClass', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['Foo::Bar', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['ptr->next', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['foo.bar', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['import os', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['how to handle user login', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
||||
['what is the best way to search?', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
||||
['explain the authentication flow', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
||||
['generate embeddings for this repo', { exact: 0.2, fuzzy: 0.1, vector: 0.7 }],
|
||||
['how does FooBar work', base],
|
||||
['user_id how to handle', base],
|
||||
['Find UserService::authenticate method', base],
|
||||
['where is foo.bar used', base],
|
||||
['parse_json function', { exact: 0.5, fuzzy: 0.1, vector: 0.4 }],
|
||||
['How to parse_json output?', base],
|
||||
['', base],
|
||||
['authentication', base],
|
||||
];
|
||||
|
||||
for (const [query, expectedWeights] of expected) {
|
||||
const actual = smartSearchModule.getRRFWeights(query, base);
|
||||
assert.deepStrictEqual(actual, expectedWeights, `unexpected weights for query: ${JSON.stringify(query)}`);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -1,703 +0,0 @@
|
||||
import { after, afterEach, before, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
||||
const originalAutoInitMissing = process.env.CODEXLENS_AUTO_INIT_MISSING;
|
||||
const originalAutoEmbedMissing = process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
||||
|
||||
describe('Smart Search MCP usage defaults and path handling', async () => {
|
||||
let smartSearchModule;
|
||||
const tempDirs = [];
|
||||
|
||||
before(async () => {
|
||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'false';
|
||||
try {
|
||||
smartSearchModule = await import(smartSearchPath);
|
||||
} catch (err) {
|
||||
console.log('Note: smart-search module import skipped:', err?.message ?? String(err));
|
||||
}
|
||||
});
|
||||
|
||||
after(() => {
|
||||
if (originalAutoInitMissing === undefined) {
|
||||
delete process.env.CODEXLENS_AUTO_INIT_MISSING;
|
||||
} else {
|
||||
process.env.CODEXLENS_AUTO_INIT_MISSING = originalAutoInitMissing;
|
||||
}
|
||||
|
||||
if (originalAutoEmbedMissing === undefined) {
|
||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
||||
return;
|
||||
}
|
||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = originalAutoEmbedMissing;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop(), { recursive: true, force: true });
|
||||
}
|
||||
if (smartSearchModule?.__testables) {
|
||||
smartSearchModule.__testables.__resetRuntimeOverrides();
|
||||
smartSearchModule.__testables.__resetBackgroundJobs();
|
||||
}
|
||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'false';
|
||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
||||
});
|
||||
|
||||
function createWorkspace() {
|
||||
const dir = mkdtempSync(join(tmpdir(), 'ccw-smart-search-'));
|
||||
tempDirs.push(dir);
|
||||
return dir;
|
||||
}
|
||||
|
||||
function createDetachedChild() {
|
||||
return {
|
||||
on() {
|
||||
return this;
|
||||
},
|
||||
unref() {},
|
||||
};
|
||||
}
|
||||
|
||||
it('keeps schema defaults aligned with runtime docs', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const { schema } = smartSearchModule;
|
||||
const props = schema.inputSchema.properties;
|
||||
|
||||
assert.equal(props.maxResults.default, 5);
|
||||
assert.equal(props.limit.default, 5);
|
||||
assert.match(schema.description, /static FTS index/i);
|
||||
assert.match(schema.description, /semantic\/vector embeddings/i);
|
||||
assert.ok(props.action.enum.includes('embed'));
|
||||
assert.match(props.embeddingBackend.description, /litellm\/api/i);
|
||||
assert.match(props.apiMaxWorkers.description, /endpoint pool/i);
|
||||
assert.match(schema.description, /apiMaxWorkers=8/i);
|
||||
assert.match(props.path.description, /single file path/i);
|
||||
assert.ok(props.output_mode.enum.includes('ace'));
|
||||
assert.match(props.output_mode.description, /ACE-style/i);
|
||||
assert.equal(props.output_mode.default, 'ace');
|
||||
});
|
||||
|
||||
it('defaults auto embedding warmup off on Windows unless explicitly enabled', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const { __testables } = smartSearchModule;
|
||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
||||
assert.equal(__testables.isAutoEmbedMissingEnabled(undefined), process.platform !== 'win32');
|
||||
assert.equal(__testables.isAutoEmbedMissingEnabled({}), process.platform !== 'win32');
|
||||
assert.equal(
|
||||
__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: true }),
|
||||
process.platform === 'win32' ? false : true,
|
||||
);
|
||||
assert.equal(__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: false }), false);
|
||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = 'true';
|
||||
assert.equal(__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: false }), true);
|
||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = 'off';
|
||||
assert.equal(__testables.isAutoEmbedMissingEnabled({ embedding_auto_embed_missing: true }), false);
|
||||
});
|
||||
|
||||
it('defaults auto index warmup off on Windows unless explicitly enabled', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const { __testables } = smartSearchModule;
|
||||
delete process.env.CODEXLENS_AUTO_INIT_MISSING;
|
||||
assert.equal(__testables.isAutoInitMissingEnabled(), process.platform !== 'win32');
|
||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'off';
|
||||
assert.equal(__testables.isAutoInitMissingEnabled(), false);
|
||||
process.env.CODEXLENS_AUTO_INIT_MISSING = '1';
|
||||
assert.equal(__testables.isAutoInitMissingEnabled(), true);
|
||||
});
|
||||
|
||||
it('explains when Windows disables background warmup by default', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const { __testables } = smartSearchModule;
|
||||
delete process.env.CODEXLENS_AUTO_INIT_MISSING;
|
||||
delete process.env.CODEXLENS_AUTO_EMBED_MISSING;
|
||||
|
||||
const initReason = __testables.getAutoInitMissingDisabledReason();
|
||||
const embedReason = __testables.getAutoEmbedMissingDisabledReason({});
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
assert.match(initReason, /disabled by default on Windows/i);
|
||||
assert.match(embedReason, /disabled by default on Windows/i);
|
||||
assert.match(embedReason, /auto_embed_missing=true/i);
|
||||
} else {
|
||||
assert.match(initReason, /disabled/i);
|
||||
assert.match(embedReason, /disabled/i);
|
||||
}
|
||||
});
|
||||
|
||||
it('builds hidden subprocess options for Smart Search child processes', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const options = smartSearchModule.__testables.buildSmartSearchSpawnOptions(tmpdir(), {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
timeout: 12345,
|
||||
});
|
||||
|
||||
assert.equal(options.cwd, tmpdir());
|
||||
assert.equal(options.shell, false);
|
||||
assert.equal(options.windowsHide, true);
|
||||
assert.equal(options.detached, true);
|
||||
assert.equal(options.timeout, 12345);
|
||||
assert.equal(options.env.PYTHONIOENCODING, 'utf-8');
|
||||
});
|
||||
|
||||
it('avoids detached background warmup children on Windows consoles', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
assert.equal(
|
||||
smartSearchModule.__testables.shouldDetachBackgroundSmartSearchProcess(),
|
||||
process.platform !== 'win32',
|
||||
);
|
||||
});
|
||||
|
||||
it('checks tool availability without shell-based lookup popups', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const lookupCalls = [];
|
||||
const available = smartSearchModule.__testables.checkToolAvailability(
|
||||
'rg',
|
||||
(command, args, options) => {
|
||||
lookupCalls.push({ command, args, options });
|
||||
return { status: 0, stdout: '', stderr: '' };
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(available, true);
|
||||
assert.equal(lookupCalls.length, 1);
|
||||
assert.equal(lookupCalls[0].command, process.platform === 'win32' ? 'where' : 'which');
|
||||
assert.deepEqual(lookupCalls[0].args, ['rg']);
|
||||
assert.equal(lookupCalls[0].options.shell, false);
|
||||
assert.equal(lookupCalls[0].options.windowsHide, true);
|
||||
assert.equal(lookupCalls[0].options.stdio, 'ignore');
|
||||
assert.equal(lookupCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
||||
});
|
||||
|
||||
it('starts background static index build once for unindexed paths', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const { __testables } = smartSearchModule;
|
||||
const dir = createWorkspace();
|
||||
const fakePython = join(dir, 'python.exe');
|
||||
writeFileSync(fakePython, '');
|
||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'true';
|
||||
|
||||
const spawnCalls = [];
|
||||
__testables.__setRuntimeOverrides({
|
||||
getVenvPythonPath: () => fakePython,
|
||||
now: () => 1234567890,
|
||||
spawnProcess: (command, args, options) => {
|
||||
spawnCalls.push({ command, args, options });
|
||||
return createDetachedChild();
|
||||
},
|
||||
});
|
||||
|
||||
const scope = { workingDirectory: dir, searchPaths: ['.'] };
|
||||
const indexStatus = { indexed: false, has_embeddings: false };
|
||||
|
||||
const first = await __testables.maybeStartBackgroundAutoInit(scope, indexStatus);
|
||||
const second = await __testables.maybeStartBackgroundAutoInit(scope, indexStatus);
|
||||
|
||||
assert.match(first.note, /started/i);
|
||||
assert.match(second.note, /already running/i);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].command, fakePython);
|
||||
assert.deepEqual(spawnCalls[0].args, ['-m', 'codexlens', 'index', 'init', dir, '--no-embeddings']);
|
||||
assert.equal(spawnCalls[0].options.cwd, dir);
|
||||
assert.equal(
|
||||
spawnCalls[0].options.detached,
|
||||
smartSearchModule.__testables.shouldDetachBackgroundSmartSearchProcess(),
|
||||
);
|
||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
||||
});
|
||||
|
||||
it('starts background embedding build without detached Windows consoles', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const { __testables } = smartSearchModule;
|
||||
const dir = createWorkspace();
|
||||
const fakePython = join(dir, 'python.exe');
|
||||
writeFileSync(fakePython, '');
|
||||
process.env.CODEXLENS_AUTO_EMBED_MISSING = 'true';
|
||||
|
||||
const spawnCalls = [];
|
||||
__testables.__setRuntimeOverrides({
|
||||
getVenvPythonPath: () => fakePython,
|
||||
checkSemanticStatus: async () => ({ available: true, litellmAvailable: true }),
|
||||
now: () => 1234567890,
|
||||
spawnProcess: (command, args, options) => {
|
||||
spawnCalls.push({ command, args, options });
|
||||
return createDetachedChild();
|
||||
},
|
||||
});
|
||||
|
||||
const status = await __testables.maybeStartBackgroundAutoEmbed(
|
||||
{ workingDirectory: dir, searchPaths: ['.'] },
|
||||
{
|
||||
indexed: true,
|
||||
has_embeddings: false,
|
||||
config: { embedding_backend: 'fastembed' },
|
||||
},
|
||||
);
|
||||
|
||||
assert.match(status.note, /started/i);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].command, fakePython);
|
||||
assert.deepEqual(spawnCalls[0].args.slice(0, 1), ['-c']);
|
||||
assert.equal(spawnCalls[0].options.cwd, dir);
|
||||
assert.equal(
|
||||
spawnCalls[0].options.detached,
|
||||
smartSearchModule.__testables.shouldDetachBackgroundSmartSearchProcess(),
|
||||
);
|
||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
||||
assert.equal(spawnCalls[0].options.stdio, 'ignore');
|
||||
});
|
||||
|
||||
it('surfaces warnings when background static index warmup cannot start', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const { __testables } = smartSearchModule;
|
||||
const dir = createWorkspace();
|
||||
process.env.CODEXLENS_AUTO_INIT_MISSING = 'true';
|
||||
|
||||
__testables.__setRuntimeOverrides({
|
||||
getVenvPythonPath: () => join(dir, 'missing-python.exe'),
|
||||
});
|
||||
|
||||
const status = await __testables.maybeStartBackgroundAutoInit(
|
||||
{ workingDirectory: dir, searchPaths: ['.'] },
|
||||
{ indexed: false, has_embeddings: false },
|
||||
);
|
||||
|
||||
assert.match(status.warning, /Automatic static index warmup could not start/i);
|
||||
assert.match(status.warning, /not ready yet/i);
|
||||
});
|
||||
|
||||
it('honors explicit small limit values', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const dir = createWorkspace();
|
||||
const file = join(dir, 'many.ts');
|
||||
writeFileSync(file, ['const hit = 1;', 'const hit = 2;', 'const hit = 3;'].join('\n'));
|
||||
|
||||
const toolResult = await smartSearchModule.handler({
|
||||
action: 'search',
|
||||
query: 'hit',
|
||||
path: dir,
|
||||
output_mode: 'full',
|
||||
limit: 1,
|
||||
regex: false,
|
||||
tokenize: false,
|
||||
});
|
||||
|
||||
assert.equal(toolResult.success, true, toolResult.error);
|
||||
assert.equal(toolResult.result.success, true);
|
||||
assert.equal(toolResult.result.results.length, 1);
|
||||
assert.equal(toolResult.result.metadata.pagination.limit, 1);
|
||||
});
|
||||
|
||||
it('scopes search results to a single file path', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const dir = createWorkspace();
|
||||
const target = join(dir, 'target.ts');
|
||||
const other = join(dir, 'other.ts');
|
||||
writeFileSync(target, 'const TARGET_TOKEN = 1;\n');
|
||||
writeFileSync(other, 'const TARGET_TOKEN = 2;\n');
|
||||
|
||||
const toolResult = await smartSearchModule.handler({
|
||||
action: 'search',
|
||||
query: 'TARGET_TOKEN',
|
||||
path: target,
|
||||
output_mode: 'full',
|
||||
regex: false,
|
||||
tokenize: false,
|
||||
});
|
||||
|
||||
assert.equal(toolResult.success, true, toolResult.error);
|
||||
assert.equal(toolResult.result.success, true);
|
||||
assert.ok(Array.isArray(toolResult.result.results));
|
||||
assert.ok(toolResult.result.results.length >= 1);
|
||||
|
||||
const normalizedFiles = toolResult.result.results.map((item) => String(item.file).replace(/\\/g, '/'));
|
||||
assert.ok(normalizedFiles.every((file) => file.endsWith('/target.ts') || file === 'target.ts'));
|
||||
assert.ok(normalizedFiles.every((file) => !file.endsWith('/other.ts')));
|
||||
});
|
||||
|
||||
it('normalizes wrapped multiline query and file path inputs', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const dir = createWorkspace();
|
||||
const nestedDir = join(dir, 'hydro_generator_module', 'builders');
|
||||
mkdirSync(nestedDir, { recursive: true });
|
||||
const target = join(nestedDir, 'full_machine_builders.py');
|
||||
writeFileSync(target, 'def _resolve_rotor_inner():\n return rotor_main_seg\n');
|
||||
|
||||
const wrappedPath = target.replace(/([\\/])builders([\\/])/, '$1\n builders$2');
|
||||
const wrappedQuery = '_resolve_rotor_inner OR\n rotor_main_seg';
|
||||
|
||||
const toolResult = await smartSearchModule.handler({
|
||||
action: 'search',
|
||||
query: wrappedQuery,
|
||||
path: wrappedPath,
|
||||
output_mode: 'full',
|
||||
regex: false,
|
||||
caseSensitive: false,
|
||||
});
|
||||
|
||||
assert.equal(toolResult.success, true, toolResult.error);
|
||||
assert.equal(toolResult.result.success, true);
|
||||
assert.ok(toolResult.result.results.length >= 1);
|
||||
});
|
||||
|
||||
it('falls back to literal ripgrep matching for invalid regex-like code queries', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const dir = createWorkspace();
|
||||
const target = join(dir, 'component.ts');
|
||||
writeFileSync(target, 'defineExpose({ handleResize });\n');
|
||||
|
||||
const toolResult = await smartSearchModule.handler({
|
||||
action: 'search',
|
||||
query: 'defineExpose({ handleResize',
|
||||
path: dir,
|
||||
output_mode: 'full',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
assert.equal(toolResult.success, true, toolResult.error);
|
||||
assert.equal(toolResult.result.success, true);
|
||||
assert.ok(toolResult.result.results.length >= 1);
|
||||
assert.match(toolResult.result.metadata.warning, /literal ripgrep matching/i);
|
||||
});
|
||||
|
||||
it('renders grouped ace-style output by default with multi-line chunks', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const dir = createWorkspace();
|
||||
const target = join(dir, 'ace-target.ts');
|
||||
writeFileSync(target, [
|
||||
'const before = 1;',
|
||||
'const TARGET_TOKEN = 1;',
|
||||
'const after = 2;',
|
||||
'',
|
||||
'function useToken() {',
|
||||
' return TARGET_TOKEN;',
|
||||
'}',
|
||||
].join('\n'));
|
||||
|
||||
const toolResult = await smartSearchModule.handler({
|
||||
action: 'search',
|
||||
query: 'TARGET_TOKEN',
|
||||
path: dir,
|
||||
contextLines: 1,
|
||||
regex: false,
|
||||
tokenize: false,
|
||||
});
|
||||
|
||||
assert.equal(toolResult.success, true, toolResult.error);
|
||||
assert.equal(toolResult.result.success, true);
|
||||
assert.equal(toolResult.result.results.format, 'ace');
|
||||
assert.equal(Array.isArray(toolResult.result.results.groups), true);
|
||||
assert.equal(Array.isArray(toolResult.result.results.sections), true);
|
||||
assert.equal(toolResult.result.results.groups.length, 1);
|
||||
assert.equal(toolResult.result.results.groups[0].sections.length, 2);
|
||||
assert.match(toolResult.result.results.text, /The following code sections were retrieved:/);
|
||||
assert.match(toolResult.result.results.text, /Path: .*ace-target\.ts/);
|
||||
assert.match(toolResult.result.results.text, /Chunk 1: lines 1-3/);
|
||||
assert.match(toolResult.result.results.text, />\s+2 \| const TARGET_TOKEN = 1;/);
|
||||
assert.match(toolResult.result.results.text, /Chunk 2: lines 5-7/);
|
||||
assert.equal(toolResult.result.metadata.pagination.total >= 1, true);
|
||||
});
|
||||
|
||||
it('defaults embed selection to local-fast for bulk indexing', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const selection = smartSearchModule.__testables.resolveEmbeddingSelection(undefined, undefined, {
|
||||
embedding_backend: 'litellm',
|
||||
embedding_model: 'qwen3-embedding-sf',
|
||||
});
|
||||
|
||||
assert.equal(selection.backend, 'fastembed');
|
||||
assert.equal(selection.model, 'fast');
|
||||
assert.equal(selection.preset, 'bulk-local-fast');
|
||||
assert.match(selection.note, /local-fast/i);
|
||||
});
|
||||
|
||||
it('keeps explicit api embedding selection when requested', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const selection = smartSearchModule.__testables.resolveEmbeddingSelection('api', 'qwen3-embedding-sf', {
|
||||
embedding_backend: 'fastembed',
|
||||
embedding_model: 'fast',
|
||||
});
|
||||
|
||||
assert.equal(selection.backend, 'litellm');
|
||||
assert.equal(selection.model, 'qwen3-embedding-sf');
|
||||
assert.equal(selection.preset, 'explicit');
|
||||
});
|
||||
|
||||
it('parses warning-prefixed JSON and plain-text file lists for semantic fallback', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const dir = createWorkspace();
|
||||
const target = join(dir, 'target.ts');
|
||||
writeFileSync(target, 'export const target = 1;\n');
|
||||
|
||||
const parsed = smartSearchModule.__testables.parseCodexLensJsonOutput([
|
||||
'RuntimeWarning: compatibility shim',
|
||||
JSON.stringify({ results: [{ file: 'target.ts', score: 0.25, excerpt: 'target' }] }),
|
||||
].join('\n'));
|
||||
assert.equal(Array.isArray(parsed.results), true);
|
||||
assert.equal(parsed.results[0].file, 'target.ts');
|
||||
|
||||
const matches = smartSearchModule.__testables.parsePlainTextFileMatches(target, {
|
||||
workingDirectory: dir,
|
||||
searchPaths: ['.'],
|
||||
});
|
||||
assert.equal(matches.length, 1);
|
||||
assert.match(String(matches[0].file).replace(/\\/g, '/'), /target\.ts$/);
|
||||
});
|
||||
|
||||
it('uses root-scoped embedding status instead of subtree artifacts', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const summary = smartSearchModule.__testables.extractEmbeddingsStatusSummary({
|
||||
total_indexes: 3,
|
||||
indexes_with_embeddings: 2,
|
||||
total_chunks: 24,
|
||||
coverage_percent: 66.7,
|
||||
root: {
|
||||
total_files: 4,
|
||||
files_with_embeddings: 0,
|
||||
total_chunks: 0,
|
||||
coverage_percent: 0,
|
||||
has_embeddings: false,
|
||||
},
|
||||
subtree: {
|
||||
total_indexes: 3,
|
||||
indexes_with_embeddings: 2,
|
||||
total_files: 12,
|
||||
files_with_embeddings: 8,
|
||||
total_chunks: 24,
|
||||
coverage_percent: 66.7,
|
||||
},
|
||||
centralized: {
|
||||
dense_index_exists: true,
|
||||
binary_index_exists: true,
|
||||
meta_db_exists: true,
|
||||
usable: false,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(summary.coveragePercent, 0);
|
||||
assert.equal(summary.totalChunks, 0);
|
||||
assert.equal(summary.hasEmbeddings, false);
|
||||
});
|
||||
|
||||
it('accepts validated root centralized readiness from CLI status payloads', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const summary = smartSearchModule.__testables.extractEmbeddingsStatusSummary({
|
||||
total_indexes: 2,
|
||||
indexes_with_embeddings: 1,
|
||||
total_chunks: 10,
|
||||
coverage_percent: 25,
|
||||
root: {
|
||||
total_files: 2,
|
||||
files_with_embeddings: 1,
|
||||
total_chunks: 3,
|
||||
coverage_percent: 50,
|
||||
has_embeddings: true,
|
||||
},
|
||||
centralized: {
|
||||
usable: true,
|
||||
dense_ready: true,
|
||||
chunk_metadata_rows: 3,
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(summary.coveragePercent, 50);
|
||||
assert.equal(summary.totalChunks, 3);
|
||||
assert.equal(summary.hasEmbeddings, true);
|
||||
});
|
||||
|
||||
it('prefers embeddings_status over legacy embeddings summary payloads', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const payload = smartSearchModule.__testables.selectEmbeddingsStatusPayload({
|
||||
embeddings: {
|
||||
total_indexes: 7,
|
||||
indexes_with_embeddings: 4,
|
||||
total_chunks: 99,
|
||||
},
|
||||
embeddings_status: {
|
||||
total_indexes: 7,
|
||||
total_chunks: 3,
|
||||
root: {
|
||||
total_files: 2,
|
||||
files_with_embeddings: 1,
|
||||
total_chunks: 3,
|
||||
coverage_percent: 50,
|
||||
has_embeddings: true,
|
||||
},
|
||||
centralized: {
|
||||
usable: true,
|
||||
dense_ready: true,
|
||||
chunk_metadata_rows: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
assert.equal(payload.root.total_chunks, 3);
|
||||
assert.equal(payload.centralized.usable, true);
|
||||
});
|
||||
|
||||
it('recognizes CodexLens CLI compatibility failures and invalid regex fallback', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const compatibilityError = [
|
||||
'UsageError: Got unexpected extra arguments (20 0 fts)',
|
||||
'TypeError: TyperArgument.make_metavar() takes 1 positional argument but 2 were given',
|
||||
].join('\n');
|
||||
|
||||
assert.equal(
|
||||
smartSearchModule.__testables.isCodexLensCliCompatibilityError(compatibilityError),
|
||||
true,
|
||||
);
|
||||
|
||||
const resolution = smartSearchModule.__testables.resolveRipgrepQueryMode(
|
||||
'defineExpose({ handleResize',
|
||||
true,
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(resolution.regex, false);
|
||||
assert.equal(resolution.literalFallback, true);
|
||||
assert.match(resolution.warning, /literal ripgrep matching/i);
|
||||
});
|
||||
|
||||
it('suppresses compatibility-only fuzzy warnings when ripgrep already produced hits', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
assert.equal(
|
||||
smartSearchModule.__testables.shouldSurfaceCodexLensFtsCompatibilityWarning({
|
||||
compatibilityTriggeredThisQuery: true,
|
||||
skipExactDueToCompatibility: false,
|
||||
ripgrepResultCount: 2,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
smartSearchModule.__testables.shouldSurfaceCodexLensFtsCompatibilityWarning({
|
||||
compatibilityTriggeredThisQuery: true,
|
||||
skipExactDueToCompatibility: false,
|
||||
ripgrepResultCount: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
smartSearchModule.__testables.shouldSurfaceCodexLensFtsCompatibilityWarning({
|
||||
compatibilityTriggeredThisQuery: false,
|
||||
skipExactDueToCompatibility: true,
|
||||
ripgrepResultCount: 0,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('builds actionable index suggestions for unhealthy index states', () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const suggestions = smartSearchModule.__testables.buildIndexSuggestions(
|
||||
{
|
||||
indexed: true,
|
||||
has_embeddings: false,
|
||||
embeddings_coverage_percent: 0,
|
||||
warning: 'Index exists but no embeddings generated. Run smart_search(action="embed") to build the vector index.',
|
||||
},
|
||||
{
|
||||
workingDirectory: 'D:/tmp/demo',
|
||||
searchPaths: ['.'],
|
||||
},
|
||||
);
|
||||
|
||||
assert.equal(Array.isArray(suggestions), true);
|
||||
assert.match(suggestions[0].command, /smart_search\(action="embed"/);
|
||||
});
|
||||
|
||||
it('surfaces backend failure details when fuzzy search fully fails', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const missingPath = join(createWorkspace(), 'missing-folder', 'missing.ts');
|
||||
const toolResult = await smartSearchModule.handler({
|
||||
action: 'search',
|
||||
query: 'TARGET_TOKEN',
|
||||
path: missingPath,
|
||||
output_mode: 'full',
|
||||
regex: false,
|
||||
tokenize: false,
|
||||
});
|
||||
|
||||
assert.equal(toolResult.success, false);
|
||||
assert.match(toolResult.error, /Both search backends failed:/);
|
||||
assert.match(toolResult.error, /(FTS|Ripgrep)/);
|
||||
});
|
||||
|
||||
it('returns structured semantic results after local init and embed without JSON parse warnings', async () => {
|
||||
if (!smartSearchModule) return;
|
||||
|
||||
const codexLensModule = await import(new URL(`../dist/tools/codex-lens.js?smart-semantic=${Date.now()}`, import.meta.url).href);
|
||||
const ready = await codexLensModule.checkVenvStatus(true);
|
||||
if (!ready.ready) {
|
||||
console.log('Skipping: CodexLens not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const semantic = await codexLensModule.checkSemanticStatus();
|
||||
if (!semantic.available) {
|
||||
console.log('Skipping: semantic dependencies not ready');
|
||||
return;
|
||||
}
|
||||
|
||||
const dir = createWorkspace();
|
||||
writeFileSync(
|
||||
join(dir, 'sample.ts'),
|
||||
'export function parseCodexLensOutput() { return stripAnsiOutput(); }\nexport const sum = (a, b) => a + b;\n',
|
||||
);
|
||||
|
||||
const init = await smartSearchModule.handler({ action: 'init', path: dir });
|
||||
assert.equal(init.success, true, init.error ?? 'Expected init to succeed');
|
||||
|
||||
const embed = await smartSearchModule.handler({
|
||||
action: 'embed',
|
||||
path: dir,
|
||||
embeddingBackend: 'local',
|
||||
force: true,
|
||||
});
|
||||
assert.equal(embed.success, true, embed.error ?? 'Expected local embed to succeed');
|
||||
|
||||
const search = await smartSearchModule.handler({
|
||||
action: 'search',
|
||||
mode: 'semantic',
|
||||
path: dir,
|
||||
query: 'parse CodexLens output strip ANSI',
|
||||
limit: 5,
|
||||
});
|
||||
|
||||
assert.equal(search.success, true, search.error ?? 'Expected semantic search to succeed');
|
||||
assert.equal(search.result.success, true);
|
||||
assert.equal(search.result.results.format, 'ace');
|
||||
assert.ok(search.result.results.total >= 1, 'Expected at least one structured semantic match');
|
||||
assert.doesNotMatch(search.result.metadata?.warning ?? '', /Failed to parse JSON output/i);
|
||||
});
|
||||
});
|
||||
@@ -1,71 +0,0 @@
|
||||
/**
|
||||
* TypeScript parity tests for query intent detection + adaptive RRF weights.
|
||||
*
|
||||
* Notes:
|
||||
* - These tests target the runtime implementation shipped in `ccw/dist`.
|
||||
* - Keep logic aligned with Python: `codex-lens/src/codexlens/search/ranking.py`.
|
||||
*/
|
||||
|
||||
import { before, describe, it } from 'node:test';
|
||||
import assert from 'node:assert';
|
||||
|
||||
const smartSearchPath = new URL('../dist/tools/smart-search.js', import.meta.url).href;
|
||||
|
||||
describe('Smart Search (TS) - Query Intent + RRF Weights', async () => {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
let smartSearchModule: any;
|
||||
|
||||
before(async () => {
|
||||
try {
|
||||
smartSearchModule = await import(smartSearchPath);
|
||||
} catch (err: any) {
|
||||
// Keep tests non-blocking for environments that haven't built `ccw/dist` yet.
|
||||
console.log('Note: smart-search module import skipped:', err?.message ?? String(err));
|
||||
}
|
||||
});
|
||||
|
||||
describe('detectQueryIntent parity (10 cases)', () => {
|
||||
const cases: Array<[string, 'keyword' | 'semantic' | 'mixed']> = [
|
||||
['def authenticate', 'keyword'],
|
||||
['MyClass', 'keyword'],
|
||||
['user_id', 'keyword'],
|
||||
['UserService::authenticate', 'keyword'],
|
||||
['ptr->next', 'keyword'],
|
||||
['how to handle user login', 'semantic'],
|
||||
['what is authentication?', 'semantic'],
|
||||
['where is this used?', 'semantic'],
|
||||
['why does FooBar crash?', 'mixed'],
|
||||
['how to use user_id in query', 'mixed'],
|
||||
];
|
||||
|
||||
for (const [query, expected] of cases) {
|
||||
it(`classifies ${JSON.stringify(query)} as ${expected}`, () => {
|
||||
if (!smartSearchModule) return;
|
||||
assert.strictEqual(smartSearchModule.detectQueryIntent(query), expected);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
describe('adaptive weights (Python parity thresholds)', () => {
|
||||
it('uses exact-heavy weights for code-like queries (exact > 0.4)', () => {
|
||||
if (!smartSearchModule) return;
|
||||
const weights = smartSearchModule.getRRFWeights('def authenticate', {
|
||||
exact: 0.3,
|
||||
fuzzy: 0.1,
|
||||
vector: 0.6,
|
||||
});
|
||||
assert.ok(weights.exact > 0.4);
|
||||
});
|
||||
|
||||
it('uses vector-heavy weights for NL queries (vector > 0.6)', () => {
|
||||
if (!smartSearchModule) return;
|
||||
const weights = smartSearchModule.getRRFWeights('how to handle user login', {
|
||||
exact: 0.3,
|
||||
fuzzy: 0.1,
|
||||
vector: 0.6,
|
||||
});
|
||||
assert.ok(weights.vector > 0.6);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,97 +0,0 @@
|
||||
import { after, beforeEach, describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { createRequire } from 'node:module';
|
||||
import { mkdtempSync, rmSync } from 'node:fs';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const fs = require('node:fs') as typeof import('node:fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-var-requires
|
||||
const childProcess = require('node:child_process') as typeof import('node:child_process');
|
||||
|
||||
class FakeChildProcess extends EventEmitter {
|
||||
stdout = new EventEmitter();
|
||||
stderr = new EventEmitter();
|
||||
stdinChunks: string[] = [];
|
||||
stdin = {
|
||||
write: (chunk: string | Buffer) => {
|
||||
this.stdinChunks.push(String(chunk));
|
||||
return true;
|
||||
},
|
||||
end: () => undefined,
|
||||
};
|
||||
}
|
||||
|
||||
type SpawnCall = {
|
||||
command: string;
|
||||
args: string[];
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
options: any;
|
||||
child: FakeChildProcess;
|
||||
};
|
||||
|
||||
const spawnCalls: SpawnCall[] = [];
|
||||
const tempDirs: string[] = [];
|
||||
let embedderAvailable = true;
|
||||
|
||||
const originalExistsSync = fs.existsSync;
|
||||
const originalSpawn = childProcess.spawn;
|
||||
|
||||
fs.existsSync = ((..._args: unknown[]) => embedderAvailable) as typeof fs.existsSync;
|
||||
|
||||
childProcess.spawn = ((command: string, args: string[] = [], options: unknown = {}) => {
|
||||
const child = new FakeChildProcess();
|
||||
spawnCalls.push({ command: String(command), args: args.map(String), options, child });
|
||||
|
||||
queueMicrotask(() => {
|
||||
child.stdout.emit('data', JSON.stringify({
|
||||
success: true,
|
||||
total_chunks: 4,
|
||||
hnsw_available: true,
|
||||
hnsw_count: 4,
|
||||
dimension: 384,
|
||||
}));
|
||||
child.emit('close', 0);
|
||||
});
|
||||
|
||||
return child as unknown as ReturnType<typeof childProcess.spawn>;
|
||||
}) as typeof childProcess.spawn;
|
||||
|
||||
after(() => {
|
||||
fs.existsSync = originalExistsSync;
|
||||
childProcess.spawn = originalSpawn;
|
||||
while (tempDirs.length > 0) {
|
||||
rmSync(tempDirs.pop() as string, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
describe('unified-vector-index', () => {
|
||||
beforeEach(() => {
|
||||
embedderAvailable = true;
|
||||
spawnCalls.length = 0;
|
||||
});
|
||||
|
||||
it('spawns CodexLens venv python with hidden window options', async () => {
|
||||
const projectDir = mkdtempSync(join(tmpdir(), 'ccw-unified-vector-index-'));
|
||||
tempDirs.push(projectDir);
|
||||
|
||||
const moduleUrl = new URL('../dist/core/unified-vector-index.js', import.meta.url);
|
||||
moduleUrl.searchParams.set('t', String(Date.now()));
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const mod: any = await import(moduleUrl.href);
|
||||
|
||||
const index = new mod.UnifiedVectorIndex(projectDir);
|
||||
const status = await index.getStatus();
|
||||
|
||||
assert.equal(status.success, true);
|
||||
assert.equal(spawnCalls.length, 1);
|
||||
assert.equal(spawnCalls[0].options.shell, false);
|
||||
assert.equal(spawnCalls[0].options.windowsHide, true);
|
||||
assert.equal(spawnCalls[0].options.env.PYTHONIOENCODING, 'utf-8');
|
||||
assert.deepEqual(spawnCalls[0].options.stdio, ['pipe', 'pipe', 'pipe']);
|
||||
assert.match(spawnCalls[0].child.stdinChunks.join(''), /"operation":"status"/);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user