Files
wehub-resource-sync 979fb22d7c
CI / test (20, macos-latest) (push) Waiting to run
CI / test (20, ubuntu-latest) (push) Waiting to run
CI / test (22, macos-latest) (push) Waiting to run
CI / test (22, ubuntu-latest) (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 13:01:18 +08:00

453 lines
14 KiB
TypeScript

import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
vi.mock("../src/logger.js", () => ({
logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn() },
}));
vi.mock("node:fs", () => ({
existsSync: vi.fn().mockReturnValue(false),
readFileSync: vi.fn(),
writeFileSync: vi.fn(),
mkdirSync: vi.fn(),
}));
vi.mock("../src/mcp/transport.js", () => ({
createStdioTransport: vi.fn(() => ({ start: vi.fn(), stop: vi.fn() })),
}));
vi.mock("../src/config.js", () => ({
getStandalonePersistPath: vi.fn(() => "/tmp/test-standalone.json"),
}));
import {
getAllTools,
CORE_TOOLS,
V040_TOOLS,
} from "../src/mcp/tools-registry.js";
import { InMemoryKV } from "../src/mcp/in-memory-kv.js";
import { handleToolCall } from "../src/mcp/standalone.js";
import {
resetHandleForTests,
setLivezProbe,
} from "../src/mcp/rest-proxy.js";
import { writeFileSync } from "node:fs";
// Issue #449: hard-coded fetch() against :3111 in the livez probe was racing
// with vitest's mock setup, making this file the "10-11 pre-existing failures"
// referenced in the last 5 release notes. Stub the probe with an instant
// ok:false response so the shim takes the deterministic InMemoryKV fallback
// path on every test. Guard the real network with a fetch trap so any
// regression that bypasses the DI seam fails loudly instead of timing out.
const instantLocalFallbackProbe = vi.fn(async () => ({
ok: false,
status: 0,
statusText: "stubbed: forced local fallback",
}));
const fetchTrap = vi.fn(async (url: unknown) => {
throw new Error(
`unexpected real fetch() call in mcp-standalone.test.ts: ${String(url)} — the livez probe DI stub should have absorbed this`,
);
});
describe("Tools Registry", () => {
it("getAllTools returns all tools with unique names", () => {
const tools = getAllTools();
expect(tools.length).toBeGreaterThanOrEqual(41);
const names = new Set(tools.map((t) => t.name));
expect(names.size).toBe(tools.length);
for (const required of [
"memory_verify",
"memory_lesson_save",
"memory_lesson_recall",
"memory_obsidian_export",
"memory_save",
"memory_recall",
]) {
expect(tools.some((t) => t.name === required)).toBe(true);
}
});
it("CORE_TOOLS has 14 items", () => {
expect(CORE_TOOLS.length).toBe(14);
});
it("V040_TOOLS has 8 items", () => {
expect(V040_TOOLS.length).toBe(8);
});
it("all tools have required name, description, inputSchema fields", () => {
const tools = getAllTools();
for (const tool of tools) {
expect(tool.name).toBeDefined();
expect(typeof tool.name).toBe("string");
expect(tool.name.length).toBeGreaterThan(0);
expect(tool.description).toBeDefined();
expect(typeof tool.description).toBe("string");
expect(tool.inputSchema).toBeDefined();
expect(tool.inputSchema.type).toBe("object");
expect(tool.inputSchema.properties).toBeDefined();
}
});
});
describe("InMemoryKV", () => {
let kv: InMemoryKV;
beforeEach(() => {
kv = new InMemoryKV();
});
it("get/set/list/delete operations work", async () => {
await kv.set("scope1", "key1", { value: "hello" });
const result = await kv.get<{ value: string }>("scope1", "key1");
expect(result).toEqual({ value: "hello" });
const list = await kv.list("scope1");
expect(list.length).toBe(1);
await kv.delete("scope1", "key1");
const afterDelete = await kv.get("scope1", "key1");
expect(afterDelete).toBeNull();
});
it("list returns empty array for unknown scope", async () => {
const result = await kv.list("nonexistent");
expect(result).toEqual([]);
});
it("persist writes JSON", async () => {
const kvWithPersist = new InMemoryKV("/tmp/test-kv.json");
await kvWithPersist.set("scope1", "key1", { data: "test" });
kvWithPersist.persist();
expect(writeFileSync).toHaveBeenCalledWith(
"/tmp/test-kv.json",
expect.any(String),
"utf-8",
);
const written = vi.mocked(writeFileSync).mock.calls[0][1] as string;
const parsed = JSON.parse(written);
expect(parsed.scope1.key1).toEqual({ data: "test" });
});
it("set overwrites existing values", async () => {
await kv.set("scope1", "key1", "first");
await kv.set("scope1", "key1", "second");
const result = await kv.get("scope1", "key1");
expect(result).toBe("second");
const list = await kv.list("scope1");
expect(list.length).toBe(1);
});
});
describe("handleToolCall", () => {
const originalFetch = globalThis.fetch;
beforeEach(() => {
vi.mocked(writeFileSync).mockClear();
instantLocalFallbackProbe.mockClear();
fetchTrap.mockClear();
// Order matters: resetHandleForTests() restores the default probe and
// clears the cached handle. Install the stub AFTER the reset so the
// shim's next resolveHandle() call hits the stubbed instant-fail path
// instead of the real 2s AbortController fetch.
resetHandleForTests();
setLivezProbe(instantLocalFallbackProbe);
(globalThis as { fetch: typeof fetch }).fetch = fetchTrap as unknown as typeof fetch;
});
afterEach(() => {
(globalThis as { fetch: typeof fetch }).fetch = originalFetch;
resetHandleForTests();
});
it("livez probe stub is invoked instead of the real fetch (issue #449)", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "regression guard" }, kv);
expect(instantLocalFallbackProbe).toHaveBeenCalledTimes(1);
expect(fetchTrap).not.toHaveBeenCalled();
});
it("memory_save persists to disk immediately after saving", async () => {
const kv = new InMemoryKV("/tmp/test-handle.json");
const result = await handleToolCall(
"memory_save",
{ content: "Test memory content" },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.saved).toMatch(/^mem_/);
expect(writeFileSync).toHaveBeenCalledWith(
"/tmp/test-handle.json",
expect.any(String),
"utf-8",
);
});
it("memory_save without persist path does not call writeFileSync", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "No persist path" }, kv);
expect(writeFileSync).not.toHaveBeenCalled();
});
it("memory_save throws when content is missing", async () => {
const kv = new InMemoryKV();
await expect(
handleToolCall("memory_save", {}, kv),
).rejects.toThrow("content is required");
});
it("memory_save rejects non-string content safely (no runtime TypeError)", async () => {
const kv = new InMemoryKV();
// These would have crashed on .trim() before the type-guard fix.
for (const bogus of [42, {}, [], null, undefined, true]) {
await expect(
handleToolCall("memory_save", { content: bogus }, kv),
).rejects.toThrow("content is required");
}
});
it("memory_recall returns matching memories", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "TypeScript is great" }, kv);
await handleToolCall("memory_save", { content: "Python is also great" }, kv);
const result = await handleToolCall(
"memory_recall",
{ query: "typescript" },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].content).toBe("TypeScript is great");
});
it("memory_save accepts concepts/files as arrays (plugin skill format, #139)", async () => {
const kv = new InMemoryKV();
const result = await handleToolCall(
"memory_save",
{
content: "Use HMAC for API auth",
concepts: ["hmac", "api-auth", "security"],
files: ["src/auth.ts", "src/middleware.ts"],
},
kv,
);
const saved = JSON.parse(result.content[0].text);
const mem = await kv.get<{ concepts: string[]; files: string[] }>(
"mem:memories",
saved.saved,
);
expect(mem?.concepts).toEqual(["hmac", "api-auth", "security"]);
expect(mem?.files).toEqual(["src/auth.ts", "src/middleware.ts"]);
});
it("memory_save still accepts concepts/files as comma-separated strings (legacy)", async () => {
const kv = new InMemoryKV();
const result = await handleToolCall(
"memory_save",
{
content: "JWT refresh rotation",
concepts: "jwt, refresh, rotation",
files: "src/auth.ts",
},
kv,
);
const saved = JSON.parse(result.content[0].text);
const mem = await kv.get<{ concepts: string[]; files: string[] }>(
"mem:memories",
saved.saved,
);
expect(mem?.concepts).toEqual(["jwt", "refresh", "rotation"]);
expect(mem?.files).toEqual(["src/auth.ts"]);
});
it("memory_smart_search falls back to substring match in the standalone shim (#139)", async () => {
const kv = new InMemoryKV();
await handleToolCall(
"memory_save",
{ content: "Use bcrypt for password hashing" },
kv,
);
await handleToolCall(
"memory_save",
{ content: "Use argon2id for new projects" },
kv,
);
const result = await handleToolCall(
"memory_smart_search",
{ query: "bcrypt", limit: 5 },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.results).toHaveLength(1);
expect(parsed.results[0].content).toBe("Use bcrypt for password hashing");
});
it("memory_smart_search rejects empty query to prevent match-all in forget flow (#139)", async () => {
const kv = new InMemoryKV();
await handleToolCall("memory_save", { content: "anything" }, kv);
await expect(
handleToolCall("memory_smart_search", {}, kv),
).rejects.toThrow("query is required");
await expect(
handleToolCall("memory_smart_search", { query: "" }, kv),
).rejects.toThrow("query is required");
await expect(
handleToolCall("memory_smart_search", { query: " " }, kv),
).rejects.toThrow("query is required");
});
it("memory_smart_search searches files and concepts, not just title/content (#139)", async () => {
const kv = new InMemoryKV();
await handleToolCall(
"memory_save",
{
content: "generic note",
concepts: ["oauth", "token-rotation"],
files: ["src/auth/refresh.ts"],
},
kv,
);
await handleToolCall("memory_save", { content: "unrelated" }, kv);
// Find by file path
const byFile = JSON.parse(
(
await handleToolCall(
"memory_smart_search",
{ query: "src/auth/refresh.ts" },
kv,
)
).content[0].text,
);
expect(byFile.results).toHaveLength(1);
expect(byFile.results[0].files).toContain("src/auth/refresh.ts");
// Find by concept
const byConcept = JSON.parse(
(
await handleToolCall(
"memory_smart_search",
{ query: "token-rotation" },
kv,
)
).content[0].text,
);
expect(byConcept.results).toHaveLength(1);
});
it("memory_sessions honours the limit arg (#139)", async () => {
const kv = new InMemoryKV();
for (let i = 0; i < 5; i++) {
await kv.set("mem:sessions", `ses_${i}`, {
id: `ses_${i}`,
project: "demo",
});
}
const result = await handleToolCall(
"memory_sessions",
{ limit: 2 },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.sessions).toHaveLength(2);
});
it("parseLimit clamps bad/malicious limit values to a safe range", async () => {
const kv = new InMemoryKV();
for (let i = 0; i < 150; i++) {
await handleToolCall("memory_save", { content: `mem ${i}` }, kv);
}
// Negative / NaN / Infinity / string / object — all should fall back
// to the default (10) for memory_smart_search.
for (const bogus of [-1, NaN, Infinity, "abc", {}, true]) {
const r = await handleToolCall(
"memory_smart_search",
{ query: "mem", limit: bogus },
kv,
);
expect(JSON.parse(r.content[0].text).results).toHaveLength(10);
}
// An absurdly large limit gets clamped to MAX_LIMIT (100).
const huge = await handleToolCall(
"memory_smart_search",
{ query: "mem", limit: 99999 },
kv,
);
expect(JSON.parse(huge.content[0].text).results).toHaveLength(100);
});
it("memory_governance_delete removes memories by id array (#139)", async () => {
const kv = new InMemoryKV();
const a = JSON.parse(
(await handleToolCall("memory_save", { content: "one" }, kv)).content[0]
.text,
);
const b = JSON.parse(
(await handleToolCall("memory_save", { content: "two" }, kv)).content[0]
.text,
);
const c = JSON.parse(
(await handleToolCall("memory_save", { content: "three" }, kv)).content[0]
.text,
);
const result = await handleToolCall(
"memory_governance_delete",
{ memoryIds: [a.saved, c.saved] },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.deleted).toBe(2);
expect(parsed.requested).toBe(2);
const remaining = await kv.list<Record<string, unknown>>("mem:memories");
expect(remaining).toHaveLength(1);
expect((remaining[0] as { id: string }).id).toBe(b.saved);
});
it("memory_governance_delete accepts CSV-string memoryIds too", async () => {
const kv = new InMemoryKV();
const saved = JSON.parse(
(await handleToolCall("memory_save", { content: "x" }, kv)).content[0]
.text,
);
const result = await handleToolCall(
"memory_governance_delete",
{ memoryIds: saved.saved, reason: "test csv" },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.deleted).toBe(1);
expect(parsed.reason).toBe("test csv");
});
it("memory_governance_delete throws when memoryIds is missing or empty", async () => {
const kv = new InMemoryKV();
await expect(
handleToolCall("memory_governance_delete", {}, kv),
).rejects.toThrow("memoryIds is required");
await expect(
handleToolCall("memory_governance_delete", { memoryIds: [] }, kv),
).rejects.toThrow("memoryIds is required");
});
it("memory_governance_delete silently skips unknown ids", async () => {
const kv = new InMemoryKV();
const saved = JSON.parse(
(await handleToolCall("memory_save", { content: "real" }, kv)).content[0]
.text,
);
const result = await handleToolCall(
"memory_governance_delete",
{ memoryIds: [saved.saved, "mem_does_not_exist"] },
kv,
);
const parsed = JSON.parse(result.content[0].text);
expect(parsed.deleted).toBe(1);
expect(parsed.requested).toBe(2);
});
});