import assert from "node:assert/strict"; import { mkdir, mkdtemp, rm, utimes, writeFile } from "node:fs/promises"; import { tmpdir } from "node:os"; import { join } from "node:path"; import test from "node:test"; import { buildExploreArgs, createCodegraphExploreHandler, findIndexedRoot, isNotIndexedOutput, type CodegraphExec, type ExploreParams, } from "../extensions/codegraph.ts"; async function makeIndexedProject(): Promise<{ root: string; cleanup: () => Promise }> { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); await mkdir(join(root, ".codegraph")); await writeFile(join(root, ".codegraph", "codegraph.db"), ""); return { root, cleanup: () => rm(root, { recursive: true, force: true }) }; } function mockCtx(cwd: string, confirm?: () => Promise) { return { cwd, hasUI: confirm !== undefined, ui: { confirm: confirm ?? (async () => false) }, } as never; } function mockExec(handler: (command: string, args: string[]) => { code?: number; stdout?: string; stderr?: string }) { const calls: Array<{ command: string; args: string[] }> = []; const exec: CodegraphExec = async (command, args) => { calls.push({ command, args }); const result = handler(command, args); return { code: result.code ?? 0, stdout: result.stdout ?? "", stderr: result.stderr ?? "", killed: false }; }; return { exec, calls }; } function makeHandler(exec: CodegraphExec) { return { handle: createCodegraphExploreHandler(exec, new Map(), new Map()), }; } test("findIndexedRoot locates the nearest ancestor index", async () => { const { root, cleanup } = await makeIndexedProject(); try { const nested = join(root, "src", "deep"); await mkdir(nested, { recursive: true }); assert.equal(await findIndexedRoot(nested), root); assert.equal(await findIndexedRoot(root), root); } finally { await cleanup(); } }); test("findIndexedRoot returns undefined when no index exists", async () => { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); try { assert.equal(await findIndexedRoot(root), undefined); } finally { await rm(root, { recursive: true, force: true }); } }); test("buildExploreArgs maps params to CLI argv", () => { assert.deepEqual(buildExploreArgs({ query: "auth flow" }, "/repo"), ["explore", "-p", "/repo", "auth flow"]); assert.deepEqual( buildExploreArgs({ query: "q", maxFiles: 5, projectPath: "/ignored" }, "/repo"), ["explore", "-p", "/repo", "--max-files", "5", "q"], ); }); test("isNotIndexedOutput detects the upstream marker", () => { assert.equal(isNotIndexedOutput("✗ CodeGraph isn't available here — no .codegraph/ index exists in /x."), true); assert.equal(isNotIndexedOutput("Found 3 symbols"), false); }); test("explore runs the CLI and caches by database mtime", async () => { const { root, cleanup } = await makeIndexedProject(); const { exec, calls } = mockExec(() => ({ stdout: "explore output" })); const { handle } = makeHandler(exec); try { const first = await handle({ query: "auth" }, mockCtx(root)); assert.equal(first.content[0].text, "explore output"); const second = await handle({ query: "auth" }, mockCtx(root)); assert.equal(second.content[0].text, "explore output"); assert.equal(calls.length, 1, "identical query on unchanged index must hit the cache"); // Simulate a reindex: bump the db mtime. await utimes(join(root, ".codegraph", "codegraph.db"), new Date(), new Date(Date.now() + 5000)); await handle({ query: "auth" }, mockCtx(root)); assert.equal(calls.length, 2, "mtime change must invalidate the cached result"); } finally { await cleanup(); } }); test("uninitialized project asks the user and indexes on approval", async () => { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const { exec, calls } = mockExec((_command, args) => { if (args[0] === "init") { // Emulate codegraph init creating the index. return { stdout: "indexed" }; } return { stdout: "explore output" }; }); const { handle } = makeHandler(exec); try { const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); assert.equal(result.content[0].text, "explore output"); assert.deepEqual( calls.map(({ args }) => args[0]), ["init", "explore"], ); assert.deepEqual(calls[0].args, ["init", root]); } finally { await rm(root, { recursive: true, force: true }); } }); test("uninitialized project bounces to built-in tools when declined", async () => { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const { exec, calls } = mockExec(() => ({ stdout: "unreachable" })); const { handle } = makeHandler(exec); try { const result = await handle({ query: "auth" }, mockCtx(root, async () => false)); assert.match(result.content[0].text ?? "", /find\/grep\/read\/LSP/); assert.equal(calls.length, 0, "declined init must not spawn the CLI at all"); } finally { await rm(root, { recursive: true, force: true }); } }); test("concurrent calls on an uninitialized project share one init decision", async () => { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); let confirmCalls = 0; const { exec, calls } = mockExec(() => ({ stdout: "ok" })); const pending = new Map>(); const handle = createCodegraphExploreHandler(exec, new Map(), pending); try { const [a, b] = await Promise.all([ handle({ query: "one" }, mockCtx(root, async () => { confirmCalls += 1; return true; })), handle({ query: "two" }, mockCtx(root, async () => { confirmCalls += 1; return true; })), ]); assert.equal(a.content[0].text, "ok"); assert.equal(b.content[0].text, "ok"); assert.equal(confirmCalls, 1, "parallel callers must not double-prompt"); assert.equal( calls.filter(({ args }) => args[0] === "init").length, 1, "parallel callers must share a single init", ); } finally { await rm(root, { recursive: true, force: true }); } }); test("init failure is reported instead of exploring", async () => { const root = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const { exec } = mockExec((_command, args) => args[0] === "init" ? { code: 1, stderr: "boom" } : { stdout: "unreachable" }, ); const { handle } = makeHandler(exec); try { const result = await handle({ query: "auth" }, mockCtx(root, async () => true)); assert.match(result.content[0].text ?? "", /codegraph init 失败/); } finally { await rm(root, { recursive: true, force: true }); } }); test("missing CLI surfaces an install hint instead of throwing", async () => { const { root, cleanup } = await makeIndexedProject(); const exec: CodegraphExec = async () => { throw new Error("spawn codegraph ENOENT"); }; const { handle } = makeHandler(exec); try { const result = await handle({ query: "auth" }, mockCtx(root)); assert.match(result.content[0].text ?? "", /无法执行 codegraph CLI/); } finally { await cleanup(); } }); test("projectPath overrides the session cwd", async () => { const { root, cleanup } = await makeIndexedProject(); const other = await mkdtemp(join(tmpdir(), "my-pi-codegraph-")); const { exec, calls } = mockExec(() => ({ stdout: "ok" })); const { handle } = makeHandler(exec); try { const params: ExploreParams = { query: "auth", projectPath: root }; await handle(params, mockCtx(other)); assert.deepEqual(calls[0].args, ["explore", "-p", root, "auth"]); } finally { await cleanup(); await rm(other, { recursive: true, force: true }); } });