Skip to content

Commit 662b64d

Browse files
test(server): cover ctx_index relative path resolution (mksglu#365)
PR mksglu#365 fixes `ctx_index` to resolve a relative `path` argument against the detected project directory instead of the MCP server cwd, but ships without a regression test — the author attempted one (commit b659944) and reverted it (commit 60e6091) when blocked locally by Node 24 + better-sqlite3. Mirrors the existing `executeFile: projectRoot path resolution` pattern (server.test.ts:598) by spawning a fresh MCP server per case and driving ctx_index over real JSON-RPC. Three cases in `describe("ctx_index: projectRoot path resolution (mksglu#365)")`: - `relative path resolves against CLAUDE_PROJECT_DIR, not server cwd` — index a relative path under a temp project dir, then ctx_search for a unique marker to confirm content really came from that dir. - `absolute path bypasses project-dir resolution` — point CLAUDE_PROJECT_DIR at a non-existent dir and confirm an absolute path still indexes. - `relative path label preserves user input (not resolved absolute)` — asserts the ContentStore label is the user-typed relative path, not the resolved absolute path; keeps `ctx_search(source: "<rel-path>")` working as users expect. Adds an `awaitRpc` helper because the MCP server processes JSON-RPC requests concurrently — a piggybacked search would otherwise race the index and hit the empty-store guard. Sends each request and waits for its response before moving on. Tests: - npx vitest run tests/core/server.test.ts -t "ctx_index: projectRoot" → 3/3 pass - npm test → 1812 pass, 24 skipped, 0 regressions vs PR's baseline (the 2 pre-existing cli.test.ts ABI failures are unrelated to this PR and exist on its base branch already) - npm run typecheck → clean
1 parent 60e6091 commit 662b64d

1 file changed

Lines changed: 160 additions & 1 deletion

File tree

tests/core/server.test.ts

Lines changed: 160 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import { join, dirname, resolve } from "node:path";
1818
import { tmpdir } from "node:os";
1919
import { fileURLToPath } from "node:url";
2020
import { createRequire } from "node:module";
21-
import { describe, test, expect, afterAll } from "vitest";
21+
import { describe, test, expect, beforeAll, afterAll } from "vitest";
2222

2323
import { classifyNonZeroExit } from "../../src/exit-classify.js";
2424
import { PolyglotExecutor } from "../../src/executor.js";
@@ -749,6 +749,165 @@ print(f"count: {data['count']}")
749749
});
750750
});
751751

752+
// ═══════════════════════════════════════════════════════════════════════════
753+
// ctx_index: projectRoot path resolution (#365)
754+
// ═══════════════════════════════════════════════════════════════════════════
755+
//
756+
// Mirrors the executeFile relative-path resolution tests (line ~598). Confirms
757+
// that ctx_index resolves a relative `path` argument against the detected
758+
// project directory (CLAUDE_PROJECT_DIR / *_PROJECT_DIR / CONTEXT_MODE_PROJECT_DIR
759+
// → cwd fallback) instead of the MCP server process cwd. End-to-end via
760+
// JSON-RPC against a freshly spawned server with an injected project dir.
761+
762+
describe("ctx_index: projectRoot path resolution (#365)", () => {
763+
const ctxProjectDir = mkdtempSync(join(tmpdir(), "ctx-index-projroot-"));
764+
const ctxFileName = "ctx-index-projroot-target.md";
765+
const uniqueMarker = `ctx-index-marker-${process.pid}-${Date.now()}`;
766+
767+
beforeAll(() => {
768+
writeFileSync(
769+
join(ctxProjectDir, ctxFileName),
770+
`# ctx_index relative path test\n\nUnique marker: ${uniqueMarker}\n`,
771+
"utf-8",
772+
);
773+
});
774+
775+
afterAll(() => {
776+
rmSync(ctxProjectDir, { recursive: true, force: true });
777+
});
778+
779+
function spawnServerWithProjectDir(projectDirEnv: string): ChildProcess {
780+
return spawn("node", [mcpEntry], {
781+
stdio: ["pipe", "pipe", "pipe"],
782+
env: {
783+
...process.env,
784+
CONTEXT_MODE_DISABLE_VERSION_CHECK: "1",
785+
CLAUDE_PROJECT_DIR: projectDirEnv,
786+
},
787+
});
788+
}
789+
790+
// MCP server processes JSON-RPC requests concurrently — we have to wait for
791+
// each response before sending the next one in tests that depend on order
792+
// (e.g. index then search). The shared `collectRpcResponses` helper kills
793+
// the proc once all expected ids arrive, so we use it serially per-call.
794+
async function awaitRpc(
795+
proc: ChildProcess,
796+
id: number,
797+
request: Record<string, unknown>,
798+
timeoutMs = 15_000,
799+
): Promise<DoctorJsonRpcResponse | undefined> {
800+
return new Promise((resolve) => {
801+
let buffer = "";
802+
const onData = (d: Buffer) => {
803+
buffer += d.toString();
804+
let idx: number;
805+
while ((idx = buffer.indexOf("\n")) >= 0) {
806+
const line = buffer.slice(0, idx).trim();
807+
buffer = buffer.slice(idx + 1);
808+
if (!line) continue;
809+
try {
810+
const parsed = JSON.parse(line) as DoctorJsonRpcResponse;
811+
if (parsed.id === id) {
812+
proc.stdout!.off("data", onData);
813+
clearTimeout(timer);
814+
resolve(parsed);
815+
return;
816+
}
817+
} catch { /* ignore */ }
818+
}
819+
};
820+
const timer = setTimeout(() => {
821+
proc.stdout!.off("data", onData);
822+
resolve(undefined);
823+
}, timeoutMs);
824+
proc.stdout!.on("data", onData);
825+
sendRpc(proc, request);
826+
});
827+
}
828+
829+
test("relative path resolves against CLAUDE_PROJECT_DIR, not server cwd", async () => {
830+
const proc = spawnServerWithProjectDir(ctxProjectDir);
831+
try {
832+
await awaitRpc(proc, 1, {
833+
jsonrpc: "2.0", id: 1, method: "initialize",
834+
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-pr365", version: "1.0" } },
835+
});
836+
sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });
837+
838+
const indexResp = await awaitRpc(proc, 100, {
839+
jsonrpc: "2.0", id: 100, method: "tools/call",
840+
params: { name: "ctx_index", arguments: { path: ctxFileName } },
841+
});
842+
843+
expect(indexResp?.error).toBeUndefined();
844+
const indexText = indexResp?.result?.content?.[0]?.text ?? "";
845+
expect(indexText).toMatch(/Indexed \d+ section/);
846+
847+
// Only send search AFTER index has completed — MCP server processes
848+
// requests concurrently, so a piggybacked search would race the index.
849+
const searchResp = await awaitRpc(proc, 101, {
850+
jsonrpc: "2.0", id: 101, method: "tools/call",
851+
params: { name: "ctx_search", arguments: { queries: [uniqueMarker] } },
852+
});
853+
854+
expect(searchResp?.error).toBeUndefined();
855+
const searchText = searchResp?.result?.content?.[0]?.text ?? "";
856+
expect(searchText).toContain(uniqueMarker);
857+
} finally {
858+
try { proc.kill("SIGTERM"); } catch { /* best effort */ }
859+
}
860+
}, 30_000);
861+
862+
test("absolute path bypasses project-dir resolution", async () => {
863+
const absFile = join(ctxProjectDir, ctxFileName);
864+
const proc = spawnServerWithProjectDir("/non-existent-dir-on-purpose");
865+
try {
866+
await awaitRpc(proc, 1, {
867+
jsonrpc: "2.0", id: 1, method: "initialize",
868+
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-pr365-abs", version: "1.0" } },
869+
});
870+
sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });
871+
872+
const indexResp = await awaitRpc(proc, 100, {
873+
jsonrpc: "2.0", id: 100, method: "tools/call",
874+
params: { name: "ctx_index", arguments: { path: absFile } },
875+
});
876+
877+
expect(indexResp?.error).toBeUndefined();
878+
const indexText = indexResp?.result?.content?.[0]?.text ?? "";
879+
expect(indexText).toMatch(/Indexed \d+ section/);
880+
} finally {
881+
try { proc.kill("SIGTERM"); } catch { /* best effort */ }
882+
}
883+
}, 30_000);
884+
885+
test("relative path label preserves user input (not resolved absolute)", async () => {
886+
const proc = spawnServerWithProjectDir(ctxProjectDir);
887+
try {
888+
await awaitRpc(proc, 1, {
889+
jsonrpc: "2.0", id: 1, method: "initialize",
890+
params: { protocolVersion: "2024-11-05", capabilities: {}, clientInfo: { name: "ctx-index-pr365-label", version: "1.0" } },
891+
});
892+
sendRpc(proc, { jsonrpc: "2.0", method: "notifications/initialized" });
893+
894+
const indexResp = await awaitRpc(proc, 100, {
895+
jsonrpc: "2.0", id: 100, method: "tools/call",
896+
params: { name: "ctx_index", arguments: { path: ctxFileName } },
897+
});
898+
899+
expect(indexResp?.error).toBeUndefined();
900+
const indexText = indexResp?.result?.content?.[0]?.text ?? "";
901+
// Label must reflect the relative path the user typed, not the resolved absolute path.
902+
// Keeps `ctx_search(source: "<relative-path>")` working as the user expects.
903+
expect(indexText).toContain(`from: ${ctxFileName}`);
904+
expect(indexText).not.toContain(ctxProjectDir);
905+
} finally {
906+
try { proc.kill("SIGTERM"); } catch { /* best effort */ }
907+
}
908+
}, 30_000);
909+
});
910+
752911
// ═══════════════════════════════════════════════════════════════════════════
753912
// 5. Subagent Output Budget (subagent-budget)
754913
// ═══════════════════════════════════════════════════════════════════════════

0 commit comments

Comments
 (0)