mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
0a9f3de72c
* test: close resolution graphs before removing Windows fixtures
Close ArkTS and JVM graph connections in afterEach, including failed assertions. Verified both files on Windows 11 and macOS (32 tests).
* test: compare explore source lines across LF and CRLF checkouts
Normalize only line endings when matching emitted source against checked-out fixtures. Preserve the closure coverage and whole-line assertions. Both files pass on Windows 11 and macOS (15 tests).
* test: exercise old Git fallback without a POSIX shell shim
Inject only the unsupported ls-files invocation and run every other Git command against real repositories. Assert that each rejected call retries, use portable graph paths, and clean up fixtures. Passes on Windows 11 and macOS.
* test: normalize Python fixture before generating CRLF cases
The tests added in #2004 produced CRCRLF from a Windows checkout. Normalize the input to LF before constructing either variant. All 28 native and WASM cases pass on Windows 11 and macOS.
* test: allow time for eviction fixture indexes on Windows
The #2036 eviction test builds and reconciles nine real indexes and consistently exceeds its default five-second timeout on the VM. Allow 15 seconds for this test alone, preserving every assertion. The full file passes on Windows 11 and macOS (21 tests).
* test: finish MCP processes before resetting Windows fixtures
Launch actual servers with their runtime flags and await termination before directory removal. Use asynchronous removal retries for daemon fixtures. Establish a ready daemon and stop its first proxy before simulating PID reuse, then make source stale without opening a competing SQLite writer. Preserve lock and database byte-equality assertions. All touched files validated on Windows 11 and macOS.
* test: allow Windows named-pipe fallback polling to finish
The VM takes up to 25.7 seconds to complete the nominal six-second retry loop. Allow 30 seconds for the three fallback waits and 40 seconds for the two shorter enclosing tests, retaining their lock-preservation and read-only assertions. The full daemon file is validated on Windows 11 and macOS.
* fix(daemon): retry transient Windows PID-file sharing violations
A transient EPERM replacing daemon.pid aborted startup, observed in the quiet-client test and reproduced with a real locked handle at ba3c21e5. Retry only Windows sharing errors with five bounded delays, rechecking ownership each time and cleaning temporary files. Keep publication synchronous before client processing. Cover transient/permanent failures, changed ownership, non-retryable errors, and a real Windows handle; require a confirmed daemon round-trip before measuring a quiet session. Validated on Windows 11 and macOS.
195 lines
8.1 KiB
TypeScript
195 lines
8.1 KiB
TypeScript
/**
|
|
* MCP workspace sub-project adoption + no-default diagnostics (#1606, #1607).
|
|
*
|
|
* When an MCP host launches the server from a workspace root whose indexed
|
|
* projects live in CHILD directories (a repo container, a monorepo root), the
|
|
* upward walk finds nothing. The server now runs the same bounded down-scan
|
|
* the front-load hook uses:
|
|
* - exactly ONE indexed sub-project → adopted as the session's default;
|
|
* - zero or several → no default, but the state is SAID:
|
|
* stderr names what was searched/found, and tool calls list the indexed
|
|
* sub-projects so the agent can pass one as `projectPath`;
|
|
* - non-workspace base (no manifest, no .git) → no scan at all.
|
|
*
|
|
* Same real-subprocess harness as mcp-roots.test.ts — no mocking.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
|
|
import * as fs from 'fs';
|
|
import * as path from 'path';
|
|
import * as os from 'os';
|
|
import { CodeGraph } from '../src';
|
|
import { once } from 'events';
|
|
import { WASM_RUNTIME_FLAGS } from '../src/extraction/wasm-runtime-flags';
|
|
|
|
const BIN = path.resolve(__dirname, '../dist/bin/codegraph.js');
|
|
|
|
function spawnServer(cwd: string): ChildProcessWithoutNullStreams {
|
|
// --no-watch keeps the test deterministic; CODEGRAPH_NO_DAEMON keeps the
|
|
// session in direct mode so no detached daemon outlives the test.
|
|
return spawn(process.execPath, [...WASM_RUNTIME_FLAGS, BIN, 'serve', '--mcp', '--no-watch'], {
|
|
cwd,
|
|
stdio: ['pipe', 'pipe', 'pipe'],
|
|
env: { ...process.env, CODEGRAPH_NO_DAEMON: '1', CODEGRAPH_WASM_RELAUNCHED: '1' },
|
|
}) as ChildProcessWithoutNullStreams;
|
|
}
|
|
|
|
function collectMessages(child: ChildProcessWithoutNullStreams): Array<Record<string, any>> {
|
|
const messages: Array<Record<string, any>> = [];
|
|
let buf = '';
|
|
child.stdout.on('data', (chunk) => {
|
|
buf += chunk.toString('utf8');
|
|
let idx;
|
|
while ((idx = buf.indexOf('\n')) !== -1) {
|
|
const line = buf.slice(0, idx).trim();
|
|
buf = buf.slice(idx + 1);
|
|
if (!line) continue;
|
|
try { messages.push(JSON.parse(line)); } catch { /* ignore non-JSON */ }
|
|
}
|
|
});
|
|
return messages;
|
|
}
|
|
|
|
function collectStderr(child: ChildProcessWithoutNullStreams): { text: () => string } {
|
|
let buf = '';
|
|
child.stderr.on('data', (chunk) => { buf += chunk.toString('utf8'); });
|
|
return { text: () => buf };
|
|
}
|
|
|
|
function waitForMessage(
|
|
messages: ReadonlyArray<Record<string, any>>,
|
|
predicate: (m: Record<string, any>) => boolean,
|
|
timeoutMs: number,
|
|
): Promise<Record<string, any>> {
|
|
return new Promise((resolve, reject) => {
|
|
const started = Date.now();
|
|
const tick = () => {
|
|
const hit = messages.find(predicate);
|
|
if (hit) return resolve(hit);
|
|
if (Date.now() - started > timeoutMs) {
|
|
return reject(new Error(`Timed out. Messages so far: ${JSON.stringify(messages)}`));
|
|
}
|
|
setTimeout(tick, 20);
|
|
};
|
|
tick();
|
|
});
|
|
}
|
|
|
|
function send(child: ChildProcessWithoutNullStreams, msg: object): void {
|
|
child.stdin.write(JSON.stringify(msg) + '\n');
|
|
}
|
|
|
|
const CLIENT_INFO = { name: 'test', version: '0.0.0' };
|
|
|
|
/** Create ws/<name> with one source file and an initialized .codegraph/. */
|
|
async function makeIndexedChild(ws: string, name: string): Promise<string> {
|
|
const dir = path.join(ws, name);
|
|
fs.mkdirSync(dir, { recursive: true });
|
|
fs.writeFileSync(path.join(dir, 'a.ts'), `export function hello_${name}() { return 1; }\n`);
|
|
const cg = await CodeGraph.init(dir);
|
|
cg.close();
|
|
return dir;
|
|
}
|
|
|
|
/** initialize (no rootUri, no roots capability) → initialized → codegraph_status. */
|
|
async function driveStatusCall(
|
|
child: ChildProcessWithoutNullStreams,
|
|
messages: Array<Record<string, any>>,
|
|
): Promise<{ initResult: Record<string, any>; statusText: string }> {
|
|
send(child, {
|
|
jsonrpc: '2.0', id: 0, method: 'initialize',
|
|
params: { protocolVersion: '2025-11-25', capabilities: {}, clientInfo: CLIENT_INFO },
|
|
});
|
|
const initResult = await waitForMessage(messages, (m) => m.id === 0 && !!m.result, 5000);
|
|
send(child, { jsonrpc: '2.0', method: 'notifications/initialized' });
|
|
send(child, { jsonrpc: '2.0', id: 1, method: 'tools/call', params: { name: 'codegraph_status', arguments: {} } });
|
|
const resp = await waitForMessage(messages, (m) => m.id === 1, 10000);
|
|
return { initResult, statusText: resp.result.content[0].text as string };
|
|
}
|
|
|
|
describe('MCP workspace sub-project adoption (#1606) + no-default diagnostics (#1607)', () => {
|
|
let ws: string;
|
|
let child: ChildProcessWithoutNullStreams | null = null;
|
|
|
|
beforeEach(() => {
|
|
ws = fs.mkdtempSync(path.join(os.tmpdir(), 'codegraph-mcp-ws-'));
|
|
});
|
|
|
|
afterEach(async () => {
|
|
// Spawn with the runtime flags so this is the server, not a relauncher
|
|
// whose grandchild would retain the Windows cwd/database handles.
|
|
if (child && child.exitCode === null && child.signalCode === null) {
|
|
const closed = once(child, 'close');
|
|
child.kill('SIGKILL');
|
|
await closed;
|
|
}
|
|
child = null;
|
|
fs.rmSync(ws, { recursive: true, force: true });
|
|
});
|
|
|
|
it('adopts the single indexed sub-project below a workspace root as the default project', async () => {
|
|
fs.mkdirSync(path.join(ws, '.git')); // workspace marker — no manifest needed
|
|
await makeIndexedChild(ws, 'service-a');
|
|
|
|
child = spawnServer(ws);
|
|
const messages = collectMessages(child);
|
|
const stderr = collectStderr(child);
|
|
|
|
const { initResult, statusText } = await driveStatusCall(child, messages);
|
|
|
|
// The default project works without any projectPath.
|
|
expect(statusText).toContain('CodeGraph Status');
|
|
expect(statusText).not.toContain('No CodeGraph project is loaded');
|
|
// The adoption is announced on stderr (#1607 discoverability).
|
|
expect(stderr.text()).toContain('adopted the single indexed sub-project');
|
|
expect(stderr.text()).toContain('service-a');
|
|
// Instructions match what the engine adopted: the FULL single-project
|
|
// playbook, not the per-project variant.
|
|
const instructions = initResult.result.instructions as string;
|
|
expect(instructions).not.toContain('per-project; pass projectPath');
|
|
}, 20000);
|
|
|
|
it('lists several indexed sub-projects instead of adopting one, in stderr and in tool responses', async () => {
|
|
fs.mkdirSync(path.join(ws, '.git'));
|
|
await makeIndexedChild(ws, 'service-a');
|
|
await makeIndexedChild(ws, 'service-b');
|
|
|
|
child = spawnServer(ws);
|
|
const messages = collectMessages(child);
|
|
const stderr = collectStderr(child);
|
|
|
|
const { initResult, statusText } = await driveStatusCall(child, messages);
|
|
|
|
// No default was adopted — ambiguous — but the state is said, not silent.
|
|
expect(statusText).toContain('No CodeGraph project is loaded');
|
|
// Protocol-reachable listing (#1607): the tool response names what IS there.
|
|
expect(statusText).toContain('Indexed sub-projects were found below it');
|
|
expect(statusText).toContain('service-a');
|
|
expect(statusText).toContain('service-b');
|
|
expect(statusText).toContain('projectPath');
|
|
// stderr carries the same facts for the host's log.
|
|
expect(stderr.text()).toContain('no default project, live sync disabled');
|
|
expect(stderr.text()).toContain('Indexed sub-projects found:');
|
|
// Ambiguous root → per-project instructions variant.
|
|
const instructions = initResult.result.instructions as string;
|
|
expect(instructions).toContain('per-project; pass projectPath');
|
|
}, 20000);
|
|
|
|
it('does not scan below a base that is not a workspace (no manifest, no .git)', async () => {
|
|
// NO .git and no manifest at ws — the gate must keep the scan off even
|
|
// though an indexed child exists.
|
|
await makeIndexedChild(ws, 'service-a');
|
|
|
|
child = spawnServer(ws);
|
|
const messages = collectMessages(child);
|
|
const stderr = collectStderr(child);
|
|
|
|
const { statusText } = await driveStatusCall(child, messages);
|
|
|
|
expect(statusText).toContain('No CodeGraph project is loaded');
|
|
expect(statusText).not.toContain('Indexed sub-projects were found below it');
|
|
expect(stderr.text()).toContain('no default project, live sync disabled');
|
|
expect(stderr.text()).not.toContain('Indexed sub-projects found:');
|
|
}, 20000);
|
|
});
|